1413 lines
52 KiB
C#
1413 lines
52 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Tests.World;
|
|
|
|
public sealed class LiveEntityHydrationControllerTests
|
|
{
|
|
[Fact]
|
|
public void FreshCreate_PublishesCanonicalOrderAndReadyAfterMaterialization()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1);
|
|
fixture.Materializer.BeforeMaterialize = (_, _) =>
|
|
Assert.NotNull(fixture.Objects.Get(Guid));
|
|
|
|
fixture.Controller.OnCreate(spawn);
|
|
|
|
Assert.Equal(
|
|
["timestamps:1", "relationship:1", "materialize:LogicalRegistration:1", "ready"],
|
|
fixture.Operations);
|
|
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
|
Assert.NotNull(record.WorldEntity);
|
|
Assert.True(record.InitialHydrationCompleted);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownOrigin_DefersFirstMaterializationUntilLandblockRecovery()
|
|
{
|
|
using var fixture = new Fixture(originKnown: false);
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
|
|
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
|
Assert.Null(record.WorldEntity);
|
|
Assert.Equal(0, fixture.Resources.RegisterCount);
|
|
Assert.Empty(fixture.Materializer.Calls);
|
|
|
|
fixture.Origin.IsKnownValue = true;
|
|
fixture.Controller.OnLandblockLoaded(Cell);
|
|
|
|
Assert.NotNull(record.WorldEntity);
|
|
Assert.Single(fixture.Materializer.Calls);
|
|
Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[0].Purpose);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void InitialPlayerOriginRecovery_DoesNotReenterSameHydrationTransaction()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true, playerGuid: Guid);
|
|
fixture.Origin.AlreadyLoadedLandblocks = [Cell];
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
|
|
Assert.True(fixture.Record.InitialHydrationCompleted);
|
|
Assert.Single(fixture.Materializer.Calls);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadedCallback_RebucketsMaterializedIdentityWithoutReactivation()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
WorldEntity entity = record.WorldEntity!;
|
|
|
|
fixture.Controller.OnLandblockLoaded(Cell);
|
|
|
|
Assert.Same(entity, record.WorldEntity);
|
|
Assert.Single(fixture.Materializer.Calls);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppearanceAfterDeferredProjection_FirstMaterializesAndPublishesReady()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Materializer.SkipMaterializationCount = 1;
|
|
WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1);
|
|
fixture.Controller.OnCreate(spawn);
|
|
LiveEntityRecord record = fixture.Record;
|
|
Assert.Null(record.WorldEntity);
|
|
|
|
Assert.True(fixture.Controller.ApplyAppearance(
|
|
record,
|
|
record.Snapshot,
|
|
appearanceUpdate: null));
|
|
|
|
Assert.NotNull(record.WorldEntity);
|
|
Assert.Equal(2, fixture.Materializer.Calls.Count);
|
|
Assert.Equal(LiveProjectionPurpose.SpatialRecovery, fixture.Materializer.Calls[1].Purpose);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SameOlderNewerAndWrappedGenerations_RouteOrReplaceExactlyOnce()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 0xFFFF, PositionSequence: 1));
|
|
LiveEntityRecord first = fixture.Record;
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 0xFFFF, PositionSequence: 2));
|
|
Assert.Equal(1, fixture.Network.ApplyCount);
|
|
Assert.Same(first, fixture.Record);
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 0xFFFE, PositionSequence: 3));
|
|
Assert.Equal(1, fixture.Network.ApplyCount);
|
|
Assert.Same(first, fixture.Record);
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 0, PositionSequence: 4));
|
|
|
|
Assert.NotSame(first, fixture.Record);
|
|
Assert.Equal((ushort)0, fixture.Record.Generation);
|
|
Assert.Equal(2, fixture.Resources.RegisterCount);
|
|
Assert.Equal(1, fixture.Resources.UnregisterCount);
|
|
Assert.Equal(2, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void RelationshipCallback_ReplacesGuidAndSupersedesOuterProjection()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
bool replaced = false;
|
|
fixture.Relationships.OnSpawnAction = spawn =>
|
|
{
|
|
if (!replaced && spawn.InstanceSequence == 1)
|
|
{
|
|
replaced = true;
|
|
fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 2));
|
|
}
|
|
};
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
|
|
Assert.Equal((ushort)2, fixture.Record.Generation);
|
|
Assert.Single(fixture.Materializer.Calls);
|
|
Assert.Equal((ushort)2, fixture.Materializer.Calls[0].Generation);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void RegistrationFailure_RollsBackAndLandblockRecoveryRetriesOnce()
|
|
{
|
|
var resources = new FailingOnceResources();
|
|
using var fixture = new Fixture(originKnown: true, resources);
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)));
|
|
|
|
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
|
Assert.Null(record.WorldEntity);
|
|
Assert.Equal(1, resources.RegisterCount);
|
|
Assert.Equal(1, resources.UnregisterCount);
|
|
|
|
fixture.Controller.OnLandblockLoaded(Cell);
|
|
|
|
Assert.NotNull(record.WorldEntity);
|
|
Assert.Equal(2, resources.RegisterCount);
|
|
Assert.Equal(1, resources.UnregisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void SameGenerationRetransmit_RecoversAfterRegistrationTombstoneConverges()
|
|
{
|
|
var resources = new FailingRegisterAndRollbackOnceResources();
|
|
using var fixture = new Fixture(originKnown: true, resources);
|
|
WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1);
|
|
|
|
Assert.Throws<AggregateException>(() => fixture.Controller.OnCreate(spawn));
|
|
Assert.Equal(0, fixture.Runtime.Count);
|
|
Assert.Equal(1, fixture.Runtime.PendingTeardownCount);
|
|
Assert.Equal(1, fixture.Runtime.RetryPendingTeardowns());
|
|
|
|
fixture.Controller.OnCreate(spawn);
|
|
|
|
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord recovered));
|
|
Assert.NotNull(recovered.WorldEntity);
|
|
Assert.True(recovered.InitialHydrationCompleted);
|
|
Assert.Equal(2, resources.RegisterCount);
|
|
Assert.Equal(2, resources.UnregisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void PriorGenerationCleanupFailure_ReportsAfterReplacementIsFullyInstalled()
|
|
{
|
|
var resources = new FailingUnregisterOnceResources();
|
|
using var fixture = new Fixture(originKnown: true, resources);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
|
|
Assert.Throws<AggregateException>(() => fixture.Controller.OnCreate(Spawn(
|
|
Generation: 2,
|
|
PositionSequence: 2,
|
|
Name: "replacement")));
|
|
|
|
Assert.Equal((ushort)2, fixture.Record.Generation);
|
|
Assert.True(fixture.Record.InitialHydrationCompleted);
|
|
Assert.Equal("replacement", fixture.Objects.Get(Guid)!.Name);
|
|
Assert.Equal(1, fixture.Runtime.PendingTeardownCount);
|
|
Assert.Equal(1, fixture.Runtime.RetryPendingTeardowns());
|
|
Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void PartialProjection_IsRetriedInsteadOfMistakenForCompletedHydration()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true, playerGuid: Guid);
|
|
bool failOnce = true;
|
|
fixture.Materializer.AfterMaterialize = (_, _) =>
|
|
{
|
|
if (!failOnce)
|
|
return;
|
|
failOnce = false;
|
|
throw new InvalidOperationException("fixture post-registration failure");
|
|
};
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1)));
|
|
|
|
LiveEntityRecord record = fixture.Record;
|
|
WorldEntity partial = Assert.IsType<WorldEntity>(record.WorldEntity);
|
|
Assert.False(record.InitialHydrationCompleted);
|
|
|
|
// Local-player records ordinarily do not rebucket from streaming
|
|
// callbacks; an incomplete initial transaction must still recover.
|
|
fixture.Controller.OnLandblockLoaded(Cell);
|
|
|
|
Assert.Same(partial, record.WorldEntity);
|
|
Assert.True(record.InitialHydrationCompleted);
|
|
Assert.Equal(2, fixture.Materializer.Calls.Count);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void FailedReadyBarrier_RetriesSameProjectionOnRetransmit()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Ready.FailPublishCount = 1;
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
WorldEntity partial = Assert.IsType<WorldEntity>(record.WorldEntity);
|
|
Assert.False(record.InitialHydrationCompleted);
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 2));
|
|
|
|
Assert.Same(partial, record.WorldEntity);
|
|
Assert.True(record.InitialHydrationCompleted);
|
|
Assert.Equal(2, fixture.Materializer.Calls.Count);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(2, fixture.Ready.PublishCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TimestampCallbackReplacement_StopsOuterObjectAndProjectionTail()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
bool replaced = false;
|
|
fixture.PublishTimestampsAction = (_, timestamps) =>
|
|
{
|
|
if (replaced || timestamps.Instance != 1)
|
|
return;
|
|
replaced = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 2,
|
|
PositionSequence: 2,
|
|
Name: "generation 2"));
|
|
};
|
|
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 1,
|
|
Name: "generation 1"));
|
|
|
|
Assert.Equal((ushort)2, fixture.Record.Generation);
|
|
Assert.Equal("generation 2", fixture.Objects.Get(Guid)!.Name);
|
|
Assert.Single(fixture.Materializer.Calls);
|
|
Assert.Equal((ushort)2, fixture.Materializer.Calls[0].Generation);
|
|
}
|
|
|
|
[Fact]
|
|
public void TimestampCallbackFresherSameGeneration_StopsOuterCreateVersion()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
bool refreshed = false;
|
|
fixture.PublishTimestampsAction = (_, timestamps) =>
|
|
{
|
|
if (refreshed)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 2,
|
|
Name: "same generation newer"));
|
|
};
|
|
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 1,
|
|
Name: "same generation older"));
|
|
|
|
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
|
|
Assert.True(fixture.Record.InitialHydrationCompleted);
|
|
Assert.Single(fixture.Materializer.Calls);
|
|
Assert.Equal((ushort)2, fixture.Materializer.PositionSequences[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObjectRemovalCallbackNewerGeneration_CannotBeOverwrittenByOuterCreate()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 1,
|
|
Name: "generation 1"));
|
|
bool replaced = false;
|
|
fixture.Objects.ObjectRemovalClassified += removal =>
|
|
{
|
|
if (replaced
|
|
|| removal.Reason is not ClientObjectRemovalReason.GenerationReplacement)
|
|
{
|
|
return;
|
|
}
|
|
|
|
replaced = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 3,
|
|
PositionSequence: 3,
|
|
Name: "generation 3"));
|
|
};
|
|
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 2,
|
|
PositionSequence: 2,
|
|
Name: "generation 2"));
|
|
|
|
Assert.Equal((ushort)3, fixture.Record.Generation);
|
|
Assert.Equal("generation 3", fixture.Objects.Get(Guid)!.Name);
|
|
Assert.Equal((ushort)3, fixture.Materializer.Calls[^1].Generation);
|
|
Assert.DoesNotContain(
|
|
fixture.Materializer.Calls,
|
|
call => call.Generation == 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObjectRemovalCallbackFresherSameGeneration_WinsOuterReplacement()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 0,
|
|
PositionSequence: 1,
|
|
Name: "generation zero"));
|
|
bool refreshed = false;
|
|
fixture.Objects.ObjectRemovalClassified += removal =>
|
|
{
|
|
if (refreshed
|
|
|| removal.Reason is not ClientObjectRemovalReason.GenerationReplacement)
|
|
{
|
|
return;
|
|
}
|
|
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 3,
|
|
Name: "same generation newer"));
|
|
};
|
|
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 2,
|
|
Name: "same generation older"));
|
|
|
|
Assert.Equal((ushort)1, fixture.Record.Generation);
|
|
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
|
|
Assert.True(fixture.Record.InitialHydrationCompleted);
|
|
Assert.Equal((ushort)3, fixture.Materializer.PositionSequences[^1]);
|
|
Assert.DoesNotContain(
|
|
(ushort)2,
|
|
fixture.Materializer.PositionSequences.Skip(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void MaterializerCallbackFresherSameGeneration_ReplaysCanonicalHydrationOnce()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
bool refreshed = false;
|
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
|
{
|
|
if (refreshed || spawn.PositionSequence != 1)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 2,
|
|
Name: "same generation newer"));
|
|
};
|
|
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 1,
|
|
Name: "same generation older"));
|
|
|
|
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
|
|
Assert.Equal([1, 2], fixture.Materializer.PositionSequences);
|
|
Assert.Equal(
|
|
LiveProjectionPurpose.CreateSupersessionRecovery,
|
|
fixture.Materializer.Calls[^1].Purpose);
|
|
Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(1, fixture.Ready.PublishCount);
|
|
Assert.True(fixture.Record.InitialHydrationCompleted);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReadyCallbackFresherSameGeneration_ReplaysCanonicalHydrationBeforeCompletion()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
bool refreshed = false;
|
|
fixture.Ready.BeforePublish = _ =>
|
|
{
|
|
if (refreshed)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 2,
|
|
Name: "same generation newer"));
|
|
};
|
|
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 1,
|
|
Name: "same generation older"));
|
|
|
|
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
|
|
Assert.Equal([1, 2], fixture.Materializer.PositionSequences);
|
|
Assert.Equal(
|
|
LiveProjectionPurpose.CreateSupersessionRecovery,
|
|
fixture.Materializer.Calls[^1].Purpose);
|
|
Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(2, fixture.Ready.PublishCount);
|
|
Assert.True(fixture.Record.InitialHydrationCompleted);
|
|
}
|
|
|
|
[Fact]
|
|
public void CompletedSpatialRecovery_DetectsCreateVersionDriftWithoutNestedHydrationRequest()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
ulong stalePositionAuthority = record.PositionAuthorityVersion;
|
|
bool refreshed = false;
|
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
|
{
|
|
if (refreshed || spawn.PositionSequence != 1)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 2,
|
|
Name: "same generation newer"));
|
|
};
|
|
|
|
Assert.False(fixture.Controller.RecoverProjection(
|
|
record,
|
|
stalePositionAuthority,
|
|
record.Snapshot));
|
|
|
|
Assert.True(record.InitialHydrationCompleted);
|
|
Assert.Equal("same generation newer", fixture.Objects.Get(Guid)!.Name);
|
|
Assert.Equal([1, 1, 2], fixture.Materializer.PositionSequences);
|
|
Assert.Equal(
|
|
LiveProjectionPurpose.CreateSupersessionRecovery,
|
|
fixture.Materializer.Calls[^1].Purpose);
|
|
Assert.Equal(2UL, fixture.Materializer.InstalledCreateIntegrationVersion);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void FailedCompletedSupersession_RemainsPendingUntilExactRetry(
|
|
bool retryFromLandblock)
|
|
{
|
|
using var fixture = new Fixture(
|
|
originKnown: true,
|
|
playerGuid: retryFromLandblock ? Guid : 0u);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
WorldEntity retained = record.WorldEntity!;
|
|
ulong positionAuthority = record.PositionAuthorityVersion;
|
|
bool refreshed = false;
|
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
|
{
|
|
if (refreshed || spawn.PositionSequence != 1)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 2,
|
|
Name: "recovery v2"));
|
|
};
|
|
fixture.Materializer.ThrowAfterMaterializePurposeOnce =
|
|
LiveProjectionPurpose.CreateSupersessionRecovery;
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Controller.RecoverProjection(
|
|
record,
|
|
positionAuthority,
|
|
record.Snapshot));
|
|
|
|
Assert.True(record.InitialHydrationCompleted);
|
|
Assert.True(record.CreateProjectionSynchronizationPending);
|
|
Assert.Same(retained, record.WorldEntity);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
|
|
fixture.Materializer.BeforeMaterialize = null;
|
|
if (retryFromLandblock)
|
|
{
|
|
fixture.Controller.OnLandblockLoaded(Cell);
|
|
}
|
|
else
|
|
{
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 3,
|
|
Name: "recovery v3"));
|
|
}
|
|
|
|
Assert.False(record.CreateProjectionSynchronizationPending);
|
|
Assert.Same(retained, record.WorldEntity);
|
|
Assert.Equal(
|
|
retryFromLandblock ? 2UL : 3UL,
|
|
fixture.Materializer.InstalledCreateIntegrationVersion);
|
|
Assert.Equal(
|
|
LiveProjectionPurpose.CreateSupersessionRecovery,
|
|
fixture.Materializer.Calls[^1].Purpose);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(0, fixture.Resources.UnregisterCount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void StaleProjectionExceptionAfterCreateDrift_PersistsExactRetryObligation(
|
|
bool retryFromLandblock)
|
|
{
|
|
using var fixture = new Fixture(
|
|
originKnown: true,
|
|
playerGuid: retryFromLandblock ? Guid : 0u);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
WorldEntity retained = record.WorldEntity!;
|
|
ulong positionAuthority = record.PositionAuthorityVersion;
|
|
bool refreshed = false;
|
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
|
{
|
|
if (refreshed || spawn.PositionSequence != 1)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 2,
|
|
Name: "drift v2"));
|
|
throw new InvalidOperationException("fixture stale projection failure");
|
|
};
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
fixture.Controller.RecoverProjection(
|
|
record,
|
|
positionAuthority,
|
|
record.Snapshot));
|
|
|
|
Assert.True(record.InitialHydrationCompleted);
|
|
Assert.True(record.CreateProjectionSynchronizationPending);
|
|
Assert.Same(retained, record.WorldEntity);
|
|
|
|
fixture.Materializer.BeforeMaterialize = null;
|
|
if (retryFromLandblock)
|
|
{
|
|
fixture.Controller.OnLandblockLoaded(Cell);
|
|
}
|
|
else
|
|
{
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 3,
|
|
Name: "drift v3"));
|
|
}
|
|
|
|
Assert.False(record.CreateProjectionSynchronizationPending);
|
|
Assert.Same(retained, record.WorldEntity);
|
|
Assert.Equal(
|
|
LiveProjectionPurpose.CreateSupersessionRecovery,
|
|
fixture.Materializer.Calls[^1].Purpose);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(0, fixture.Resources.UnregisterCount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void CompletedSupersessionReadyFailure_RetriesWholeCommitBoundary(
|
|
bool retryFromLandblock)
|
|
{
|
|
using var fixture = new Fixture(
|
|
originKnown: true,
|
|
playerGuid: retryFromLandblock ? Guid : 0u);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
WorldEntity retained = record.WorldEntity!;
|
|
ulong positionAuthority = record.PositionAuthorityVersion;
|
|
bool refreshed = false;
|
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
|
{
|
|
if (refreshed || spawn.PositionSequence != 1)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 2,
|
|
Name: "ready v2"));
|
|
};
|
|
fixture.Ready.FailPublishCount = 1;
|
|
|
|
Assert.False(fixture.Controller.RecoverProjection(
|
|
record,
|
|
positionAuthority,
|
|
record.Snapshot));
|
|
|
|
Assert.True(record.CreateProjectionSynchronizationPending);
|
|
fixture.Materializer.BeforeMaterialize = null;
|
|
if (retryFromLandblock)
|
|
{
|
|
fixture.Controller.OnLandblockLoaded(Cell);
|
|
}
|
|
else
|
|
{
|
|
fixture.Controller.OnCreate(Spawn(
|
|
Generation: 1,
|
|
PositionSequence: 3,
|
|
Name: "ready v3"));
|
|
}
|
|
|
|
Assert.False(record.CreateProjectionSynchronizationPending);
|
|
Assert.Same(retained, record.WorldEntity);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(0, fixture.Resources.UnregisterCount);
|
|
Assert.True(fixture.Ready.PublishCount >= 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickupSupersession_CompletesCelllessWithoutDatMaterialization()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
ulong positionAuthority = record.PositionAuthorityVersion;
|
|
fixture.Network.ApplyAction = events =>
|
|
{
|
|
Assert.NotNull(events.Pickup);
|
|
Assert.True(fixture.Runtime.TryApplyPickup(events.Pickup!.Value, out _));
|
|
Assert.True(fixture.Runtime.WithdrawLiveEntityProjectionToCellless(Guid));
|
|
};
|
|
bool refreshed = false;
|
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
|
{
|
|
if (refreshed || spawn.PositionSequence != 1)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(CelllessSpawn(
|
|
PositionSequence: 2,
|
|
parentGuid: null));
|
|
};
|
|
|
|
Assert.False(fixture.Controller.RecoverProjection(
|
|
record,
|
|
positionAuthority,
|
|
record.Snapshot));
|
|
|
|
Assert.False(record.CreateProjectionSynchronizationPending);
|
|
Assert.Equal(0u, record.FullCellId);
|
|
Assert.False(record.IsSpatiallyProjected);
|
|
Assert.DoesNotContain(
|
|
fixture.Materializer.Calls,
|
|
call => call.Purpose is LiveProjectionPurpose.CreateSupersessionRecovery);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickupSupersedesInitialHydration_WithoutInventingRenderOwner()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Network.ApplyAction = events =>
|
|
{
|
|
Assert.NotNull(events.Pickup);
|
|
Assert.True(fixture.Runtime.TryApplyPickup(events.Pickup!.Value, out _));
|
|
};
|
|
bool refreshed = false;
|
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
|
{
|
|
if (refreshed || spawn.PositionSequence != 1)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(CelllessSpawn(
|
|
PositionSequence: 2,
|
|
parentGuid: null));
|
|
};
|
|
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
|
|
LiveEntityRecord record = fixture.Record;
|
|
Assert.False(record.CreateProjectionSynchronizationPending);
|
|
Assert.False(record.InitialHydrationCompleted);
|
|
Assert.Null(record.WorldEntity);
|
|
Assert.Equal(0, fixture.Resources.RegisterCount);
|
|
|
|
fixture.Materializer.BeforeMaterialize = null;
|
|
fixture.Network.ApplyAction = events =>
|
|
{
|
|
if (events.Position is { } position)
|
|
{
|
|
fixture.Runtime.TryApplyPosition(
|
|
position,
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out _,
|
|
out _,
|
|
out _);
|
|
}
|
|
};
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 3));
|
|
|
|
Assert.True(record.InitialHydrationCompleted);
|
|
Assert.NotNull(record.WorldEntity);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void ParentSupersession_CompletesAtExactAttachedReadyBoundary(
|
|
bool failFirstReadyAttempt)
|
|
{
|
|
const uint parentGuid = 0x70000002u;
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
WorldEntity retained = record.WorldEntity!;
|
|
ulong positionAuthority = record.PositionAuthorityVersion;
|
|
fixture.Ready.FailPublishCount = failFirstReadyAttempt ? 2 : 0;
|
|
CreateParentUpdate? pendingParent = null;
|
|
fixture.Network.ApplyAction = events =>
|
|
{
|
|
Assert.NotNull(events.Parent);
|
|
Assert.True(fixture.Runtime.TryApplyCreateParent(
|
|
events.Parent!.Value,
|
|
out _));
|
|
pendingParent = events.Parent.Value;
|
|
};
|
|
fixture.Relationships.OnSpawnAction = _ =>
|
|
{
|
|
if (pendingParent is not { } parent
|
|
|| !record.CreateProjectionSynchronizationPending)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Assert.True(fixture.Runtime.CommitStagedParent(
|
|
new ParentAttachmentRelation(
|
|
parent.ParentGuid,
|
|
parent.ChildGuid,
|
|
parent.ParentLocation,
|
|
parent.PlacementId,
|
|
ParentInstanceSequence: 0,
|
|
parent.ChildPositionSequence),
|
|
out _));
|
|
ulong positionVersion = record.PositionAuthorityVersion;
|
|
Assert.True(fixture.Runtime.CommitAcceptedParentCellless(
|
|
record,
|
|
positionVersion));
|
|
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record));
|
|
WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity(
|
|
Guid,
|
|
Cell,
|
|
_ => throw new InvalidOperationException("retained identity expected"),
|
|
LiveEntityProjectionKind.Attached);
|
|
Assert.Same(retained, attached);
|
|
fixture.Controller.OnEntityReady(
|
|
new LiveEntityReadyCandidate(
|
|
record,
|
|
record.CreateIntegrationVersion));
|
|
};
|
|
bool refreshed = false;
|
|
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
|
|
{
|
|
if (refreshed || spawn.PositionSequence != 1)
|
|
return;
|
|
refreshed = true;
|
|
fixture.Controller.OnCreate(CelllessSpawn(
|
|
PositionSequence: 2,
|
|
parentGuid));
|
|
};
|
|
|
|
Assert.False(fixture.Controller.RecoverProjection(
|
|
record,
|
|
positionAuthority,
|
|
record.Snapshot));
|
|
|
|
if (failFirstReadyAttempt)
|
|
{
|
|
Assert.True(record.CreateProjectionSynchronizationPending);
|
|
fixture.Materializer.BeforeMaterialize = null;
|
|
fixture.Controller.OnCreate(CelllessSpawn(
|
|
PositionSequence: 3,
|
|
parentGuid));
|
|
}
|
|
|
|
Assert.False(record.CreateProjectionSynchronizationPending);
|
|
Assert.Equal(LiveEntityProjectionKind.Attached, record.ProjectionKind);
|
|
Assert.Same(retained, record.WorldEntity);
|
|
Assert.DoesNotContain(
|
|
fixture.Materializer.Calls,
|
|
call => call.Purpose is LiveProjectionPurpose.CreateSupersessionRecovery);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(false)]
|
|
[InlineData(true)]
|
|
public void InitialParentReadyFailure_RetriesCelllessReadyBoundary(
|
|
bool retryFromLandblock)
|
|
{
|
|
const uint parentGuid = 0x70000002u;
|
|
using var fixture = new Fixture(
|
|
originKnown: true,
|
|
playerGuid: retryFromLandblock ? Guid : 0u);
|
|
fixture.Ready.FailPublishCount = 2;
|
|
fixture.Network.ApplyAction = events =>
|
|
{
|
|
if (events.Parent is { } parent)
|
|
fixture.Runtime.TryApplyCreateParent(parent, out _);
|
|
};
|
|
fixture.Relationships.OnSpawnAction = _ =>
|
|
{
|
|
LiveEntityRecord record = fixture.Record;
|
|
if (record.WorldEntity is not null)
|
|
return;
|
|
|
|
WorldEntity? attached = fixture.Runtime.MaterializeLiveEntity(
|
|
Guid,
|
|
Cell,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = Guid,
|
|
SourceGfxObjOrSetupId = 0x02000001u,
|
|
Position = Vector3.Zero,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = [],
|
|
ParentCellId = Cell,
|
|
},
|
|
LiveEntityProjectionKind.Attached);
|
|
Assert.NotNull(attached);
|
|
fixture.Controller.OnEntityReady(new LiveEntityReadyCandidate(
|
|
record,
|
|
record.CreateIntegrationVersion));
|
|
};
|
|
|
|
fixture.Controller.OnCreate(CelllessSpawn(
|
|
PositionSequence: 1,
|
|
parentGuid));
|
|
|
|
LiveEntityRecord initial = fixture.Record;
|
|
Assert.False(initial.InitialHydrationCompleted);
|
|
Assert.Equal(LiveEntityProjectionKind.Attached, initial.ProjectionKind);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
|
|
if (retryFromLandblock)
|
|
{
|
|
fixture.Controller.OnLandblockLoaded(Cell);
|
|
}
|
|
else
|
|
{
|
|
fixture.Controller.OnCreate(CelllessSpawn(
|
|
PositionSequence: 2,
|
|
parentGuid));
|
|
}
|
|
|
|
Assert.True(initial.InitialHydrationCompleted);
|
|
Assert.False(initial.CreateProjectionSynchronizationPending);
|
|
Assert.Equal(1, fixture.Resources.RegisterCount);
|
|
Assert.Equal(0, fixture.Resources.UnregisterCount);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 1, 0, 0)]
|
|
[InlineData(1, 1, 1, 0)]
|
|
[InlineData(2, 1, 1, 1)]
|
|
public void ReadyPublisher_RevalidatesExactRecordBetweenEveryStage(
|
|
int replacementStage,
|
|
int expectedPrepare,
|
|
int expectedPresent,
|
|
int expectedReplay)
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord expected = fixture.Record;
|
|
int prepare = 0;
|
|
int present = 0;
|
|
int replay = 0;
|
|
bool replaced = false;
|
|
bool Stage(int stage)
|
|
{
|
|
if (!replaced && replacementStage == stage)
|
|
{
|
|
replaced = true;
|
|
fixture.Runtime.RegisterLiveEntity(
|
|
Spawn(Generation: 1, PositionSequence: 2));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
var publisher = new LiveEntityReadyPublisher(
|
|
fixture.Runtime,
|
|
_ => { prepare++; return Stage(0); },
|
|
_ => { present++; return Stage(1); },
|
|
_ => { replay++; return Stage(2); });
|
|
|
|
Assert.False(publisher.Publish(expected));
|
|
Assert.Equal(expectedPrepare, prepare);
|
|
Assert.Equal(expectedPresent, present);
|
|
Assert.Equal(expectedReplay, replay);
|
|
Assert.Same(expected, fixture.Record);
|
|
Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public void EquippedChildReadyCandidate_RejectsVersionAdvancedByPoseCallback()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
WorldEntity entity = record.WorldEntity!;
|
|
ulong capturedCreateIntegrationVersion = record.CreateIntegrationVersion;
|
|
|
|
// Models ProjectionPoseReady synchronously accepting a fresher
|
|
// same-generation CreateObject before EntityReady is emitted.
|
|
fixture.Runtime.RegisterLiveEntity(
|
|
Spawn(Generation: 1, PositionSequence: 2));
|
|
bool published = false;
|
|
|
|
Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
|
|
fixture.Runtime,
|
|
record,
|
|
capturedCreateIntegrationVersion,
|
|
entity,
|
|
_ => published = true));
|
|
Assert.False(published);
|
|
}
|
|
|
|
[Fact]
|
|
public void PositionAuthorityGuard_PreventsStaleRecoveryAfterNestedUpdate()
|
|
{
|
|
using var fixture = new Fixture(originKnown: true);
|
|
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
|
|
LiveEntityRecord record = fixture.Record;
|
|
fixture.Runtime.WithdrawLiveEntityProjection(Guid);
|
|
ulong staleVersion = record.PositionAuthorityVersion;
|
|
|
|
Assert.True(fixture.Runtime.TryApplyPosition(
|
|
new WorldSession.EntityPositionUpdate(
|
|
Guid,
|
|
new CreateObject.ServerPosition(Cell, 12f, 10f, 5f, 1f, 0f, 0f, 0f),
|
|
Velocity: null,
|
|
PlacementId: 0,
|
|
IsGrounded: true,
|
|
InstanceSequence: 1,
|
|
PositionSequence: 2,
|
|
TeleportSequence: 0,
|
|
ForcePositionSequence: 0),
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out _,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out _));
|
|
|
|
Assert.False(fixture.Controller.RecoverProjection(
|
|
record,
|
|
staleVersion,
|
|
accepted));
|
|
Assert.Empty(fixture.Runtime.MaterializedWorldEntities);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeferredNetworkSink_IsSingleAssignmentAndFailsBeforeBind()
|
|
{
|
|
var deferred = new DeferredLiveEntityNetworkUpdateSink();
|
|
SameGenerationCreateObjectEvents events = default;
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
deferred.ApplySameGeneration(events));
|
|
|
|
var first = new RecordingNetworkSink();
|
|
deferred.Bind(first);
|
|
deferred.ApplySameGeneration(events);
|
|
|
|
Assert.Equal(1, first.ApplyCount);
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
deferred.Bind(new RecordingNetworkSink()));
|
|
}
|
|
|
|
private const uint Guid = 0x70000001u;
|
|
private const uint Cell = 0x01010001u;
|
|
|
|
private sealed class Fixture : IDisposable
|
|
{
|
|
public readonly List<string> Operations = [];
|
|
public readonly ClientObjectTable Objects = new();
|
|
public readonly RecordingResources Resources;
|
|
public readonly LiveEntityRuntime Runtime;
|
|
public readonly RecordingMaterializer Materializer;
|
|
public readonly RecordingRelationships Relationships;
|
|
public readonly RecordingReadyPublisher Ready;
|
|
public readonly RecordingOrigin Origin;
|
|
public readonly RecordingNetworkSink Network = new();
|
|
public readonly LiveEntityHydrationController Controller;
|
|
|
|
public Action<uint, AcceptedPhysicsTimestamps>? PublishTimestampsAction { get; set; }
|
|
|
|
public Fixture(
|
|
bool originKnown,
|
|
RecordingResources? resources = null,
|
|
uint playerGuid = 0u)
|
|
{
|
|
Resources = resources ?? new RecordingResources();
|
|
var spatial = new GpuWorldState();
|
|
spatial.AddLandblock(new LoadedLandblock(
|
|
0x0101FFFFu,
|
|
new DatReaderWriter.DBObjs.LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
Runtime = new LiveEntityRuntime(
|
|
spatial,
|
|
Resources,
|
|
new DelegateLiveEntityRuntimeComponentLifecycle(_ => { }));
|
|
Materializer = new RecordingMaterializer(Runtime, Operations);
|
|
Relationships = new RecordingRelationships(Operations);
|
|
Ready = new RecordingReadyPublisher(Operations);
|
|
Origin = new RecordingOrigin(originKnown);
|
|
Network.ApplyAction = events =>
|
|
{
|
|
if (events.Position is not { } position)
|
|
return;
|
|
|
|
Runtime.TryApplyPosition(
|
|
position,
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out _,
|
|
out _,
|
|
out _);
|
|
};
|
|
Controller = new LiveEntityHydrationController(
|
|
Runtime,
|
|
Objects,
|
|
new object(),
|
|
Materializer,
|
|
Relationships,
|
|
Ready,
|
|
Origin,
|
|
Network,
|
|
(guid, timestamps) =>
|
|
{
|
|
Operations.Add($"timestamps:{timestamps.Instance}");
|
|
PublishTimestampsAction?.Invoke(guid, timestamps);
|
|
},
|
|
() => playerGuid);
|
|
}
|
|
|
|
public LiveEntityRecord Record
|
|
{
|
|
get
|
|
{
|
|
Assert.True(Runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
|
return record;
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
Runtime.Clear();
|
|
}
|
|
catch
|
|
{
|
|
// Individual failure-path tests assert the relevant exception.
|
|
}
|
|
}
|
|
}
|
|
|
|
private class RecordingResources : ILiveEntityResourceLifecycle
|
|
{
|
|
public int RegisterCount { get; protected set; }
|
|
public int UnregisterCount { get; protected set; }
|
|
public virtual void Register(WorldEntity entity) => RegisterCount++;
|
|
public virtual void Unregister(WorldEntity entity) => UnregisterCount++;
|
|
}
|
|
|
|
private sealed class FailingOnceResources : RecordingResources
|
|
{
|
|
private bool _fail = true;
|
|
|
|
public override void Register(WorldEntity entity)
|
|
{
|
|
base.Register(entity);
|
|
if (_fail)
|
|
{
|
|
_fail = false;
|
|
throw new InvalidOperationException("fixture registration failure");
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class FailingRegisterAndRollbackOnceResources : RecordingResources
|
|
{
|
|
private bool _failRegister = true;
|
|
private bool _failRollback = true;
|
|
|
|
public override void Register(WorldEntity entity)
|
|
{
|
|
base.Register(entity);
|
|
if (_failRegister)
|
|
{
|
|
_failRegister = false;
|
|
throw new InvalidOperationException("fixture registration failure");
|
|
}
|
|
}
|
|
|
|
public override void Unregister(WorldEntity entity)
|
|
{
|
|
base.Unregister(entity);
|
|
if (_failRollback)
|
|
{
|
|
_failRollback = false;
|
|
throw new InvalidOperationException("fixture rollback failure");
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class FailingUnregisterOnceResources : RecordingResources
|
|
{
|
|
private bool _fail = true;
|
|
|
|
public override void Unregister(WorldEntity entity)
|
|
{
|
|
base.Unregister(entity);
|
|
if (_fail)
|
|
{
|
|
_fail = false;
|
|
throw new InvalidOperationException("fixture generation cleanup failure");
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingMaterializer(
|
|
LiveEntityRuntime runtime,
|
|
List<string> operations) : ILiveEntityProjectionMaterializer
|
|
{
|
|
public readonly List<(LiveProjectionPurpose Purpose, ushort Generation)> Calls = [];
|
|
public readonly List<ushort> PositionSequences = [];
|
|
public ulong? InstalledCreateIntegrationVersion { get; private set; }
|
|
public Action<LiveEntityRecord, WorldSession.EntitySpawn>? BeforeMaterialize { get; set; }
|
|
public Action<LiveEntityRecord, WorldSession.EntitySpawn>? AfterMaterialize { get; set; }
|
|
public LiveProjectionPurpose? ThrowAfterMaterializePurposeOnce { get; set; }
|
|
public int SkipMaterializationCount { get; set; }
|
|
|
|
public bool TryMaterialize(
|
|
LiveEntityRecord expectedRecord,
|
|
WorldSession.EntitySpawn canonicalSpawn,
|
|
LiveProjectionPurpose purpose,
|
|
ulong expectedCreateIntegrationVersion,
|
|
AcDream.App.Rendering.LiveEntityAppearanceUpdateState? appearanceUpdate = null)
|
|
{
|
|
Calls.Add((purpose, expectedRecord.Generation));
|
|
PositionSequences.Add(canonicalSpawn.PositionSequence);
|
|
operations.Add($"materialize:{purpose}:{expectedRecord.Generation}");
|
|
BeforeMaterialize?.Invoke(expectedRecord, canonicalSpawn);
|
|
if (!runtime.IsCurrentCreateIntegration(
|
|
expectedRecord,
|
|
expectedCreateIntegrationVersion))
|
|
{
|
|
return false;
|
|
}
|
|
if (InstalledCreateIntegrationVersion is null
|
|
|| purpose is LiveProjectionPurpose.CreateSupersessionRecovery)
|
|
{
|
|
InstalledCreateIntegrationVersion = expectedCreateIntegrationVersion;
|
|
}
|
|
if (SkipMaterializationCount > 0)
|
|
{
|
|
SkipMaterializationCount--;
|
|
return false;
|
|
}
|
|
if (canonicalSpawn.Position is not { } position
|
|
|| canonicalSpawn.SetupTableId is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
WorldEntity? entity = runtime.MaterializeLiveEntity(
|
|
canonicalSpawn.Guid,
|
|
position.LandblockId,
|
|
id => new WorldEntity
|
|
{
|
|
Id = id,
|
|
ServerGuid = canonicalSpawn.Guid,
|
|
SourceGfxObjOrSetupId = canonicalSpawn.SetupTableId.Value,
|
|
Position = new Vector3(position.PositionX, position.PositionY, position.PositionZ),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = [],
|
|
ParentCellId = position.LandblockId,
|
|
});
|
|
if (ThrowAfterMaterializePurposeOnce == purpose)
|
|
{
|
|
ThrowAfterMaterializePurposeOnce = null;
|
|
throw new InvalidOperationException(
|
|
"fixture supersession projection failure");
|
|
}
|
|
AfterMaterialize?.Invoke(expectedRecord, canonicalSpawn);
|
|
return entity is not null
|
|
&& runtime.IsCurrentRecord(expectedRecord);
|
|
}
|
|
|
|
public void ResetSessionState() { }
|
|
}
|
|
|
|
private sealed class RecordingRelationships(List<string> operations)
|
|
: ILiveEntitySpawnRelationshipSink
|
|
{
|
|
public Action<WorldSession.EntitySpawn>? OnSpawnAction { get; set; }
|
|
public void OnSpawn(WorldSession.EntitySpawn spawn)
|
|
{
|
|
operations.Add($"relationship:{spawn.InstanceSequence}");
|
|
OnSpawnAction?.Invoke(spawn);
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingReadyPublisher(List<string> operations)
|
|
: ILiveEntityReadyPublisher
|
|
{
|
|
public int PublishCount { get; private set; }
|
|
public int FailPublishCount { get; set; }
|
|
public Action<LiveEntityRecord>? BeforePublish { get; set; }
|
|
public bool Publish(LiveEntityRecord expectedRecord)
|
|
{
|
|
PublishCount++;
|
|
operations.Add("ready");
|
|
BeforePublish?.Invoke(expectedRecord);
|
|
if (FailPublishCount > 0)
|
|
{
|
|
FailPublishCount--;
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingOrigin(bool isKnown) : ILiveEntityWorldOriginCoordinator
|
|
{
|
|
public bool IsKnownValue { get; set; } = isKnown;
|
|
public IReadOnlyList<uint> AlreadyLoadedLandblocks { get; set; } = [];
|
|
public bool IsKnown => IsKnownValue;
|
|
public LiveEntityOriginInitialization TryInitialize(WorldSession.EntitySpawn spawn) =>
|
|
new(IsKnownValue, AlreadyLoadedLandblocks);
|
|
}
|
|
|
|
private sealed class RecordingNetworkSink : ILiveEntityNetworkUpdateSink
|
|
{
|
|
public int ApplyCount { get; private set; }
|
|
public Action<SameGenerationCreateObjectEvents>? ApplyAction { get; set; }
|
|
public void ApplySameGeneration(SameGenerationCreateObjectEvents events)
|
|
{
|
|
ApplyCount++;
|
|
ApplyAction?.Invoke(events);
|
|
}
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
ushort Generation,
|
|
ushort PositionSequence,
|
|
string Name = "fixture")
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
PositionSequence, 1, 1, 1, 0, 1, 0, 1, Generation);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
Guid,
|
|
position,
|
|
0x02000001u,
|
|
[],
|
|
[],
|
|
[],
|
|
null,
|
|
null,
|
|
Name,
|
|
(uint)ItemType.Creature,
|
|
null,
|
|
0x09000001u,
|
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: Generation,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: PositionSequence,
|
|
Physics: physics);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn CelllessSpawn(
|
|
ushort PositionSequence,
|
|
uint? parentGuid)
|
|
{
|
|
var timestamps = new PhysicsTimestamps(
|
|
PositionSequence, 1, 1, 1, 0, 1, 0, 1, 1);
|
|
PhysicsAttachment? parent = parentGuid is { } guid
|
|
? new PhysicsAttachment(guid, LocationId: 1u)
|
|
: null;
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: null,
|
|
Movement: null,
|
|
AnimationFrame: parentGuid is null ? null : 1u,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: parent,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
Guid,
|
|
Position: null,
|
|
SetupTableId: 0x02000001u,
|
|
AnimPartChanges: [],
|
|
TextureChanges: [],
|
|
SubPalettes: [],
|
|
BasePaletteId: null,
|
|
ObjScale: null,
|
|
Name: parentGuid is null ? "pickup" : "parented",
|
|
ItemType: (uint)ItemType.Creature,
|
|
MotionState: null,
|
|
MotionTableId: 0x09000001u,
|
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: 1,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: PositionSequence,
|
|
ParentGuid: parentGuid,
|
|
ParentLocation: parentGuid is null ? null : 1u,
|
|
PlacementId: parentGuid is null ? null : 1u,
|
|
Physics: physics);
|
|
}
|
|
}
|