Cutover slice C0 (docs/plans/2026-08-02-placement-cutover.md): the seam work that lets C3 flip hosts onto a complete receipt stream instead of growing one mid-cutover. The executor's Released exit now publishes an acknowledge-only ExecutorCompleted receipt through the one placement projection stream — registered before observer dispatch, correlated to the full execution receipt, reaped exactly once on acknowledgement/ discard/session-clear, and counted in the convergence ledger. All three production placement sinks acknowledge-and-ignore the new kind via early returns proven behavior-preserving for every existing kind; without them the first such receipt at cutover would permanently wedge the exact-head FIFO behind sinks that return false. Provably inert today: the publisher has no production caller. Execute's live inputs now derive from Runtime's own owners bound at GameRuntime construction: UsePositionFromServer is retail's exact autonomy_level != 2 (CommandInterpreter::UsePositionFromServer 0x006B3B40, startup-only knob), and PlayerDistance uses the live movement controller's position with a null-safe fallback to the caller struct — never a fabricated origin. TryPrepareAndSubmitAuthoredPlacement chains the prepared-collision Setup read through PrepareMover to submission with zero validation-semantics changes. TryCommitParent and CommitWithdrawal gain the sibling cancellation flow (residence + ordinary placement family); TryCommitParent deliberately omits LeaveWorld — retail's set_parent performs its single gated leave_world (0x00515A90) and a second would have no counterpart. Not fully dormant: the two cancellation fixes change Runtime paths production already calls (today as no-op-adjacent hardening, since nothing upstream begins a residence yet); everything else is reachable only by tests. Reviewed: retail-conformance PASS + architecture/ adversarial PASS after one fix round (sink wedge, completion-receipt lifecycle, null-controller distance). Runtime 921/921; complete Release solution 10,716 passed / 4 intentional skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1243 lines
43 KiB
C#
1243 lines
43 KiB
C#
using System.Buffers.Binary;
|
|
using System.Collections.Immutable;
|
|
using System.Net;
|
|
using System.Numerics;
|
|
using System.Reflection;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Headless.Configuration;
|
|
using AcDream.Headless.Credentials;
|
|
using AcDream.Headless.Diagnostics;
|
|
using AcDream.Headless.Hosting;
|
|
using AcDream.Headless.Platform;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Physics;
|
|
using AcDream.Runtime.Session;
|
|
using AcDream.Runtime.World;
|
|
|
|
namespace AcDream.Headless.Tests;
|
|
|
|
public sealed class HeadlessSessionHostTests
|
|
{
|
|
[Fact]
|
|
public void SingleSessionStartsReconnectsAndConvergesWithoutPresentation()
|
|
{
|
|
var operations = new FixtureSessionOperations();
|
|
using var diagnosticsOutput = new StringWriter();
|
|
using var credential = new HeadlessCredentialSecret(
|
|
"fixture",
|
|
"password");
|
|
var host = new HeadlessSessionHost(
|
|
Descriptor(),
|
|
credential,
|
|
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
|
operations);
|
|
|
|
RuntimeSessionStartResult first = host.Start();
|
|
ulong firstGeneration = host.Runtime.Generation.Value;
|
|
host.Tick(0.015d);
|
|
RuntimeSessionStartResult second = host.Reconnect();
|
|
|
|
Assert.Equal(RuntimeSessionStartStatus.Connected, first.Status);
|
|
Assert.Equal(RuntimeSessionStartStatus.Connected, second.Status);
|
|
Assert.Equal(0x50000002u, first.CharacterId);
|
|
Assert.Equal("Headless", host.ActiveCharacterName);
|
|
Assert.True(host.Runtime.Session.IsInWorld);
|
|
Assert.True(host.Runtime.Generation.Value > firstGeneration);
|
|
Assert.Equal(2, operations.CreatedSessionCount);
|
|
Assert.Equal(1, operations.DisposedSessionCount);
|
|
|
|
host.Dispose();
|
|
|
|
Assert.Equal(2, operations.DisposedSessionCount);
|
|
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
|
Assert.True(credential.IsDisposed);
|
|
string diagnostics = diagnosticsOutput.ToString();
|
|
Assert.Contains("\"state\":\"start-result\"", diagnostics);
|
|
Assert.DoesNotContain("password", diagnostics);
|
|
Assert.DoesNotContain("AcDream.App", diagnostics);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
|
{
|
|
var configuration = new HeadlessConfiguration
|
|
{
|
|
Version = 1,
|
|
Sessions =
|
|
[
|
|
Descriptor(
|
|
HeadlessCredentialProviderKind.StandardInput,
|
|
"stdin-bot"),
|
|
],
|
|
};
|
|
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
|
new HeadlessPathOverrides());
|
|
using var diagnostics = new StringWriter();
|
|
var operations = new FixtureSessionOperations();
|
|
using var host = new HeadlessProcessHost(
|
|
configuration,
|
|
paths,
|
|
new System.IO.StringReader(
|
|
"process-password" + Environment.NewLine),
|
|
diagnostics,
|
|
operations);
|
|
using var cancellation = new CancellationTokenSource();
|
|
cancellation.Cancel();
|
|
|
|
HeadlessExitCode result =
|
|
await host.RunAsync(cancellation.Token);
|
|
|
|
Assert.Equal(HeadlessExitCode.Success, result);
|
|
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
|
Assert.DoesNotContain(
|
|
"process-password",
|
|
diagnostics.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DirectCredentialsOverrideSingleConfiguredSession()
|
|
{
|
|
var configuration = new HeadlessConfiguration
|
|
{
|
|
Version = 1,
|
|
Sessions = [Descriptor()],
|
|
};
|
|
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
|
new HeadlessPathOverrides());
|
|
using var diagnostics = new StringWriter();
|
|
var operations = new FixtureSessionOperations();
|
|
using var host = new HeadlessProcessHost(
|
|
configuration,
|
|
paths,
|
|
TextReader.Null,
|
|
diagnostics,
|
|
operations,
|
|
directCredentials: new HeadlessDirectCredentials(
|
|
"direct-account",
|
|
"direct-secret"));
|
|
using var cancellation = new CancellationTokenSource();
|
|
cancellation.Cancel();
|
|
|
|
HeadlessExitCode result =
|
|
await host.RunAsync(cancellation.Token);
|
|
|
|
Assert.Equal(HeadlessExitCode.Success, result);
|
|
Assert.Equal("direct-account", operations.LastUser);
|
|
Assert.Equal("direct-secret", operations.LastPassword);
|
|
Assert.DoesNotContain(
|
|
"direct-secret",
|
|
diagnostics.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectFrameUsesSharedRetailOrderAndMovementCadence()
|
|
{
|
|
var operations = new FixtureSessionOperations();
|
|
using var credential = new HeadlessCredentialSecret(
|
|
"fixture",
|
|
"password");
|
|
using var host = new HeadlessSessionHost(
|
|
Descriptor(),
|
|
credential,
|
|
new HeadlessDiagnosticWriter(TextWriter.Null),
|
|
operations);
|
|
Assert.Equal(
|
|
RuntimeSessionStartStatus.Connected,
|
|
host.Start().Status);
|
|
HydrateGroundedPlayer(host.Runtime);
|
|
var sent = new List<(byte[] Body, double Time)>();
|
|
var trace = new RuntimeTraceRecorder();
|
|
using IDisposable subscription =
|
|
host.Runtime.Subscribe(trace);
|
|
operations.Sessions[^1].GameActionCapture =
|
|
body => sent.Add((
|
|
body,
|
|
host.Runtime.Clock.SimulationTimeSeconds));
|
|
|
|
RuntimeCommandResult intent = host.Commands.Movement.SetIntent(
|
|
host.Runtime.Generation,
|
|
new MovementInput(Forward: true, Run: true));
|
|
host.Tick(0.015d);
|
|
|
|
Assert.True(intent.Accepted);
|
|
Assert.Contains(
|
|
trace.Entries,
|
|
static entry =>
|
|
entry.Kind == RuntimeTraceKind.Movement);
|
|
Assert.Equal(
|
|
[
|
|
MoveToState.MoveToStateAction,
|
|
AutonomousPosition.AutonomousPositionAction,
|
|
],
|
|
sent.Select(static entry =>
|
|
ActionOpcode(entry.Body)).ToArray());
|
|
|
|
sent.Clear();
|
|
for (int index = 0; index < 70; index++)
|
|
host.Tick(0.015d);
|
|
|
|
(byte[] Body, double Time)[] positions = sent
|
|
.Where(static entry =>
|
|
ActionOpcode(entry.Body)
|
|
== AutonomousPosition.AutonomousPositionAction)
|
|
.ToArray();
|
|
Assert.InRange(positions.Length, 1, 2);
|
|
Assert.True(positions[^1].Time >= 1d);
|
|
if (positions.Length == 2)
|
|
{
|
|
Assert.True(
|
|
positions[1].Time - positions[0].Time >= 0.99d);
|
|
}
|
|
Assert.DoesNotContain(
|
|
sent,
|
|
entry => ActionOpcode(entry.Body)
|
|
== MoveToState.MoveToStateAction);
|
|
}
|
|
|
|
[Fact]
|
|
public void TeardownRetriesOnlyTheUnfinishedSuffix()
|
|
{
|
|
var operations = new FixtureSessionOperations();
|
|
var writer = new FailOnceTextWriter();
|
|
using var credential = new HeadlessCredentialSecret(
|
|
"fixture",
|
|
"password");
|
|
var host = new HeadlessSessionHost(
|
|
Descriptor(),
|
|
credential,
|
|
new HeadlessDiagnosticWriter(writer),
|
|
operations);
|
|
Assert.Equal(
|
|
RuntimeSessionStartStatus.Connected,
|
|
host.Start().Status);
|
|
writer.FailNextWrite = true;
|
|
|
|
Assert.Throws<IOException>(host.Dispose);
|
|
Assert.Equal(1, operations.DisposedSessionCount);
|
|
|
|
host.Dispose();
|
|
|
|
Assert.Equal(1, operations.DisposedSessionCount);
|
|
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldProjectionHydratesCanonicalMovementAndTeleportState()
|
|
{
|
|
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 = 0x50000002u;
|
|
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 collision = new FixtureCollisionNeighborhood();
|
|
var projection = new HeadlessSessionWorldProjection(
|
|
runtime,
|
|
collision);
|
|
|
|
projection.ProjectSpawn(record, isLocalPlayer: true);
|
|
PlayerMovementController controller =
|
|
Assert.IsType<PlayerMovementController>(
|
|
runtime.MovementOwner.Controller);
|
|
controller.SetPosition(
|
|
new Vector3(48f, 49f, 50f),
|
|
0xA9B40001u);
|
|
projection.ProjectPosition(
|
|
record,
|
|
isLocalPlayer: true,
|
|
PositionTimestampDisposition.Apply);
|
|
|
|
Assert.Same(controller, runtime.MovementOwner.Controller);
|
|
Assert.Equal(record.LocalEntityId, controller.LocalEntityId);
|
|
Assert.Equal(new Vector3(48f, 49f, 50f), controller.Position);
|
|
Assert.Equal(
|
|
0xA9B40000u,
|
|
controller.CellId & 0xFFFF0000u);
|
|
Assert.True((controller.CellId & 0xFFFFu) < 0x0100u);
|
|
projection.BeginTeleport();
|
|
Assert.Equal(PlayerState.PortalSpace, controller.State);
|
|
|
|
RuntimeDestinationReadiness readiness =
|
|
projection.PrepareDestination(
|
|
revealGeneration: 7,
|
|
new RuntimeTeleportDestination(
|
|
player,
|
|
InstanceSequence: 1,
|
|
PositionSequence: 2,
|
|
TeleportSequence: 1,
|
|
ForcePositionSequence: 0,
|
|
new Position(
|
|
0xA9B40001u,
|
|
new Vector3(96f, 97f, 50f),
|
|
Quaternion.Identity)));
|
|
|
|
Assert.True(readiness.IsCollisionReady);
|
|
Assert.False(readiness.IsUnhydratable);
|
|
Assert.Equal(PlayerState.InWorld, controller.State);
|
|
Assert.Equal(3, collision.CenterCount);
|
|
Assert.Equal(0xA9B40001u, collision.LastCell);
|
|
}
|
|
|
|
[Fact]
|
|
public void WorldProjectionIgnoresNormalEchoButBlipsForcePosition()
|
|
{
|
|
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 = 0x50000003u;
|
|
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 collision = new FixtureCollisionNeighborhood();
|
|
var projection = new HeadlessSessionWorldProjection(
|
|
runtime,
|
|
collision);
|
|
projection.ProjectSpawn(record, isLocalPlayer: true);
|
|
PlayerMovementController controller =
|
|
Assert.IsType<PlayerMovementController>(
|
|
runtime.MovementOwner.Controller);
|
|
|
|
controller.SetPosition(
|
|
new Vector3(48f, 49f, 50f),
|
|
0xA9B40001u);
|
|
projection.ProjectPosition(
|
|
record,
|
|
isLocalPlayer: true,
|
|
PositionTimestampDisposition.Apply);
|
|
|
|
Assert.Equal(new Vector3(48f, 49f, 50f), controller.Position);
|
|
|
|
var force = new WorldSession.EntityPositionUpdate(
|
|
player,
|
|
record.Snapshot.Position!.Value with
|
|
{
|
|
PositionX = 72f,
|
|
PositionY = 73f,
|
|
},
|
|
Velocity: null,
|
|
PlacementId: null,
|
|
IsGrounded: true,
|
|
InstanceSequence: 1,
|
|
PositionSequence: 2,
|
|
TeleportSequence: 0,
|
|
ForcePositionSequence: 1);
|
|
Assert.True(runtime.EntityObjects.TryApplyPosition(
|
|
force,
|
|
isLocalPlayer: true,
|
|
forcePositionRotation: Quaternion.Identity,
|
|
currentLocalVelocity: controller.BodyVelocity,
|
|
projectionRequiresTeleportHook: false,
|
|
acknowledgeProjection: null,
|
|
out PositionTimestampDisposition disposition,
|
|
out _,
|
|
out _));
|
|
Assert.Equal(
|
|
PositionTimestampDisposition.ForcePosition,
|
|
disposition);
|
|
projection.ProjectPosition(
|
|
record,
|
|
isLocalPlayer: true,
|
|
disposition);
|
|
|
|
Assert.Equal(new Vector3(72f, 73f, 50f), controller.Position);
|
|
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 ExecutorCompletedReceiptIsAcknowledgeOnlyRegardlessOfRecordValidity()
|
|
{
|
|
// F1: mirrors PlacementReceiptUsesExactIncarnationAndDiscardIsAckOnly's
|
|
// stale-token half - proves ExecutorCompleted is acknowledged
|
|
// unconditionally (never gated by the record-lookup/portal-shape
|
|
// checks Place/Withdraw depend on), so a genuinely stale/mismatched
|
|
// token can never wedge the FIFO behind it.
|
|
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(0x50000006u))
|
|
.Canonical!;
|
|
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
|
record,
|
|
record.CreateIntegrationVersion,
|
|
record.Snapshot,
|
|
replaceGeneration: false));
|
|
var sink = new HeadlessRuntimePlacementProjectionSink(runtime);
|
|
RuntimePlacementProjectionSnapshot completion = Placement(
|
|
runtime,
|
|
record,
|
|
RuntimePlacementProjectionKind.ExecutorCompleted,
|
|
Vector3.One,
|
|
Quaternion.Identity);
|
|
RuntimePlacementProjectionSnapshot stale = completion with
|
|
{
|
|
Token = completion.Token with
|
|
{
|
|
Entity = completion.Token.Entity with
|
|
{
|
|
Incarnation = unchecked((ushort)(
|
|
completion.Token.Entity.Incarnation + 1)),
|
|
},
|
|
SessionLifetimeVersion = ulong.MaxValue,
|
|
},
|
|
};
|
|
|
|
Assert.True(sink.TryApply(in completion));
|
|
Assert.True(sink.TryApply(in stale));
|
|
}
|
|
|
|
[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()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
RuntimePhysicsState physics = lifetime.Physics;
|
|
const uint landblockId = 0xA9B4FFFFu;
|
|
CompleteCollisionGeneration(
|
|
physics,
|
|
landblockId,
|
|
afterAdmission: null,
|
|
(admission, prepared) =>
|
|
physics.StageCollisionAssets(
|
|
admission,
|
|
prepared,
|
|
CollisionAssets(landblockId, 10f)));
|
|
|
|
Assert.Throws<FixtureCollisionPublicationException>(() =>
|
|
CompleteCollisionGeneration(
|
|
physics,
|
|
landblockId,
|
|
_ => throw new FixtureCollisionPublicationException(),
|
|
(_, _) => throw new InvalidOperationException(
|
|
"Staging must not run after the injected admission fault.")));
|
|
|
|
Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f));
|
|
RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership();
|
|
Assert.Equal(1, ownership.LandblockCount);
|
|
Assert.Equal(0, ownership.CollisionAdmissionCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void CollisionTransactionYieldsTheFirstNonterminalRuntimePoll()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
RuntimePhysicsState physics = lifetime.Physics;
|
|
const uint landblockId = 0xA9B4FFFFu;
|
|
HeadlessCollisionGenerationTransaction transaction =
|
|
HeadlessCollisionGenerationTransaction.Begin(
|
|
physics,
|
|
landblockId,
|
|
afterAdmission: null,
|
|
(admission, prepared) =>
|
|
physics.StageCollisionAssets(
|
|
admission,
|
|
prepared,
|
|
CollisionAssets(landblockId, 10f)));
|
|
|
|
HeadlessCollisionGenerationAdvance advance;
|
|
do
|
|
{
|
|
advance = transaction.Advance();
|
|
Assert.True(advance.Progressed);
|
|
}
|
|
while (!advance.YieldToCaller);
|
|
|
|
Assert.False(advance.Completed);
|
|
Assert.False(advance.WaitingForProjectionAcknowledgement);
|
|
Assert.False(transaction.CompletionCommitted);
|
|
|
|
do
|
|
{
|
|
advance = transaction.Advance();
|
|
Assert.True(advance.Progressed);
|
|
}
|
|
while (!advance.Completed && !advance.YieldToCaller);
|
|
if (!advance.Completed)
|
|
advance = transaction.Advance();
|
|
|
|
Assert.True(advance.Completed);
|
|
Assert.True(transaction.EngineMutationCommitted);
|
|
Assert.True(transaction.CompletionCommitted);
|
|
Assert.Equal(0, physics.CaptureOwnership().CollisionAdmissionCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void CollisionTransactionRetainsPostEngineCancellationUntilPlaceAck()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
RuntimePhysicsState physics = lifetime.Physics;
|
|
const uint landblockId = 0xA9B4FFFFu;
|
|
CompleteCollisionGeneration(
|
|
physics,
|
|
landblockId,
|
|
afterAdmission: null,
|
|
(admission, prepared) =>
|
|
physics.StageCollisionAssets(
|
|
admission,
|
|
prepared,
|
|
CollisionAssets(landblockId, 10f)));
|
|
|
|
const uint guid = 0x70004201u;
|
|
const uint cell = 0xA9B40001u;
|
|
Vector3 position = new(10f, 10f, 0f);
|
|
RuntimeEntityRecord record = lifetime.RegisterEntity(
|
|
Spawn(guid)).Canonical!;
|
|
lifetime.Entities.SetFinalPhysicsState(record, PhysicsStateFlags.Gravity);
|
|
lifetime.Entities.SetFullCell(record, cell, landblockId);
|
|
var body = new PhysicsBody
|
|
{
|
|
Position = position,
|
|
Orientation = Quaternion.Identity,
|
|
LastUpdateTime = 1d,
|
|
State = PhysicsStateFlags.Gravity,
|
|
TransientState = TransientStateFlags.Active,
|
|
};
|
|
body.SnapToCell(cell, position, position);
|
|
lifetime.Entities.SetPhysicsBody(record, body);
|
|
record.ObjectClock.Activate();
|
|
physics.AcknowledgeSpatialProjection(record, spatial: true);
|
|
RuntimePlacementProjectionToken seeded = SeedRuntimePlacement(
|
|
physics,
|
|
record,
|
|
cell,
|
|
position);
|
|
Assert.True(physics.SetPosition.AcknowledgeProjection(seeded));
|
|
|
|
HeadlessCollisionGenerationTransaction transaction =
|
|
HeadlessCollisionGenerationTransaction.Begin(
|
|
physics,
|
|
landblockId,
|
|
afterAdmission: null,
|
|
(admission, prepared) =>
|
|
physics.StageCollisionAssets(
|
|
admission,
|
|
prepared,
|
|
CollisionAssets(landblockId, 20f)));
|
|
HeadlessCollisionGenerationAdvance advance;
|
|
do
|
|
{
|
|
advance = transaction.Advance();
|
|
}
|
|
while (!advance.WaitingForProjectionAcknowledgement);
|
|
Assert.False(transaction.EngineMutationCommitted);
|
|
Assert.True(physics.SetPosition.TryPeekProjection(
|
|
out RuntimePlacementProjectionSnapshot withdrawal));
|
|
Assert.True(physics.SetPosition.AcknowledgeProjection(withdrawal.Token));
|
|
|
|
do
|
|
{
|
|
advance = transaction.Advance();
|
|
}
|
|
while (!advance.WaitingForProjectionAcknowledgement);
|
|
Assert.True(transaction.EngineMutationCommitted);
|
|
Assert.True(physics.SetPosition.TryPeekProjection(
|
|
out RuntimePlacementProjectionSnapshot placement));
|
|
|
|
Assert.False(transaction.TryCancel());
|
|
Assert.True(physics.SetPosition.AcknowledgeProjection(placement.Token));
|
|
Assert.True(transaction.TryCancel());
|
|
RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership();
|
|
Assert.Equal(0, ownership.CollisionPrefixMutationCount);
|
|
Assert.Equal(0, ownership.CollisionAdmissionCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void CollisionTransactionCancelsStagingFaultWithoutWithdrawingActiveWorld()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
RuntimePhysicsState physics = lifetime.Physics;
|
|
const uint landblockId = 0xA9B4FFFFu;
|
|
CompleteCollisionGeneration(
|
|
physics,
|
|
landblockId,
|
|
afterAdmission: null,
|
|
(admission, prepared) =>
|
|
physics.StageCollisionAssets(
|
|
admission,
|
|
prepared,
|
|
CollisionAssets(landblockId, 10f)));
|
|
|
|
Assert.Throws<FixtureCollisionPublicationException>(() =>
|
|
CompleteCollisionGeneration(
|
|
physics,
|
|
landblockId,
|
|
afterAdmission: null,
|
|
(admission, prepared) =>
|
|
{
|
|
physics.StageCollisionAssets(
|
|
admission,
|
|
prepared,
|
|
CollisionAssets(landblockId, 25f));
|
|
throw new FixtureCollisionPublicationException();
|
|
}));
|
|
|
|
Assert.Equal(10f, physics.Engine.SampleTerrainZ(1f, 1f));
|
|
RuntimePhysicsOwnershipSnapshot ownership = physics.CaptureOwnership();
|
|
Assert.Equal(1, ownership.LandblockCount);
|
|
Assert.Equal(0, ownership.CollisionAdmissionCount);
|
|
}
|
|
|
|
private static HeadlessSessionDescriptor Descriptor(
|
|
HeadlessCredentialProviderKind provider =
|
|
HeadlessCredentialProviderKind.Environment,
|
|
string credentialReference = "BOT_PASSWORD") => new()
|
|
{
|
|
Id = "bot",
|
|
Endpoint = new HeadlessEndpointDescriptor
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 9000,
|
|
},
|
|
Account = "account",
|
|
Character = new HeadlessCharacterSelector
|
|
{
|
|
Name = "headless",
|
|
},
|
|
Policy = new HeadlessBotPolicyDescriptor
|
|
{
|
|
Id = "idle",
|
|
},
|
|
Credential = new HeadlessCredentialReference
|
|
{
|
|
Provider = provider,
|
|
Reference = credentialReference,
|
|
},
|
|
};
|
|
|
|
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
|
{
|
|
const uint player = 0x50000002u;
|
|
PhysicsEngine engine = runtime.EntityObjects.Physics.Engine;
|
|
AddFlatLandblock(engine);
|
|
|
|
RuntimeEntityRecord record = runtime.EntityObjects
|
|
.RegisterEntity(Spawn(player))
|
|
.Canonical!;
|
|
Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn(
|
|
record,
|
|
record.CreateIntegrationVersion,
|
|
record.Snapshot,
|
|
replaceGeneration: false));
|
|
var controller = new PlayerMovementController(engine);
|
|
controller.SetPosition(
|
|
new Vector3(96f, 97f, 50f),
|
|
0xA9B40001u,
|
|
new Vector3(96f, 97f, 50f));
|
|
runtime.MovementOwner.Controller = controller;
|
|
}
|
|
|
|
private static void CompleteCollisionGeneration(
|
|
RuntimePhysicsState physics,
|
|
uint landblockId,
|
|
Action<RuntimeCollisionAdmission>? afterAdmission,
|
|
Action<RuntimeCollisionAdmission,
|
|
PreparedLandblockCollisionGeneration> stage)
|
|
{
|
|
HeadlessCollisionGenerationTransaction transaction =
|
|
HeadlessCollisionGenerationTransaction.Begin(
|
|
physics,
|
|
landblockId,
|
|
afterAdmission,
|
|
stage);
|
|
while (true)
|
|
{
|
|
HeadlessCollisionGenerationAdvance advance = transaction.Advance();
|
|
if (advance.Completed)
|
|
return;
|
|
Assert.True(advance.Progressed);
|
|
Assert.False(advance.WaitingForProjectionAcknowledgement);
|
|
}
|
|
}
|
|
|
|
private static RuntimePlacementProjectionToken SeedRuntimePlacement(
|
|
RuntimePhysicsState physics,
|
|
RuntimeEntityRecord record,
|
|
uint cell,
|
|
Vector3 position)
|
|
{
|
|
Type coreMarker = typeof(PhysicsEngine);
|
|
Type requestType = coreMarker.Assembly.GetType(
|
|
"AcDream.Core.Physics.PhysicsSetPositionRequest",
|
|
throwOnError: true)!;
|
|
Type flagsType = coreMarker.Assembly.GetType(
|
|
"AcDream.Core.Physics.PhysicsSetPositionFlags",
|
|
throwOnError: true)!;
|
|
Type placementClassType = coreMarker.Assembly.GetType(
|
|
"AcDream.Core.Physics.PhysicsPlacementClass",
|
|
throwOnError: true)!;
|
|
object request = Activator.CreateInstance(
|
|
requestType,
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
|
binder: null,
|
|
args:
|
|
[
|
|
position,
|
|
Quaternion.Identity,
|
|
cell,
|
|
position,
|
|
ImmutableArray<FlatCollisionSphere>.Empty,
|
|
1f,
|
|
0.4f,
|
|
0.4f,
|
|
PhysicsStateFlags.None,
|
|
ObjectInfoState.None,
|
|
0u,
|
|
Enum.ToObject(placementClassType, 0),
|
|
Enum.ToObject(flagsType, 0x011u),
|
|
Vector3.Zero,
|
|
0f,
|
|
0f,
|
|
0u,
|
|
cell,
|
|
],
|
|
culture: null)!;
|
|
Type runtimeMarker = typeof(RuntimePhysicsState);
|
|
Type commandType = runtimeMarker.Assembly.GetType(
|
|
"AcDream.Runtime.Physics.RuntimeSetPositionCommand",
|
|
throwOnError: true)!;
|
|
Type kindType = runtimeMarker.Assembly.GetType(
|
|
"AcDream.Runtime.Physics.RuntimeSetPositionOperationKind",
|
|
throwOnError: true)!;
|
|
object command = Activator.CreateInstance(
|
|
commandType,
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
|
binder: null,
|
|
args:
|
|
[
|
|
request,
|
|
Enum.ToObject(kindType, 2),
|
|
10d,
|
|
0UL,
|
|
0f,
|
|
0f,
|
|
default(RuntimePortalPlacementAuthority),
|
|
],
|
|
culture: null)!;
|
|
MethodInfo apply = physics.SetPosition.GetType().GetMethod(
|
|
"Apply",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)
|
|
?? throw new MissingMethodException("Runtime SetPosition.Apply");
|
|
object outcome = apply.Invoke(
|
|
physics.SetPosition,
|
|
[record, record.PositionAuthorityVersion, command])!;
|
|
return (RuntimePlacementProjectionToken)(outcome.GetType().GetProperty(
|
|
"Projection",
|
|
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
|
|
?.GetValue(outcome)
|
|
?? throw new MissingMemberException("Runtime placement projection"));
|
|
}
|
|
|
|
private static RuntimeLandblockCollisionAssets CollisionAssets(
|
|
uint landblockId,
|
|
float terrainHeight)
|
|
{
|
|
var heights = new byte[81];
|
|
var table = new float[256];
|
|
table[0] = terrainHeight;
|
|
return new RuntimeLandblockCollisionAssets(
|
|
landblockId,
|
|
new TerrainSurface(heights, table),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
0f,
|
|
0f,
|
|
0u);
|
|
}
|
|
|
|
private sealed class FixtureCollisionPublicationException : Exception;
|
|
|
|
private static void AddFlatLandblock(PhysicsEngine engine)
|
|
{
|
|
var heights = new byte[81];
|
|
Array.Fill(heights, (byte)50);
|
|
var heightTable = new float[256];
|
|
for (int index = 0; index < heightTable.Length; index++)
|
|
heightTable[index] = index;
|
|
engine.AddLandblock(
|
|
0xA9B4FFFFu,
|
|
new TerrainSurface(heights, heightTable),
|
|
[],
|
|
[],
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(uint guid)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
0xA9B40001u,
|
|
96f,
|
|
97f,
|
|
50f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f);
|
|
var timestamps = new PhysicsTimestamps(
|
|
Position: 1,
|
|
Movement: 1,
|
|
State: 1,
|
|
Vector: 1,
|
|
Teleport: 0,
|
|
ServerControlledMove: 1,
|
|
ForcePosition: 0,
|
|
ObjDesc: 1,
|
|
Instance: 1);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x02000001u,
|
|
MotionTableId: null,
|
|
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,
|
|
"Headless",
|
|
null,
|
|
null,
|
|
null,
|
|
PhysicsState: physics.RawState,
|
|
InstanceSequence: 1,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
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)));
|
|
|
|
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
|
{
|
|
public List<WorldSession> Sessions { get; } = [];
|
|
public int CreatedSessionCount { get; private set; }
|
|
public int DisposedSessionCount { get; private set; }
|
|
public string? LastUser { get; private set; }
|
|
public string? LastPassword { get; private set; }
|
|
|
|
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
|
new(IPAddress.Loopback, port);
|
|
|
|
public WorldSession CreateSession(IPEndPoint endpoint)
|
|
{
|
|
CreatedSessionCount++;
|
|
var session = new WorldSession(endpoint);
|
|
Sessions.Add(session);
|
|
return session;
|
|
}
|
|
|
|
public void Connect(
|
|
WorldSession session,
|
|
string user,
|
|
string password)
|
|
{
|
|
LastUser = user;
|
|
LastPassword = password;
|
|
}
|
|
|
|
public CharacterList.Parsed GetCharacters(
|
|
WorldSession session) =>
|
|
new(
|
|
0u,
|
|
[
|
|
new CharacterList.Character(
|
|
0x50000001u,
|
|
"Other",
|
|
0u),
|
|
new CharacterList.Character(
|
|
0x50000002u,
|
|
"Headless",
|
|
0u),
|
|
],
|
|
[],
|
|
11,
|
|
"account",
|
|
true,
|
|
true);
|
|
|
|
public void EnterWorld(
|
|
WorldSession session,
|
|
int activeCharacterIndex)
|
|
{
|
|
}
|
|
|
|
public void Tick(WorldSession session)
|
|
{
|
|
}
|
|
|
|
public void DisposeSession(WorldSession session)
|
|
{
|
|
DisposedSessionCount++;
|
|
session.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class FailOnceTextWriter : StringWriter
|
|
{
|
|
public bool FailNextWrite { get; set; }
|
|
|
|
public override void WriteLine(string? value)
|
|
{
|
|
if (FailNextWrite)
|
|
{
|
|
FailNextWrite = false;
|
|
throw new IOException("fixture write failure");
|
|
}
|
|
base.WriteLine(value);
|
|
}
|
|
}
|
|
|
|
private sealed class FixtureCollisionNeighborhood
|
|
: IHeadlessCollisionNeighborhood
|
|
{
|
|
public int CenterCount { get; private set; }
|
|
public uint LastCell { get; private set; }
|
|
|
|
public void CenterOn(uint fullCellId)
|
|
{
|
|
CenterCount++;
|
|
LastCell = fullCellId;
|
|
}
|
|
|
|
public bool IsReady(uint fullCellId) =>
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|