diff --git a/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs new file mode 100644 index 00000000..bc48214c --- /dev/null +++ b/src/AcDream.Runtime/Physics/RuntimePlacementProjectionSubscription.cs @@ -0,0 +1,119 @@ +using AcDream.Runtime.Entities; + +namespace AcDream.Runtime.Physics; + +/// +/// Presentation-only sink for canonical Runtime SetPosition receipts. +/// Implementations must apply an exact receipt idempotently: acknowledgement +/// can fail after a successful projection when a re-entrant Runtime mutation +/// revises the FIFO head, and the same immutable receipt may then be retried. +/// +public interface IRuntimePlacementProjectionSink +{ + bool TryApply(in RuntimePlacementProjectionSnapshot projection); +} + +/// +/// Shared graphical/no-window subscription which projects only the oldest +/// canonical receipt and acknowledges it only after the host sink succeeds. +/// Runtime remains the sole position, collision, residence, and lifetime +/// authority; this class owns only its observer subscription and one +/// idempotency token for a projection that succeeded before acknowledgement. +/// +public sealed class RuntimePlacementProjectionSubscription + : IRuntimePlacementObserver, + IDisposable +{ + private readonly RuntimePlacementProjectionChannel _channel; + private readonly Func _generation; + private readonly IRuntimePlacementProjectionSink _sink; + private IDisposable? _subscription; + private RuntimePlacementProjectionToken _appliedAwaitingAcknowledgement; + private bool _disposed; + + public RuntimePlacementProjectionSubscription( + GameRuntime runtime, + IRuntimePlacementProjectionSink sink) + : this( + runtime?.Placements + ?? throw new ArgumentNullException(nameof(runtime)), + () => runtime.Generation, + sink) + { + } + + internal RuntimePlacementProjectionSubscription( + RuntimePlacementProjectionChannel channel, + Func generation, + IRuntimePlacementProjectionSink sink) + { + _channel = channel ?? throw new ArgumentNullException(nameof(channel)); + _generation = generation + ?? throw new ArgumentNullException(nameof(generation)); + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + _subscription = _channel.Subscribe(this); + _ = RetryPending(); + } + + public bool HasAppliedReceiptAwaitingAcknowledgement => + _appliedAwaitingAcknowledgement.IsValid; + + /// + /// Republishes Runtime's complete still-pending FIFO. Later receipts are + /// ignored until the exact oldest receipt projects and acknowledges. + /// + public bool RetryPending() + { + ObjectDisposedException.ThrowIf(_disposed, this); + RuntimeGenerationToken generation = _generation(); + if (_appliedAwaitingAcknowledgement.IsValid + && (!_channel.TryPeek( + generation, + out RuntimePlacementProjectionSnapshot head) + || head.Token != _appliedAwaitingAcknowledgement)) + { + _appliedAwaitingAcknowledgement = default; + } + return _channel.RetryPending(generation); + } + + public void OnPlacement(in RuntimePlacementDelta delta) + { + if (_disposed + || !_channel.TryPeek( + delta.Stamp.Generation, + out RuntimePlacementProjectionSnapshot head) + || head != delta.Placement) + { + return; + } + + RuntimePlacementProjectionToken token = head.Token; + if (_appliedAwaitingAcknowledgement != token) + { + if (!_sink.TryApply(in head)) + return; + // A sink can synchronously tear down its host while applying a + // receipt. Leave that receipt pending for the replacement host; + // disposal is never permission to acknowledge afterward. + if (_disposed) + return; + _appliedAwaitingAcknowledgement = token; + } + + if (_channel.Acknowledge(delta.Stamp.Generation, token) + && _appliedAwaitingAcknowledgement == token) + { + _appliedAwaitingAcknowledgement = default; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Interlocked.Exchange(ref _subscription, null)?.Dispose(); + _appliedAwaitingAcknowledgement = default; + } +} diff --git a/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs b/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs new file mode 100644 index 00000000..541a0ac3 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Physics/RuntimePlacementProjectionSubscriptionTests.cs @@ -0,0 +1,415 @@ +using System.Collections.Immutable; +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Runtime.Entities; +using AcDream.Runtime.Physics; + +namespace AcDream.Runtime.Tests.Physics; + +public sealed class RuntimePlacementProjectionSubscriptionTests +{ + private const uint Landblock = 0xA9B40000u; + private const uint Cell = Landblock | 0x0001u; + + [Fact] + public void SuccessfulSinkAcknowledgesExactPlaceSynchronously() + { + using var fixture = new Fixture(); + var sink = new RecordingSink(); + using var subscription = fixture.Subscribe(sink); + + RuntimeSetPositionOutcome outcome = fixture.Place( + fixture.First, + new Vector3(12f, 18f, 7f)); + + Assert.Equal( + RuntimeSetPositionStatus.CommittedHostAcknowledgementPending, + outcome.Status); + RuntimePlacementProjectionSnapshot only = Assert.Single(sink.Applied); + Assert.Equal(RuntimePlacementProjectionKind.Place, only.Kind); + Assert.Equal(outcome.Projection, only.Token); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + } + + [Fact] + public void RejectedSinkRetainsHeadAndRetryAppliesItExactly() + { + using var fixture = new Fixture(); + bool ready = false; + var sink = new RecordingSink(_ => ready); + using var subscription = fixture.Subscribe(sink); + RuntimeSetPositionOutcome outcome = fixture.Place( + fixture.First, + new Vector3(13f, 18f, 7f)); + + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Single(sink.Applied); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + + ready = true; + Assert.True(subscription.RetryPending()); + + Assert.Equal(2, sink.Applied.Count); + Assert.All(sink.Applied, + projection => Assert.Equal(outcome.Projection, projection.Token)); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + [Fact] + public void ThrowingSinkLeavesReceiptRetryableAndRecordsDispatchFailure() + { + using var fixture = new Fixture(); + bool fail = true; + var sink = new RecordingSink(_ => + { + if (fail) + throw new InvalidOperationException("host unavailable"); + return true; + }); + using var subscription = fixture.Subscribe(sink); + _ = fixture.Place(fixture.First, new Vector3(14f, 18f, 7f)); + + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Equal(1, fixture.Lifetime.Events.DispatchFailureCount); + Assert.IsType( + fixture.Lifetime.Events.LastDispatchFailure); + + fail = false; + Assert.True(subscription.RetryPending()); + + Assert.Equal(2, sink.Applied.Count); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + [Fact] + public void LaterReceiptCannotProjectBeforeRejectedFifoHead() + { + using var fixture = new Fixture(secondEntity: true); + bool ready = false; + RuntimeEntityKey firstKey = fixture.First.Key!.Value; + var sink = new RecordingSink(projection => + ready || projection.Token.Entity != firstKey); + using var subscription = fixture.Subscribe(sink); + + _ = fixture.Place(fixture.First, new Vector3(15f, 18f, 7f)); + _ = fixture.Place(fixture.Second!, new Vector3(16f, 18f, 7f)); + + RuntimePlacementProjectionSnapshot initial = Assert.Single(sink.Applied); + Assert.Equal(firstKey, initial.Token.Entity); + Assert.Equal(2, fixture.Lifetime.Placements.PendingCount); + + ready = true; + Assert.True(subscription.RetryPending()); + + Assert.Equal( + [firstKey, firstKey, fixture.Second!.Key!.Value], + sink.Applied.Select(item => item.Token.Entity).ToArray()); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + [Fact] + public void ReentrantReplacementProjectsRevisedDiscardBeforeAcknowledging() + { + using var fixture = new Fixture(); + RuntimeEntityPlacementToken replacement = default; + var sink = new RecordingSink(projection => + { + if (projection.Kind is RuntimePlacementProjectionKind.Place) + { + replacement = fixture.Lifetime.Physics.SetPosition + .BeginAcceptedPlacement( + fixture.First, + fixture.First.PositionAuthorityVersion, + RuntimeSetPositionOperationKind.RemoteAuthoritative); + } + return true; + }); + using var subscription = fixture.Subscribe(sink); + + RuntimeSetPositionOutcome original = fixture.Place( + fixture.First, + new Vector3(17f, 18f, 7f)); + + Assert.True(replacement.IsValid); + Assert.Equal(2, sink.Applied.Count); + Assert.Equal(RuntimePlacementProjectionKind.Place, + sink.Applied[0].Kind); + Assert.Equal(RuntimePlacementProjectionKind.Discard, + sink.Applied[1].Kind); + Assert.Equal(original.Projection.Sequence, + sink.Applied[1].Token.Sequence); + Assert.True(sink.Applied[1].Token.Revision + > original.Projection.Revision); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + + fixture.Lifetime.Physics.SetPosition.Forget( + fixture.First, + releasePreparedMover: true); + } + + [Fact] + public void ConstructorDrainsReceiptsCreatedBeforeSubscriptionInFifoOrder() + { + using var fixture = new Fixture(secondEntity: true); + RuntimeSetPositionOutcome first = fixture.Place( + fixture.First, + new Vector3(18f, 18f, 7f)); + RuntimeSetPositionOutcome second = fixture.Place( + fixture.Second!, + new Vector3(19f, 18f, 7f)); + Assert.Equal(2, fixture.Lifetime.Placements.PendingCount); + var sink = new RecordingSink(); + + using var subscription = fixture.Subscribe(sink); + + Assert.Equal( + [first.Projection, second.Projection], + sink.Applied.Select(item => item.Token).ToArray()); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + } + + [Fact] + public void SyntheticWithdrawalUsesTheSameExactSinkAndAckPath() + { + using var fixture = new Fixture(); + var sink = new RecordingSink(); + using var subscription = fixture.Subscribe(sink); + + Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel( + fixture.First, + publishWithdrawal: true)); + + RuntimePlacementProjectionSnapshot withdrawal = Assert.Single( + sink.Applied); + Assert.Equal(RuntimePlacementProjectionKind.Withdraw, withdrawal.Kind); + Assert.Equal(fixture.First.Key, withdrawal.Token.Entity); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + } + + [Fact] + public void DisposeUnsubscribesWithoutConsumingPendingRuntimeReceipt() + { + using var fixture = new Fixture(); + var sink = new RecordingSink(_ => false); + var subscription = fixture.Subscribe(sink); + _ = fixture.Place(fixture.First, new Vector3(18f, 18f, 7f)); + Assert.Equal(1, fixture.Lifetime.Events.PlacementSubscriberCount); + + subscription.Dispose(); + + Assert.Equal(0, fixture.Lifetime.Events.PlacementSubscriberCount); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.Throws(() => + subscription.RetryPending()); + + var replacementSink = new RecordingSink(); + using var replacement = fixture.Subscribe(replacementSink); + Assert.Single(replacementSink.Applied); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + [Fact] + public void ReentrantSinkDisposalNeverAcknowledgesAfterHostTeardown() + { + using var fixture = new Fixture(); + RuntimePlacementProjectionSubscription? subscription = null; + var sink = new RecordingSink(_ => + { + subscription!.Dispose(); + return true; + }); + subscription = fixture.Subscribe(sink); + + _ = fixture.Place(fixture.First, new Vector3(19f, 18f, 7f)); + + Assert.Single(sink.Applied); + Assert.Equal(0, fixture.Lifetime.Events.PlacementSubscriberCount); + Assert.Equal(1, fixture.Lifetime.Placements.PendingCount); + Assert.False(subscription.HasAppliedReceiptAwaitingAcknowledgement); + + var replacementSink = new RecordingSink(); + using var replacement = fixture.Subscribe(replacementSink); + Assert.Single(replacementSink.Applied); + Assert.Equal(0, fixture.Lifetime.Placements.PendingCount); + } + + private sealed class Fixture : IDisposable + { + private readonly RuntimeGenerationToken _generation = new(7UL); + + internal Fixture(bool secondEntity = false) + { + Lifetime = new RuntimeEntityObjectLifetime(FlatEngine()); + Lifetime.BindEventContext( + () => _generation, + static () => 11UL); + First = CreateRecord(Lifetime, 0x70002001u, 1); + AttachBody(Lifetime, First, new Vector3(10f, 20f, 7f)); + if (secondEntity) + { + Second = CreateRecord(Lifetime, 0x70002002u, 1); + AttachBody(Lifetime, Second, new Vector3(11f, 20f, 7f)); + } + } + + internal RuntimeEntityObjectLifetime Lifetime { get; } + internal RuntimeEntityRecord First { get; } + internal RuntimeEntityRecord? Second { get; } + + internal RuntimePlacementProjectionSubscription Subscribe( + IRuntimePlacementProjectionSink sink) => new( + Lifetime.Placements, + () => _generation, + sink); + + internal RuntimeSetPositionOutcome Place( + RuntimeEntityRecord record, + Vector3 position) => Lifetime.Physics.SetPosition.Apply( + record, + record.PositionAuthorityVersion, + Command(position)); + + public void Dispose() => Lifetime.Dispose(); + } + + private sealed class RecordingSink( + Func? apply = null) + : IRuntimePlacementProjectionSink + { + internal List Applied { get; } = []; + + public bool TryApply( + in RuntimePlacementProjectionSnapshot projection) + { + Applied.Add(projection); + return apply?.Invoke(projection) ?? true; + } + } + + private static RuntimeSetPositionCommand Command(Vector3 position) => new( + new PhysicsSetPositionRequest( + position, + Quaternion.Identity, + Cell, + position, + ImmutableArray.Empty, + Scale: 1f, + StepUpHeight: 0.4f, + StepDownHeight: 0.4f, + Flags: PhysicsSetPositionFlags.Placement + | PhysicsSetPositionFlags.Slide), + RuntimeSetPositionOperationKind.RemoteAuthoritative, + GameTime: 10d, + ExpectedVelocityAuthorityVersion: 0UL); + + private static RuntimeEntityRecord CreateRecord( + RuntimeEntityObjectLifetime lifetime, + uint guid, + ushort incarnation) + { + RuntimeEntityRecord record = lifetime.RegisterEntity( + Spawn(guid, incarnation)).Canonical!; + lifetime.Entities.SetFinalPhysicsState( + record, + PhysicsStateFlags.Gravity); + return record; + } + + private static void AttachBody( + RuntimeEntityObjectLifetime lifetime, + RuntimeEntityRecord record, + Vector3 position) + { + lifetime.Entities.SetFullCell(record, Cell, Landblock | 0xFFFFu); + 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(); + lifetime.Physics.AcknowledgeSpatialProjection(record, spatial: true); + } + + private static PhysicsEngine FlatEngine() + { + var engine = new PhysicsEngine + { + DataCache = new PhysicsDataCache(), + }; + engine.AddLandblock( + Landblock, + new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + + private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance) + { + var position = new CreateObject.ServerPosition( + Cell, 10f, 20f, 7f, 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: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)PhysicsStateFlags.Gravity, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: 0x09000001u, + 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, + Array.Empty(), + Array.Empty(), + Array.Empty(), + null, + null, + "placement-projection-fixture", + null, + null, + 0x09000001u, + PhysicsState: (uint)PhysicsStateFlags.Gravity, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } +}