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

@ -80,6 +80,64 @@ public interface ILiveEntityResourceLifecycle
void Unregister(WorldEntity entity);
}
/// <summary>
/// Exact-incarnation App component cleanup invoked by the canonical runtime
/// after it has retired the active identity. Mesh/script resource lifetime is
/// owned separately by <see cref="ILiveEntityResourceLifecycle"/>.
/// </summary>
internal interface ILiveEntityRuntimeComponentLifecycle
{
void TearDown(LiveEntityRecord record);
}
internal sealed class NullLiveEntityRuntimeComponentLifecycle :
ILiveEntityRuntimeComponentLifecycle
{
public static NullLiveEntityRuntimeComponentLifecycle Instance { get; } = new();
private NullLiveEntityRuntimeComponentLifecycle() { }
public void TearDown(LiveEntityRecord record) { }
}
internal sealed class DelegateLiveEntityRuntimeComponentLifecycle(
Action<LiveEntityRecord> tearDown) : ILiveEntityRuntimeComponentLifecycle
{
private readonly Action<LiveEntityRecord> _tearDown =
tearDown ?? throw new ArgumentNullException(nameof(tearDown));
public void TearDown(LiveEntityRecord record) => _tearDown(record);
}
/// <summary>
/// Composition bridge for the runtime-to-hydration construction cycle. It is
/// deliberately fail-fast and single-assignment: a live session must not start
/// before the exact component owner is bound, and ownership cannot be replaced
/// during a session.
/// </summary>
internal sealed class DeferredLiveEntityRuntimeComponentLifecycle :
ILiveEntityRuntimeComponentLifecycle
{
private ILiveEntityRuntimeComponentLifecycle? _inner;
public bool IsBound => Volatile.Read(ref _inner) is not null;
public void Bind(ILiveEntityRuntimeComponentLifecycle lifecycle)
{
ArgumentNullException.ThrowIfNull(lifecycle);
if (ReferenceEquals(lifecycle, this))
throw new ArgumentException("A lifecycle bridge cannot bind to itself.", nameof(lifecycle));
if (Interlocked.CompareExchange(ref _inner, lifecycle, null) is not null)
throw new InvalidOperationException("The live-entity runtime component lifecycle is already bound.");
}
public void TearDown(LiveEntityRecord record)
{
ILiveEntityRuntimeComponentLifecycle lifecycle = Volatile.Read(ref _inner)
?? throw new InvalidOperationException(
"The live-entity runtime component lifecycle has not been bound.");
lifecycle.TearDown(record);
}
}
/// <summary>Delegate adapter used by the App composition root.</summary>
public sealed class DelegateLiveEntityResourceLifecycle : ILiveEntityResourceLifecycle
{
@ -300,7 +358,7 @@ public sealed class LiveEntityRuntime
private readonly GpuWorldState _spatial;
private readonly ILiveEntityResourceLifecycle _resources;
private readonly Action<LiveEntityRecord>? _tearDownRuntimeComponents;
private readonly ILiveEntityRuntimeComponentLifecycle _runtimeComponentLifecycle;
private readonly InboundPhysicsStateController _inbound = new();
private readonly Dictionary<uint, LiveEntityRecord> _recordsByGuid = new();
// Records leave the active gameplay map before arbitrary teardown callbacks
@ -338,12 +396,38 @@ public sealed class LiveEntityRuntime
public LiveEntityRuntime(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
Action<LiveEntityRecord>? tearDownRuntimeComponents = null,
uint firstLocalEntityId = FirstLiveEntityId)
: this(
spatial,
resources,
NullLiveEntityRuntimeComponentLifecycle.Instance,
firstLocalEntityId)
{
}
public LiveEntityRuntime(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
Action<LiveEntityRecord> tearDownRuntimeComponents,
uint firstLocalEntityId = FirstLiveEntityId)
: this(
spatial,
resources,
new DelegateLiveEntityRuntimeComponentLifecycle(tearDownRuntimeComponents),
firstLocalEntityId)
{
}
internal LiveEntityRuntime(
GpuWorldState spatial,
ILiveEntityResourceLifecycle resources,
ILiveEntityRuntimeComponentLifecycle? runtimeComponentLifecycle,
uint firstLocalEntityId = FirstLiveEntityId)
{
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_tearDownRuntimeComponents = tearDownRuntimeComponents;
_runtimeComponentLifecycle = runtimeComponentLifecycle
?? throw new ArgumentNullException(nameof(runtimeComponentLifecycle));
if (firstLocalEntityId is < FirstLiveEntityId or > LastLiveEntityId)
throw new ArgumentOutOfRangeException(
nameof(firstLocalEntityId),
@ -2239,8 +2323,7 @@ public sealed class LiveEntityRuntime
if (!record.RuntimeComponentsTeardownCompleted)
{
record.RuntimeComponentsTeardownCompleted =
_tearDownRuntimeComponents is null
|| TryCleanup(() => _tearDownRuntimeComponents(record));
TryCleanup(() => _runtimeComponentLifecycle.TearDown(record));
}
if (record.WorldEntity is { } entity)

View file

@ -0,0 +1,49 @@
namespace AcDream.App.World;
/// <summary>
/// Session-scoped owner of the landblock origin used to translate retail
/// full-cell coordinates into the current streamed world coordinate system.
/// A placeholder can exist before the first authoritative player position;
/// <see cref="IsKnown"/> distinguishes it from a server-confirmed origin.
/// </summary>
/// <remarks>
/// This owner is update/render-thread confined with the rest of live world
/// mutation. It intentionally exposes no cross-thread snapshot contract.
/// </remarks>
internal sealed class LiveWorldOriginState
{
public int CenterX { get; private set; }
public int CenterY { get; private set; }
public bool IsKnown { get; private set; }
public void SetPlaceholder(int centerX, int centerY)
{
CenterX = centerX;
CenterY = centerY;
IsKnown = false;
}
public bool TryInitialize(int centerX, int centerY)
{
if (IsKnown)
return false;
CenterX = centerX;
CenterY = centerY;
IsKnown = true;
return true;
}
public void Recenter(int centerX, int centerY)
{
CenterX = centerX;
CenterY = centerY;
}
/// <summary>
/// Ends the accepted-origin lifetime while retaining the last coordinates
/// as the next placeholder. Consumers must gate them on
/// <see cref="IsKnown"/> until the next accepted player position.
/// </summary>
public void Reset() => IsKnown = false;
}