feat(headless): observe canonical placement receipts

This commit is contained in:
Erik 2026-08-01 15:10:03 +02:00
parent 74c9b155bd
commit 378ca95a67
5 changed files with 445 additions and 23 deletions

View file

@ -0,0 +1,76 @@
using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Physics;
using AcDream.Runtime.World;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Validation-only no-window observer for canonical Runtime SetPosition
/// receipts. A headless host has no graphical sidecar to move or hide, so a
/// valid receipt is acknowledged without re-running placement or mutating
/// Runtime's body, controller, shadows, clocks, or worksets.
/// </summary>
internal sealed class HeadlessRuntimePlacementProjectionSink
: IRuntimePlacementProjectionSink
{
private readonly GameRuntime _runtime;
internal HeadlessRuntimePlacementProjectionSink(GameRuntime runtime)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
}
public bool TryApply(
in RuntimePlacementProjectionSnapshot projection)
{
if (projection.Kind is RuntimePlacementProjectionKind.Discard)
{
// Discard cancels only an unacknowledged observation. It is valid
// even after its entity/session authority has been superseded.
return true;
}
RuntimePlacementProjectionToken token = projection.Token;
RuntimeEntityDirectory directory = _runtime.EntityObjects.Entities;
if (!token.IsValid
|| token.SessionLifetimeVersion
!= directory.SessionLifetimeVersion
|| !directory.TryGetByLocalId(
token.Entity.LocalEntityId,
out RuntimeEntityRecord record)
|| !directory.IsCurrent(record)
|| record.Key != token.Entity
|| !HasValidPortalShape(token))
{
return false;
}
if (projection.Kind is RuntimePlacementProjectionKind.Withdraw)
return true;
if (projection.Kind is not RuntimePlacementProjectionKind.Place)
return false;
return record.PositionAuthorityVersion
== token.PositionAuthorityVersion
&& record.SpatialAuthorityVersion
== token.SpatialAuthorityVersion
&& record.PlacementCommitVersion
== token.PlacementCommitVersion
&& record.FullCellId == token.ExactCellId
&& _runtime.TransitOwner.IsCurrentPlacementAuthority(
token.Portal,
token.ExactCellId);
}
private static bool HasValidPortalShape(
in RuntimePlacementProjectionToken token)
{
RuntimePortalPlacementAuthority portal = token.Portal;
if (!portal.Present)
return portal.IsEmpty;
return portal.IsValid
&& portal.Projection.DestinationCell == token.ExactCellId;
}
}

View file

@ -0,0 +1,67 @@
using AcDream.Runtime;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Owns the inbound network route and the canonical placement observer for
/// one exact headless session lifetime. Placement detaches first so no
/// receipt can reach a retiring no-window projection while the network route
/// is being removed or Runtime is being reset.
/// </summary>
internal sealed class HeadlessSessionEventRoute : ILiveSessionEventRouting
{
private readonly ILiveSessionEventRouting _events;
private readonly GameRuntime _runtime;
private readonly IRuntimePlacementProjectionSink _placements;
private RuntimePlacementProjectionSubscription? _subscription;
private bool _attachStarted;
private bool _eventsDisposed;
private bool _disposed;
internal HeadlessSessionEventRoute(
ILiveSessionEventRouting events,
GameRuntime runtime,
IRuntimePlacementProjectionSink placements)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_placements = placements
?? throw new ArgumentNullException(nameof(placements));
}
public void Attach()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_attachStarted)
return;
// Mark the attempt before the fallible call. If Attach partially
// succeeds and throws, LiveSessionHost's retryable rollback still
// invokes Dispose on the underlying route.
_attachStarted = true;
_events.Attach();
_subscription = new RuntimePlacementProjectionSubscription(
_runtime,
_placements);
}
public void Dispose()
{
if (_disposed)
return;
// Subscription disposal is idempotent and deliberately precedes the
// network route. A still-pending FIFO head remains Runtime-owned for
// the replacement route to drain.
Interlocked.Exchange(ref _subscription, null)?.Dispose();
if (!_eventsDisposed)
{
_events.Dispose();
_eventsDisposed = true;
}
_disposed = true;
}
}

View file

@ -4,6 +4,7 @@ using AcDream.Headless.Diagnostics;
using AcDream.Headless.Policies;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
namespace AcDream.Headless.Hosting;
@ -521,7 +522,7 @@ internal sealed class HeadlessSessionHost : IDisposable
}
}
private LiveSessionEventRouter CreateEventRoute(
private ILiveSessionEventRouting CreateEventRoute(
AcDream.Core.Net.WorldSession session)
{
IRuntimeDirectWorldProjection? worldProjection =
@ -536,7 +537,7 @@ internal sealed class HeadlessSessionHost : IDisposable
message,
Runtime.Generation.Value),
worldProjection);
return new LiveSessionEventRouter(
var route = new LiveSessionEventRouter(
session,
entities.CreateSink(),
new LiveEnvironmentSessionSink(
@ -579,6 +580,10 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime.CommunicationOwner.TurbineChat,
Runtime.CommunicationOwner.Friends,
Runtime.CommunicationOwner.Squelch));
return new HeadlessSessionEventRoute(
route,
Runtime,
new HeadlessRuntimePlacementProjectionSink(Runtime));
}
private static LiveSessionCharacterSelector MapCharacterSelector(