feat(runtime): own placement projection acknowledgement
This commit is contained in:
parent
5785a07b3e
commit
ef43667872
2 changed files with 534 additions and 0 deletions
|
|
@ -0,0 +1,119 @@
|
|||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IRuntimePlacementProjectionSink
|
||||
{
|
||||
bool TryApply(in RuntimePlacementProjectionSnapshot projection);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class RuntimePlacementProjectionSubscription
|
||||
: IRuntimePlacementObserver,
|
||||
IDisposable
|
||||
{
|
||||
private readonly RuntimePlacementProjectionChannel _channel;
|
||||
private readonly Func<RuntimeGenerationToken> _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<RuntimeGenerationToken> 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;
|
||||
|
||||
/// <summary>
|
||||
/// Republishes Runtime's complete still-pending FIFO. Later receipts are
|
||||
/// ignored until the exact oldest receipt projects and acknowledges.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvalidOperationException>(
|
||||
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<ObjectDisposedException>(() =>
|
||||
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<RuntimePlacementProjectionSnapshot, bool>? apply = null)
|
||||
: IRuntimePlacementProjectionSink
|
||||
{
|
||||
internal List<RuntimePlacementProjectionSnapshot> 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<FlatCollisionSphere>.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<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
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<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"placement-projection-fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.Gravity,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue