refactor(world): extract live appearance and parenting

Move ObjDesc, Parent, Pickup, child-ready, and retained projection recovery into LiveEntityHydrationController while preserving retail timestamp and leave-world semantics. Pin ready publication to exact projection authority and restore attached descendant chains synchronously.
This commit is contained in:
Erik 2026-07-21 18:10:07 +02:00
parent 40352d5a7a
commit fe5514967c
9 changed files with 1098 additions and 199 deletions

View file

@ -12,6 +12,25 @@ 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()
{
@ -93,10 +112,7 @@ public sealed class LiveEntityHydrationControllerTests
LiveEntityRecord record = fixture.Record;
Assert.Null(record.WorldEntity);
Assert.True(fixture.Controller.ApplyAppearance(
record,
record.Snapshot,
appearanceUpdate: null));
Assert.True(fixture.Controller.OnAppearance(ObjDesc(2, 0x04000022u)));
Assert.NotNull(record.WorldEntity);
Assert.Equal(2, fixture.Materializer.Calls.Count);
@ -105,6 +121,324 @@ public sealed class LiveEntityHydrationControllerTests
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()
{
@ -805,9 +1139,7 @@ public sealed class LiveEntityHydrationControllerTests
LiveEntityProjectionKind.Attached);
Assert.Same(retained, attached);
fixture.Controller.OnEntityReady(
new LiveEntityReadyCandidate(
record,
record.CreateIntegrationVersion));
LiveEntityReadyCandidate.Capture(record));
};
bool refreshed = false;
fixture.Materializer.BeforeMaterialize = (_, spawn) =>
@ -880,9 +1212,8 @@ public sealed class LiveEntityHydrationControllerTests
},
LiveEntityProjectionKind.Attached);
Assert.NotNull(attached);
fixture.Controller.OnEntityReady(new LiveEntityReadyCandidate(
record,
record.CreateIntegrationVersion));
fixture.Controller.OnEntityReady(
LiveEntityReadyCandidate.Capture(record));
};
fixture.Controller.OnCreate(CelllessSpawn(
@ -945,7 +1276,8 @@ public sealed class LiveEntityHydrationControllerTests
_ => { present++; return Stage(1); },
_ => { replay++; return Stage(2); });
Assert.False(publisher.Publish(expected));
Assert.False(publisher.Publish(
LiveEntityReadyCandidate.Capture(expected)));
Assert.Equal(expectedPrepare, prepare);
Assert.Equal(expectedPresent, present);
Assert.Equal(expectedReplay, replay);
@ -953,6 +1285,52 @@ public sealed class LiveEntityHydrationControllerTests
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()
{
@ -970,9 +1348,35 @@ public sealed class LiveEntityHydrationControllerTests
Assert.False(EquippedChildRenderController.PublishEntityReadyExact(
fixture.Runtime,
record,
capturedCreateIntegrationVersion,
entity,
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);
}
@ -1256,14 +1660,46 @@ public sealed class LiveEntityHydrationControllerTests
}
private sealed class RecordingRelationships(List<string> operations)
: ILiveEntitySpawnRelationshipSink
: 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)
@ -1272,8 +1708,9 @@ public sealed class LiveEntityHydrationControllerTests
public int PublishCount { get; private set; }
public int FailPublishCount { get; set; }
public Action<LiveEntityRecord>? BeforePublish { get; set; }
public bool Publish(LiveEntityRecord expectedRecord)
public bool Publish(LiveEntityReadyCandidate candidate)
{
LiveEntityRecord expectedRecord = candidate.Record;
PublishCount++;
operations.Add("ready");
BeforePublish?.Invoke(expectedRecord);
@ -1357,6 +1794,19 @@ public sealed class LiveEntityHydrationControllerTests
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)