refactor(world): establish live entity composition seams

This commit is contained in:
Erik 2026-07-21 13:09:58 +02:00
parent db5a11707d
commit d68c83d1a5
5 changed files with 357 additions and 21 deletions

View file

@ -0,0 +1,137 @@
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Tests.World;
public sealed class DeferredLiveEntityRuntimeComponentLifecycleTests
{
[Fact]
public void TearDownBeforeBind_FailsFast()
{
var bridge = new DeferredLiveEntityRuntimeComponentLifecycle();
Assert.False(bridge.IsBound);
Assert.Throws<InvalidOperationException>(() => bridge.TearDown(null!));
}
[Fact]
public void Bind_IsSingleAssignmentAndForwardsExactRecord()
{
var bridge = new DeferredLiveEntityRuntimeComponentLifecycle();
LiveEntityRecord? observed = null;
var owner = new DelegateLiveEntityRuntimeComponentLifecycle(record => observed = record);
var replacement = new DelegateLiveEntityRuntimeComponentLifecycle(_ => { });
LiveEntityRecord record = CreateRecord();
bridge.Bind(owner);
bridge.TearDown(record);
Assert.True(bridge.IsBound);
Assert.Same(record, observed);
Assert.Throws<InvalidOperationException>(() => bridge.Bind(replacement));
}
[Fact]
public void BindRejectsNullAndSelf()
{
var bridge = new DeferredLiveEntityRuntimeComponentLifecycle();
Assert.Throws<ArgumentNullException>(() => bridge.Bind(null!));
Assert.Throws<ArgumentException>(() => bridge.Bind(bridge));
}
[Fact]
public async Task ConcurrentBind_HasOneWinnerAndPublishesThatOwner()
{
var bridge = new DeferredLiveEntityRuntimeComponentLifecycle();
int firstCalls = 0;
int secondCalls = 0;
var owners = new ILiveEntityRuntimeComponentLifecycle[]
{
new DelegateLiveEntityRuntimeComponentLifecycle(_ => Interlocked.Increment(ref firstCalls)),
new DelegateLiveEntityRuntimeComponentLifecycle(_ => Interlocked.Increment(ref secondCalls)),
};
using var ready = new CountdownEvent(2);
using var start = new ManualResetEventSlim();
Task<bool>[] attempts = owners.Select(owner => Task.Run(() =>
{
ready.Signal();
start.Wait();
try
{
bridge.Bind(owner);
return true;
}
catch (InvalidOperationException)
{
return false;
}
})).ToArray();
ready.Wait();
start.Set();
bool[] results = await Task.WhenAll(attempts);
LiveEntityRecord record = CreateRecord();
bridge.TearDown(record);
Assert.Single(results, value => value);
Assert.True(bridge.IsBound);
Assert.Equal(1, firstCalls + secondCalls);
}
[Fact]
public void RuntimeTeardownBeforeBind_RetainsTombstoneAndRetryConverges()
{
const uint guid = 0x70000002u;
var bridge = new DeferredLiveEntityRuntimeComponentLifecycle();
var runtime = new LiveEntityRuntime(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }),
bridge);
WorldSession.EntitySpawn spawn = CreateSpawn(guid);
LiveEntityRecord record = runtime.RegisterLiveEntity(spawn).Record!;
var delete = new DeleteObject.Parsed(guid, InstanceSequence: 1);
Assert.Throws<AggregateException>(() =>
runtime.UnregisterLiveEntity(delete, isLocalPlayer: false));
Assert.Equal(1, runtime.PendingTeardownCount);
int calls = 0;
bridge.Bind(new DelegateLiveEntityRuntimeComponentLifecycle(exact =>
{
Assert.Same(record, exact);
calls++;
}));
Assert.True(runtime.UnregisterLiveEntity(delete, isLocalPlayer: false));
Assert.Equal(1, calls);
Assert.Equal(0, runtime.PendingTeardownCount);
}
private static LiveEntityRecord CreateRecord()
{
var runtime = new LiveEntityRuntime(
new GpuWorldState(),
new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { }));
return runtime.RegisterLiveEntity(CreateSpawn(0x70000001u)).Record!;
}
private static WorldSession.EntitySpawn CreateSpawn(uint guid) =>
new(
Guid: guid,
Position: null,
SetupTableId: null,
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
BasePaletteId: null,
ObjScale: null,
Name: "test",
ItemType: null,
MotionState: null,
MotionTableId: null,
InstanceSequence: 1);
}

View file

@ -0,0 +1,62 @@
using AcDream.App.World;
namespace AcDream.App.Tests.World;
public sealed class LiveWorldOriginStateTests
{
[Fact]
public void Placeholder_IsNotAuthoritativeUntilFirstInitialization()
{
var state = new LiveWorldOriginState();
state.SetPlaceholder(42, 43);
Assert.Equal(42, state.CenterX);
Assert.Equal(43, state.CenterY);
Assert.False(state.IsKnown);
Assert.True(state.TryInitialize(10, 11));
Assert.Equal(10, state.CenterX);
Assert.Equal(11, state.CenterY);
Assert.True(state.IsKnown);
Assert.False(state.TryInitialize(20, 21));
Assert.Equal(10, state.CenterX);
Assert.Equal(11, state.CenterY);
}
[Fact]
public void Recenter_ReplacesKnownOriginAndResetRetainsItAsPlaceholder()
{
var state = new LiveWorldOriginState();
Assert.True(state.TryInitialize(1, 2));
state.Recenter(3, 4);
state.Reset();
Assert.Equal(3, state.CenterX);
Assert.Equal(4, state.CenterY);
Assert.False(state.IsKnown);
Assert.True(state.TryInitialize(5, 6));
}
[Fact]
public void RecenterBeforeInitialization_PreservesUnknownAndResetIsIdempotent()
{
var state = new LiveWorldOriginState();
state.SetPlaceholder(20, 21);
state.Recenter(30, 31);
state.Reset();
state.Reset();
Assert.Equal(30, state.CenterX);
Assert.Equal(31, state.CenterY);
Assert.False(state.IsKnown);
Assert.True(state.TryInitialize(40, 41));
state.Recenter(50, 51);
Assert.True(state.IsKnown);
Assert.Equal(50, state.CenterX);
Assert.Equal(51, state.CenterY);
}
}