feat(headless): observe canonical placement receipts
This commit is contained in:
parent
74c9b155bd
commit
378ca95a67
5 changed files with 445 additions and 23 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
67
src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs
Normal file
67
src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -5,30 +5,28 @@ namespace AcDream.App.Tests.Runtime;
|
|||
public sealed class RuntimePhysicsOwnershipTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlacementProjectionChannelRemainsDormantInProductionHosts()
|
||||
public void GraphicalPlacementProjectionRemainsDormantUntilCoordinatedCutover()
|
||||
{
|
||||
string root = FindRepositoryRoot();
|
||||
foreach (string relative in new[]
|
||||
{
|
||||
Path.Combine("src", "AcDream.App"),
|
||||
Path.Combine("src", "AcDream.Headless"),
|
||||
})
|
||||
string relative = Path.Combine("src", "AcDream.App");
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
Path.Combine(root, relative),
|
||||
"*.cs",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
Path.Combine(root, relative),
|
||||
"*.cs",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
string source = File.ReadAllText(file);
|
||||
Assert.DoesNotContain(
|
||||
".Placements.",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"RuntimePlacementProjectionChannel",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
string source = File.ReadAllText(file);
|
||||
Assert.DoesNotContain(
|
||||
".Placements.",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"RuntimePlacementProjectionChannel",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"RuntimePlacementProjectionSubscription",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -374,6 +374,231 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.Equal(2, collision.CenterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlacementReceiptValidationDoesNotRegainMovementOrPhysicsAuthority()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
const uint player = 0x50000004u;
|
||||
runtime.PlayerIdentity.ServerGuid = player;
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(player))
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var directProjection = new HeadlessSessionWorldProjection(
|
||||
runtime,
|
||||
new FixtureCollisionNeighborhood());
|
||||
directProjection.ProjectSpawn(record, isLocalPlayer: true);
|
||||
PlayerMovementController controller = Assert.IsType<
|
||||
PlayerMovementController>(runtime.MovementOwner.Controller);
|
||||
Vector3 positionBefore = controller.Position;
|
||||
Quaternion orientationBefore = controller.BodyOrientation;
|
||||
RuntimePhysicsOwnershipSnapshot physicsBefore =
|
||||
runtime.EntityObjects.Physics.CaptureOwnership();
|
||||
RuntimePlacementProjectionSnapshot receipt = Placement(
|
||||
runtime,
|
||||
record,
|
||||
RuntimePlacementProjectionKind.Place,
|
||||
new Vector3(600f, 601f, 602f),
|
||||
Quaternion.CreateFromAxisAngle(Vector3.UnitY, 1.2f));
|
||||
|
||||
var receiptSink = new HeadlessRuntimePlacementProjectionSink(runtime);
|
||||
Assert.True(receiptSink.TryApply(in receipt));
|
||||
|
||||
Assert.Equal(positionBefore, controller.Position);
|
||||
Assert.Equal(orientationBefore, controller.BodyOrientation);
|
||||
Assert.Equal(
|
||||
physicsBefore,
|
||||
runtime.EntityObjects.Physics.CaptureOwnership());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlacementReceiptUsesExactIncarnationAndDiscardIsAckOnly()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(0x50000005u))
|
||||
.Canonical!;
|
||||
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
||||
record,
|
||||
record.CreateIntegrationVersion,
|
||||
record.Snapshot,
|
||||
replaceGeneration: false));
|
||||
var projection = new HeadlessRuntimePlacementProjectionSink(runtime);
|
||||
RuntimePlacementProjectionSnapshot place = Placement(
|
||||
runtime,
|
||||
record,
|
||||
RuntimePlacementProjectionKind.Place,
|
||||
Vector3.One,
|
||||
Quaternion.Identity);
|
||||
RuntimePlacementProjectionSnapshot stale = place with
|
||||
{
|
||||
Token = place.Token with
|
||||
{
|
||||
Entity = place.Token.Entity with
|
||||
{
|
||||
Incarnation = unchecked((ushort)(
|
||||
place.Token.Entity.Incarnation + 1)),
|
||||
},
|
||||
},
|
||||
};
|
||||
RuntimePlacementProjectionSnapshot discard = stale with
|
||||
{
|
||||
Kind = RuntimePlacementProjectionKind.Discard,
|
||||
Token = stale.Token with
|
||||
{
|
||||
SessionLifetimeVersion = ulong.MaxValue,
|
||||
},
|
||||
};
|
||||
|
||||
Assert.False(projection.TryApply(in stale));
|
||||
Assert.True(projection.TryApply(in discard));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionEventRouteOwnsOneObserverAndUnsubscribesBeforeNetworkDetach()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
int subscriberCountDuringDetach = -1;
|
||||
var inner = new FixtureEventRoute(
|
||||
onDispose: () => subscriberCountDuringDetach =
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
var placements = new HeadlessRuntimePlacementProjectionSink(runtime);
|
||||
var route = new HeadlessSessionEventRoute(
|
||||
inner,
|
||||
runtime,
|
||||
placements);
|
||||
|
||||
Assert.Equal(
|
||||
0,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
route.Attach();
|
||||
route.Attach();
|
||||
Assert.Equal(
|
||||
1,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
Assert.Equal(1, inner.AttachCount);
|
||||
|
||||
route.Dispose();
|
||||
route.Dispose();
|
||||
|
||||
Assert.Equal(0, subscriberCountDuringDetach);
|
||||
Assert.Equal(
|
||||
0,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
Assert.Equal(1, inner.DisposeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplacementSessionEventRouteGetsOneFreshObserver()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
var placements = new HeadlessRuntimePlacementProjectionSink(runtime);
|
||||
|
||||
var first = new HeadlessSessionEventRoute(
|
||||
new FixtureEventRoute(),
|
||||
runtime,
|
||||
placements);
|
||||
first.Attach();
|
||||
Assert.Equal(
|
||||
1,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
first.Dispose();
|
||||
Assert.Equal(
|
||||
0,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
|
||||
var replacement = new HeadlessSessionEventRoute(
|
||||
new FixtureEventRoute(),
|
||||
runtime,
|
||||
placements);
|
||||
replacement.Attach();
|
||||
Assert.Equal(
|
||||
1,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
replacement.Dispose();
|
||||
Assert.Equal(
|
||||
0,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionEventRouteRetryDoesNotRestorePlacementObserver()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
var inner = new FixtureEventRoute
|
||||
{
|
||||
DisposeFailuresRemaining = 1,
|
||||
};
|
||||
var route = new HeadlessSessionEventRoute(
|
||||
inner,
|
||||
runtime,
|
||||
new HeadlessRuntimePlacementProjectionSink(runtime));
|
||||
route.Attach();
|
||||
|
||||
Assert.Throws<IOException>(route.Dispose);
|
||||
Assert.Equal(
|
||||
0,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
Assert.Equal(1, inner.DisposeCount);
|
||||
|
||||
route.Dispose();
|
||||
|
||||
Assert.Equal(2, inner.DisposeCount);
|
||||
Assert.Equal(
|
||||
0,
|
||||
runtime.EntityObjects.Events.PlacementSubscriberCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollisionTransactionCancelsPostAdmissionFaultWithoutWithdrawingActiveWorld()
|
||||
{
|
||||
|
|
@ -587,6 +812,36 @@ public sealed class HeadlessSessionHostTests
|
|||
Physics: physics);
|
||||
}
|
||||
|
||||
private static RuntimePlacementProjectionSnapshot Placement(
|
||||
GameRuntime runtime,
|
||||
RuntimeEntityRecord record,
|
||||
RuntimePlacementProjectionKind kind,
|
||||
Vector3 position,
|
||||
Quaternion orientation)
|
||||
{
|
||||
RuntimeEntityKey key = Assert.IsType<RuntimeEntityKey>(record.Key);
|
||||
var token = new RuntimePlacementProjectionToken(
|
||||
Sequence: 1,
|
||||
Revision: 1,
|
||||
Entity: key,
|
||||
PositionAuthorityVersion: record.PositionAuthorityVersion,
|
||||
SpatialAuthorityVersion: record.SpatialAuthorityVersion,
|
||||
PlacementCommitVersion: record.PlacementCommitVersion,
|
||||
SessionLifetimeVersion:
|
||||
runtime.EntityObjects.Entities.SessionLifetimeVersion,
|
||||
ExactCellId: record.FullCellId,
|
||||
CollisionGeneration: 1,
|
||||
Portal: default);
|
||||
return new RuntimePlacementProjectionSnapshot(
|
||||
token,
|
||||
kind,
|
||||
position,
|
||||
orientation,
|
||||
CellLocalPosition: position,
|
||||
InContact: false,
|
||||
OnWalkable: false);
|
||||
}
|
||||
|
||||
private static uint ActionOpcode(byte[] body) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
body.AsSpan(8, sizeof(uint)));
|
||||
|
|
@ -687,4 +942,25 @@ public sealed class HeadlessSessionHostTests
|
|||
fullCellId == LastCell;
|
||||
}
|
||||
|
||||
private sealed class FixtureEventRoute(
|
||||
Action? onDispose = null) : ILiveSessionEventRouting
|
||||
{
|
||||
public int AttachCount { get; private set; }
|
||||
public int DisposeCount { get; private set; }
|
||||
public int DisposeFailuresRemaining { get; set; }
|
||||
|
||||
public void Attach() => AttachCount++;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
onDispose?.Invoke();
|
||||
if (DisposeFailuresRemaining > 0)
|
||||
{
|
||||
DisposeFailuresRemaining--;
|
||||
throw new IOException("fixture route detach failure");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue