acdream/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs
Erik e91f310279 refactor(update): cut over the frame orchestrator
Reduce GameWindow.OnUpdate to one typed orchestration call and remove transitive window callbacks from teardown, liveness, teleport placement, live presentation, auto-entry, and entity packet routing. Preserve the frozen retail object/network order while making the production owner graph explicit and testable.
2026-07-22 03:31:38 +02:00

2057 lines
77 KiB
C#

using System.Numerics;
using AcDream.App.Input;
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 ParentAcceptanceBridge_IsFailFastAndSingleAssignment()
{
var bridge = new DeferredLiveEntityParentAcceptance();
var update = new ParentEvent.Parsed(1, 2, 3, 4, 5, 6);
Assert.Throws<InvalidOperationException>(() => bridge.TryAccept(update));
ParentEvent.Parsed accepted = default;
bridge.Bind(value =>
{
accepted = value;
return true;
});
Assert.True(bridge.TryAccept(update));
Assert.Equal(update, accepted);
Assert.Throws<InvalidOperationException>(() => bridge.Bind(_ => false));
}
[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.OnAppearance(ObjDesc(2, 0x04000022u)));
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 AppearanceUpdate_ReplacesOnlyProjectionAndPreservesExactOwners()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
WorldEntity entity = record.WorldEntity!;
int applied = 0;
fixture.Controller.AppearanceApplied += _ => applied++;
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
Assert.Same(record, fixture.Record);
Assert.Same(entity, record.WorldEntity);
Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId);
Assert.False(record.AppearanceProjectionSynchronizationPending);
Assert.Equal(LiveProjectionPurpose.AppearanceMutation, fixture.Materializer.Calls[^1].Purpose);
Assert.Equal(1, fixture.Resources.RegisterCount);
Assert.Equal(0, fixture.Resources.UnregisterCount);
Assert.Equal(1, applied);
}
[Fact]
public void NestedAppearanceUpdate_ReplaysNewestCanonicalSnapshotAfterOuterPublication()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
int applied = 0;
fixture.Controller.AppearanceApplied += _ => applied++;
bool nested = false;
bool nestedResult = true;
fixture.Materializer.BeforeMaterialize = (_, _) =>
{
if (fixture.Materializer.Calls[^1].Purpose is not LiveProjectionPurpose.AppearanceMutation
|| nested)
{
return;
}
nested = true;
nestedResult = fixture.Controller.OnAppearance(ObjDesc(3, 0x04000033u));
};
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
Assert.False(nestedResult);
Assert.Equal((uint)0x04000033u, fixture.Record.Snapshot.BasePaletteId);
Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
Assert.Equal(
2,
fixture.Materializer.Calls.Count(call =>
call.Purpose is LiveProjectionPurpose.AppearanceMutation));
Assert.Equal(1, applied);
}
[Fact]
public void AppearancePublicationFailure_RetainsObligationForLandblockRetry()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
int applied = 0;
fixture.Controller.AppearanceApplied += _ => applied++;
fixture.Materializer.ThrowAfterMaterializePurposeOnce =
LiveProjectionPurpose.AppearanceMutation;
Assert.Throws<InvalidOperationException>(() =>
fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
Assert.True(fixture.Record.AppearanceProjectionSynchronizationPending);
Assert.Equal(0, applied);
fixture.Controller.OnLandblockLoaded(Cell);
Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
Assert.Equal((uint)0x04000022u, fixture.Record.Snapshot.BasePaletteId);
Assert.Equal(1, fixture.Resources.RegisterCount);
Assert.Equal(1, applied);
}
[Fact]
public void AttachedAppearance_UsesRelationshipProjectionWithoutWorldPosition()
{
const uint parentGuid = 0x70000002u;
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
WorldEntity entity = record.WorldEntity!;
Assert.True(fixture.Runtime.TryApplyCreateParent(
new CreateParentUpdate(Guid, parentGuid, 1, 0, 1, 2),
out _));
Assert.True(fixture.Runtime.CommitStagedParent(
new ParentAttachmentRelation(
parentGuid,
Guid,
ParentLocation: 1,
PlacementId: 0,
ParentInstanceSequence: 0,
ChildPositionSequence: 2),
out _));
record.ProjectionKind = LiveEntityProjectionKind.Attached;
record.FullCellId = 0u;
int materializerCalls = fixture.Materializer.Calls.Count;
int relationshipCalls = 0;
int applied = 0;
fixture.Relationships.ApplyAttachedAppearance = (expected, version) =>
{
relationshipCalls++;
Assert.Same(record, expected);
Assert.True(fixture.Runtime.IsCurrentObjDescAuthority(expected, version));
Assert.Null(expected.Snapshot.Position);
Assert.Equal((uint)0x04000022u, expected.Snapshot.BasePaletteId);
return true;
};
fixture.Controller.AppearanceApplied += _ => applied++;
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
Assert.Same(entity, record.WorldEntity);
Assert.Equal(LiveEntityProjectionKind.Attached, record.ProjectionKind);
Assert.False(record.AppearanceProjectionSynchronizationPending);
Assert.Equal(materializerCalls, fixture.Materializer.Calls.Count);
Assert.Equal(1, relationshipCalls);
Assert.Equal(1, applied);
}
[Theory]
[InlineData(1)]
[InlineData(0)]
public void StaleOrEqualAppearance_HasNoProjectionOrNotificationSideEffects(
ushort sequence)
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
int calls = fixture.Materializer.Calls.Count;
int applied = 0;
fixture.Controller.AppearanceApplied += _ => applied++;
Assert.False(fixture.Controller.OnAppearance(
ObjDesc(sequence, 0x04000022u)));
Assert.Equal(calls, fixture.Materializer.Calls.Count);
Assert.Equal(0, applied);
Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
Assert.Null(fixture.Record.Snapshot.BasePaletteId);
}
[Fact]
public void AppearanceSequenceWrap_PublishesExactlyOnce()
{
using var fixture = new Fixture(originKnown: true);
WorldSession.EntitySpawn spawn = Spawn(Generation: 1, PositionSequence: 1);
spawn = spawn with
{
Physics = spawn.Physics!.Value with
{
Timestamps = spawn.Physics.Value.Timestamps with
{
ObjDesc = 0xFFFF,
},
},
};
fixture.Controller.OnCreate(spawn);
int applied = 0;
fixture.Controller.AppearanceApplied += _ => applied++;
Assert.True(fixture.Controller.OnAppearance(ObjDesc(0, 0x04000022u)));
Assert.False(fixture.Record.AppearanceProjectionSynchronizationPending);
Assert.Equal((uint)0x04000022u, fixture.Record.Snapshot.BasePaletteId);
Assert.Equal(1, applied);
}
[Fact]
public void FailedAppearance_IsClearedAtGenerationReplacementAndCannotLeak()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord retired = fixture.Record;
fixture.Materializer.SkipMaterializationCount = 1;
Assert.False(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
Assert.True(retired.AppearanceProjectionSynchronizationPending);
fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
LiveEntityRecord replacement = fixture.Record;
fixture.Controller.OnLandblockLoaded(Cell);
Assert.NotSame(retired, replacement);
Assert.False(retired.AppearanceProjectionSynchronizationPending);
Assert.False(retired.AppearanceHydrationInProgress);
Assert.False(replacement.AppearanceProjectionSynchronizationPending);
Assert.Null(replacement.Snapshot.BasePaletteId);
}
[Fact]
public void Pickup_LeavesWorldWithoutEndingLogicalOrResourceIdentity()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
WorldEntity entity = record.WorldEntity!;
fixture.Relationships.OnUnparentAction = _ =>
fixture.Runtime.WithdrawLiveEntityProjection(record)
? ChildUnparentDisposition.Completed
: ChildUnparentDisposition.Superseded;
Assert.True(fixture.Controller.OnPickup(
new PickupEvent.Parsed(Guid, 1, 2)));
Assert.Same(record, fixture.Record);
Assert.Same(entity, record.WorldEntity);
Assert.Equal(0u, record.FullCellId);
Assert.False(record.IsSpatiallyProjected);
Assert.True(record.ResourcesRegistered);
Assert.Equal(1, fixture.Resources.RegisterCount);
Assert.Equal(0, fixture.Resources.UnregisterCount);
Assert.Contains($"unparent:{Guid:X8}", fixture.Operations);
}
[Fact]
public void PositionAfterPickup_ReentersWithSameEntityBodyAndResources()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
WorldEntity entity = record.WorldEntity!;
var body = new PhysicsBody();
record.PhysicsBody = body;
fixture.Relationships.OnUnparentAction = _ =>
fixture.Runtime.WithdrawLiveEntityProjection(record)
? ChildUnparentDisposition.Completed
: ChildUnparentDisposition.Superseded;
Assert.True(fixture.Controller.OnPickup(
new PickupEvent.Parsed(Guid, 1, 2)));
int applied = 0;
fixture.Controller.AppearanceApplied += _ => applied++;
Assert.False(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
Assert.True(record.AppearanceProjectionSynchronizationPending);
Assert.Equal(0, applied);
Assert.True(fixture.Runtime.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
Guid,
new CreateObject.ServerPosition(
Cell, 14f, 12f, 6f, 1f, 0f, 0f, 0f),
Velocity: null,
PlacementId: 0,
IsGrounded: true,
InstanceSequence: 1,
PositionSequence: 3,
TeleportSequence: 0,
ForcePositionSequence: 0),
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out _,
out WorldSession.EntitySpawn accepted,
out _));
ulong positionAuthority = record.PositionAuthorityVersion;
Assert.True(fixture.Controller.RecoverProjection(
record,
positionAuthority,
accepted));
Assert.Same(entity, record.WorldEntity);
Assert.Same(body, record.PhysicsBody);
Assert.True(record.IsSpatiallyProjected);
Assert.False(record.AppearanceProjectionSynchronizationPending);
Assert.Equal((uint)0x04000022u, record.Snapshot.BasePaletteId);
Assert.Equal(1, applied);
Assert.Equal(1, fixture.Resources.RegisterCount);
Assert.Equal(0, fixture.Resources.UnregisterCount);
}
[Fact]
public void ParentEvent_IsRetainedByRelationshipOwnerAndAcceptedThroughHydrationGate()
{
using var fixture = new Fixture(originKnown: true);
const uint parentGuid = 0x70000002u;
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
fixture.Runtime.RegisterLiveEntity(
Spawn(Generation: 4, PositionSequence: 1) with { Guid = parentGuid });
bool accepted = false;
fixture.Relationships.OnParentAction = update =>
accepted = fixture.Controller.TryAcceptParentForProjection(update);
var update = new ParentEvent.Parsed(
parentGuid,
Guid,
ParentLocation: 1,
PlacementId: 0,
ParentInstanceSequence: 4,
ChildPositionSequence: 2);
fixture.Controller.OnParent(update);
Assert.True(accepted);
Assert.Equal((ushort)2, fixture.Record.Snapshot.PositionSequence);
Assert.Contains($"parent:{Guid:X8}", fixture.Operations);
}
[Fact]
public void SameGenerationCreateParent_AdvancesSharedPositionAndPublishesRelation()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
var update = new CreateParentUpdate(
Guid,
ParentGuid: 0x70000002u,
ParentLocation: 3,
PlacementId: 0,
ChildInstanceSequence: 1,
ChildPositionSequence: 2);
Assert.True(fixture.Controller.OnCreateParentAccepted(update));
Assert.Equal((ushort)2, fixture.Record.Snapshot.PositionSequence);
Assert.Contains($"create-parent:{Guid:X8}", fixture.Operations);
}
[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 UnknownDelete_ForgetsOnlyPendingOwnerState()
{
using var fixture = new Fixture(originKnown: true);
Assert.False(fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
Assert.Equal([Guid], fixture.Teardown.ForgottenUnknownOwners);
Assert.Equal(0, fixture.Teardown.TearDownCount);
Assert.Equal(0, fixture.Runtime.Count);
}
[Fact]
public void StaleDelete_DoesNotEndRemoteLiveIncarnation()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
LiveEntityRecord current = fixture.Record;
Assert.False(fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
Assert.Same(current, fixture.Record);
Assert.NotNull(fixture.Objects.Get(Guid));
Assert.Equal(0, fixture.Teardown.TearDownCount);
Assert.Empty(fixture.Teardown.ForgottenUnknownOwners);
}
[Fact]
public void LocalPlayerDelete_IsSideEffectFreeBeforeAndAfterCreate()
{
using var fixture = new Fixture(originKnown: true, playerGuid: Guid);
Assert.False(fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
Assert.Empty(fixture.Teardown.ForgottenUnknownOwners);
fixture.Controller.OnCreate(Spawn(Generation: 2, PositionSequence: 1));
LiveEntityRecord current = fixture.Record;
Assert.False(fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 2)));
Assert.Same(current, fixture.Record);
Assert.NotNull(fixture.Objects.Get(Guid));
Assert.Equal(0, fixture.Teardown.TearDownCount);
Assert.Empty(fixture.Teardown.ForgottenUnknownOwners);
}
[Fact]
public void ExactDelete_RemovesObjectAndTearsDownOnce()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
Assert.True(fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(fixture.Objects.Get(Guid));
Assert.Equal(1, fixture.Teardown.TearDownCount);
Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
}
[Fact]
public void Prune_UsesTheSameExactDeleteTransaction()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
Assert.True(fixture.Controller.OnPrune(
new LiveEntityPruneCandidate(Guid, Generation: 1)));
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(fixture.Objects.Get(Guid));
Assert.Equal(1, fixture.Teardown.TearDownCount);
}
[Fact]
public void Delete_DrainsIndependentFailuresAndRetryDoesNotReplayObjectRemoval()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
int objectRemovedCalls = 0;
fixture.Objects.ObjectRemoved += _ =>
{
objectRemovedCalls++;
throw new InvalidOperationException("fixture object-table observer failure");
};
bool failTeardown = true;
fixture.Teardown.TearDownAction = _ =>
{
if (!failTeardown)
return;
failTeardown = false;
throw new InvalidOperationException("fixture component teardown failure");
};
AggregateException error = Assert.Throws<AggregateException>(() =>
fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
Assert.Contains(error.Flatten().InnerExceptions,
exception => exception.Message.Contains("object-table observer", StringComparison.Ordinal));
Assert.Contains(error.Flatten().InnerExceptions,
exception => exception.Message.Contains("component teardown", StringComparison.Ordinal));
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
Assert.Null(fixture.Objects.Get(Guid));
Assert.Equal(1, objectRemovedCalls);
Assert.Equal(1, fixture.Runtime.PendingTeardownCount);
Assert.Equal(1, fixture.Controller.RetryPendingTeardowns());
Assert.Equal(0, fixture.Runtime.PendingTeardownCount);
Assert.Equal(1, objectRemovedCalls);
Assert.Equal(2, fixture.Teardown.TearDownCount);
}
[Fact]
public void DeleteCallback_CanInstallNewerGenerationWithoutOldCleanupRemovingIt()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord old = fixture.Record;
bool replaced = false;
fixture.Teardown.TearDownAction = record =>
{
Assert.Same(old, record);
if (replaced)
return;
replaced = true;
LiveEntityRegistrationResult result = fixture.Runtime.RegisterLiveEntity(
Spawn(Generation: 2, PositionSequence: 2, Name: "replacement"));
Assert.True(result.LogicalRegistrationCreated);
};
Assert.True(fixture.Controller.OnDelete(
new DeleteObject.Parsed(Guid, InstanceSequence: 1)));
Assert.True(fixture.Runtime.TryGetRecord(Guid, out LiveEntityRecord replacement));
Assert.NotSame(old, replacement);
Assert.Equal((ushort)2, replacement.Generation);
Assert.Equal("replacement", replacement.Snapshot.Name);
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(
LiveEntityReadyCandidate.Capture(record));
};
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(
LiveEntityReadyCandidate.Capture(record));
};
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(
LiveEntityReadyCandidate.Capture(expected)));
Assert.Equal(expectedPrepare, prepare);
Assert.Equal(expectedPresent, present);
Assert.Equal(expectedReplay, replay);
Assert.Same(expected, fixture.Record);
Assert.Equal(2UL, fixture.Record.CreateIntegrationVersion);
}
[Theory]
[InlineData(0, 1, 0, 0)]
[InlineData(1, 1, 1, 0)]
[InlineData(2, 1, 1, 1)]
public void ReadyPublisher_RejectsSameIdentityProjectionChangeBetweenStages(
int withdrawalStage,
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;
LiveEntityReadyCandidate candidate =
LiveEntityReadyCandidate.Capture(expected);
int prepare = 0;
int present = 0;
int replay = 0;
bool withdrawn = false;
bool Stage(int stage)
{
if (!withdrawn && withdrawalStage == stage)
{
withdrawn = true;
Assert.True(fixture.Runtime.TryApplyPickup(
new PickupEvent.Parsed(Guid, 1, 2),
out _));
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(expected));
}
return true;
}
var publisher = new LiveEntityReadyPublisher(
fixture.Runtime,
_ => { prepare++; return Stage(0); },
_ => { present++; return Stage(1); },
_ => { replay++; return Stage(2); });
Assert.False(publisher.Publish(candidate));
Assert.Equal(expectedPrepare, prepare);
Assert.Equal(expectedPresent, present);
Assert.Equal(expectedReplay, replay);
Assert.Same(expected, fixture.Record);
Assert.False(expected.IsSpatiallyProjected);
}
[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,
LiveEntityReadyCandidate.Capture(record) with
{
CreateIntegrationVersion = capturedCreateIntegrationVersion,
WorldEntity = entity,
},
_ => published = true));
Assert.False(published);
}
[Fact]
public void EquippedChildReadyCandidate_RejectsPickupWithdrawalByPoseCallback()
{
using var fixture = new Fixture(originKnown: true);
fixture.Controller.OnCreate(Spawn(Generation: 1, PositionSequence: 1));
LiveEntityRecord record = fixture.Record;
LiveEntityReadyCandidate candidate =
LiveEntityReadyCandidate.Capture(record);
// Models ProjectionPoseReady accepting Pickup and withdrawing the
// retained projection before EntityReady is emitted.
Assert.True(fixture.Runtime.TryApplyPickup(
new PickupEvent.Parsed(Guid, 1, 2),
out _));
Assert.True(fixture.Runtime.WithdrawLiveEntityProjection(record));
bool published = false;
Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
fixture.Runtime,
candidate,
_ => 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 RecordingTeardownCoordinator Teardown = new();
public readonly RecordingTimestampPublisher Timestamps;
public readonly LiveEntityHydrationController Controller;
public Action<uint, AcceptedPhysicsTimestamps>? PublishTimestampsAction
{
get => Timestamps.PublishAction;
set => Timestamps.PublishAction = value;
}
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,
Teardown);
Materializer = new RecordingMaterializer(Runtime, Operations);
Relationships = new RecordingRelationships(Operations);
Ready = new RecordingReadyPublisher(Operations);
Origin = new RecordingOrigin(originKnown);
Timestamps = new RecordingTimestampPublisher(Operations);
Network.ApplyAction = events =>
{
if (events.Position is not { } position)
return;
Runtime.TryApplyPosition(
position,
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out _,
out _,
out _);
};
var identity = new LocalPlayerIdentityState
{
ServerGuid = playerGuid,
};
var deletion = new LiveEntityDeletionController(
Runtime,
Objects,
Teardown,
identity);
Controller = new LiveEntityHydrationController(
Runtime,
Objects,
new object(),
Materializer,
Relationships,
Ready,
Origin,
Network,
Timestamps,
identity,
deletion);
}
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 sealed class RecordingTimestampPublisher
: IAcceptedLocalPhysicsTimestampPublisher
{
private readonly List<string> _operations;
public RecordingTimestampPublisher(List<string> operations) =>
_operations = operations;
public Action<uint, AcceptedPhysicsTimestamps>? PublishAction { get; set; }
public void Publish(uint serverGuid, AcceptedPhysicsTimestamps timestamps)
{
_operations.Add($"timestamps:{timestamps.Instance}");
PublishAction?.Invoke(serverGuid, timestamps);
}
}
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)
: ILiveEntityRelationshipProjection
{
public Action<WorldSession.EntitySpawn>? OnSpawnAction { get; set; }
public Action<ParentEvent.Parsed>? OnParentAction { get; set; }
public Action<CreateParentUpdate>? OnCreateParentAction { get; set; }
public Func<uint, ChildUnparentDisposition>? OnUnparentAction { get; set; }
public Func<LiveEntityRecord, ulong, bool>? ApplyAttachedAppearance { get; set; }
public void OnSpawn(WorldSession.EntitySpawn spawn)
{
operations.Add($"relationship:{spawn.InstanceSequence}");
OnSpawnAction?.Invoke(spawn);
}
public void OnParent(ParentEvent.Parsed update)
{
operations.Add($"parent:{update.ChildGuid:X8}");
OnParentAction?.Invoke(update);
}
public void OnCreateParentAccepted(CreateParentUpdate update)
{
operations.Add($"create-parent:{update.ChildGuid:X8}");
OnCreateParentAction?.Invoke(update);
}
public ChildUnparentDisposition OnChildBecameUnparented(uint childGuid)
{
operations.Add($"unparent:{childGuid:X8}");
return OnUnparentAction?.Invoke(childGuid)
?? ChildUnparentDisposition.Completed;
}
public bool TryApplyAttachedAppearance(
LiveEntityRecord record,
ulong objDescAuthorityVersion)
{
operations.Add($"attached-appearance:{record.ServerGuid:X8}");
return ApplyAttachedAppearance?.Invoke(record, objDescAuthorityVersion)
?? false;
}
}
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(LiveEntityReadyCandidate candidate)
{
LiveEntityRecord expectedRecord = candidate.Record;
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 sealed class RecordingTeardownCoordinator
: ILiveEntityTeardownCoordinator
{
public int TearDownCount { get; private set; }
public List<uint> ForgottenUnknownOwners { get; } = [];
public Action<LiveEntityRecord>? TearDownAction { get; set; }
public void TearDown(LiveEntityRecord record)
{
TearDownCount++;
TearDownAction?.Invoke(record);
}
public void ForgetUnknownOwner(uint serverGuid) =>
ForgottenUnknownOwners.Add(serverGuid);
}
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 ObjDescEvent.Parsed ObjDesc(
ushort sequence,
uint basePaletteId) =>
new(
Guid,
new CreateObject.ModelData(
basePaletteId,
Array.Empty<CreateObject.SubPaletteSwap>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.AnimPartChange>()),
InstanceSequence: 1,
ObjDescSequence: sequence);
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);
}
}