refactor(world): extract live entity teardown

This commit is contained in:
Erik 2026-07-21 18:25:59 +02:00
parent 4427cfb2c7
commit f38822c490
8 changed files with 565 additions and 159 deletions

View file

@ -26,6 +26,10 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData("OnLiveEntityPickedUp")]
[InlineData("OnLiveParentUpdated")]
[InlineData("TryAcceptParentForRender")]
[InlineData("OnLiveEntityDeleted")]
[InlineData("OnLiveEntityPruned")]
[InlineData("TearDownLiveEntityRuntimeComponents")]
[InlineData("CreateLiveEntityRuntimeTeardownPlan")]
public void GameWindow_DoesNotReacquireExtractedLiveEntityBodies(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateImplementation));
@ -47,6 +51,7 @@ public sealed class GameWindowLiveEntityCompositionTests
[InlineData(typeof(LiveEntityAppearanceBinding))]
[InlineData(typeof(LiveEntityHydrationController))]
[InlineData(typeof(DatLiveEntityProjectionMaterializer))]
[InlineData(typeof(LiveEntityRuntimeTeardownController))]
public void ExtractedHelpers_DoNotOwnGuidIndexesOrBackendState(Type helperType)
{
foreach (FieldInfo field in helperType.GetFields(

View file

@ -550,6 +550,152 @@ public sealed class LiveEntityHydrationControllerTests
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()
{
@ -1447,6 +1593,7 @@ public sealed class LiveEntityHydrationControllerTests
public readonly RecordingReadyPublisher Ready;
public readonly RecordingOrigin Origin;
public readonly RecordingNetworkSink Network = new();
public readonly RecordingTeardownCoordinator Teardown = new();
public readonly LiveEntityHydrationController Controller;
public Action<uint, AcceptedPhysicsTimestamps>? PublishTimestampsAction { get; set; }
@ -1465,7 +1612,7 @@ public sealed class LiveEntityHydrationControllerTests
Runtime = new LiveEntityRuntime(
spatial,
Resources,
new DelegateLiveEntityRuntimeComponentLifecycle(_ => { }));
Teardown);
Materializer = new RecordingMaterializer(Runtime, Operations);
Relationships = new RecordingRelationships(Operations);
Ready = new RecordingReadyPublisher(Operations);
@ -1493,6 +1640,7 @@ public sealed class LiveEntityHydrationControllerTests
Ready,
Origin,
Network,
Teardown,
(guid, timestamps) =>
{
Operations.Add($"timestamps:{timestamps.Instance}");
@ -1743,6 +1891,23 @@ public sealed class LiveEntityHydrationControllerTests
}
}
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,

View file

@ -0,0 +1,115 @@
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.World;
public sealed class LiveEntityRuntimeTeardownControllerTests
{
[Fact]
public void Retry_ReusesPlanAndDoesNotReplayCompletedOwners()
{
LiveEntityRecord record = new(Spawn());
int factoryCalls = 0;
int completedOwnerCalls = 0;
int retryingOwnerCalls = 0;
bool failOnce = true;
var controller = new LiveEntityRuntimeTeardownController(
_ =>
{
factoryCalls++;
return new LiveEntityTeardownPlan([
() => completedOwnerCalls++,
() =>
{
retryingOwnerCalls++;
if (failOnce)
{
failOnce = false;
throw new InvalidOperationException("fixture failure");
}
},
]);
},
_ => { });
Assert.Throws<AggregateException>(() => controller.TearDown(record));
controller.TearDown(record);
Assert.Equal(1, factoryCalls);
Assert.Equal(1, completedOwnerCalls);
Assert.Equal(2, retryingOwnerCalls);
Assert.True(record.RuntimeComponentTeardownPlan!.IsComplete);
}
[Fact]
public void UnknownOwner_IsForwardedWithoutCreatingATeardownPlan()
{
uint forgotten = 0u;
int factoryCalls = 0;
var controller = new LiveEntityRuntimeTeardownController(
_ =>
{
factoryCalls++;
return new LiveEntityTeardownPlan([]);
},
guid => forgotten = guid);
controller.ForgetUnknownOwner(Guid);
Assert.Equal(Guid, forgotten);
Assert.Equal(0, factoryCalls);
}
private const uint Guid = 0x70000001u;
private const uint Cell = 0x01010001u;
private static WorldSession.EntitySpawn Spawn()
{
var position = new CreateObject.ServerPosition(
Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
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,
"fixture",
(uint)ItemType.Creature,
null,
0x09000001u,
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
InstanceSequence: 1,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: 1,
Physics: physics);
}
}