Fixes #284 (plan S1).
A first-entry placement that could not be prepared returned
RetrySetupUnavailable and was re-Advanced every pump forever. Nothing counted
it, nothing named its cause, and nothing distinguished "waiting for something
that will arrive" from "waiting for something that never can". That is why
#281's 43 test failures presented as four unrelated symptoms across App and
Runtime instead of one cause, and why a stuck entity in the live client simply
never appears with no log line to follow.
Worse, the two causes were conflated: 670f307c's missing-world-frame park
reported itself as RetrySetupUnavailable, sending anyone diagnosing it to the
prepared-asset pipeline rather than to the absent local-player Create that
actually publishes the frame.
- RetryWorldFrameUnavailable splits the two causes. Call sites now ask
IsRetryable() instead of comparing against one reason, so a future retry
reason cannot be silently reclassified as a hard rejection - the exact way
this class of bug hides.
- The operation retains its RuntimeSetPositionParkReason, and
RuntimeSetPositionOwnershipSnapshot reports parked work by cause
(ParkedAwaitingSetupCollisionCount / ParkedAwaitingWorldFrameCount /
ParkedPlacementCount), so parked placements appear wherever ledgers are
already asserted.
- ObserveLocalPlayerCreate records the accepted local-player Create even when
it carries no landblock - precisely the case where no frame is ever
published - and ThrowIfWorldFrameUnreachable makes that contradiction
terminal. Waiting is legitimate only while that Create is outstanding; after
it, no later pump can supply the frame. Same shape as 01f4791e, which made a
violated receipt-ledger invariant terminal rather than resumable.
This is observability plus fail-fast. There is no timeout, no retry cap, and
no grace period anywhere in it; retryable work still retries exactly as before
and no placement behaviour changed.
The parked counts are deliberately NOT folded into IsConverged: #277 documents
a far Create legitimately parking for a whole session, so a parked entry at
teardown is not automatically a defect. Wiring them into the connected gates
is carried with #277's service-window conversion, where "legitimately parked"
becomes definable.
Runtime 1,012/1,012. Complete Release solution: 10,834 passed / 4 skipped /
0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
11 KiB
C#
311 lines
11 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Coverage for the world-frame owner introduced by <c>670f307c</c>
|
|
/// (<c>RuntimePhysicsState.ObserveLocalWorldFrame</c> /
|
|
/// <c>TryGetWorldFrameOffset</c>). CreateObject and Position frames are
|
|
/// landblock-local; Runtime converts them into the one streamed world frame
|
|
/// before <c>SetPosition</c>. That mechanism shipped with no tests, and its
|
|
/// absence silently parked every remote first-entry placement on
|
|
/// <c>RetrySetupUnavailable</c>, so these pin its exact contract:
|
|
/// the local player publishes it, remotes never do, ordinary movement across
|
|
/// a landblock boundary must NOT rebase it, and an accepted teleport must.
|
|
///
|
|
/// The "no rebase on ordinary movement" rule is not an arbitrary choice — it
|
|
/// matches App's own coordinate owner, <c>LiveWorldOriginState</c>, whose
|
|
/// <c>Recenter</c> is called only from
|
|
/// <c>StreamingOriginRecenterCoordinator.Advance</c> at a teleport boundary.
|
|
/// If these two ever disagree, remote objects are placed at a multiple of
|
|
/// 192 m from where the world is streamed.
|
|
/// </summary>
|
|
public sealed class RuntimeWorldFrameTests
|
|
{
|
|
private const uint CenterLandblock = 0xA9B60000u;
|
|
private const uint CenterCell = CenterLandblock | 0x0001u;
|
|
|
|
[Fact]
|
|
public void LocalPlayerCreate_PublishesTheWorldFrameAtItsLandblock()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
BindGeneration(lifetime);
|
|
|
|
Assert.False(lifetime.Physics.TryGetWorldFrameOffset(
|
|
CenterCell,
|
|
out _,
|
|
out _));
|
|
|
|
lifetime.RegisterEntityWithInitialResidence(
|
|
Spawn(0x50000001u, CenterCell),
|
|
isLocalPlayer: true);
|
|
|
|
Assert.True(lifetime.Physics.TryGetWorldFrameOffset(
|
|
CenterCell,
|
|
out float offsetX,
|
|
out float offsetY));
|
|
Assert.Equal(0f, offsetX);
|
|
Assert.Equal(0f, offsetY);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoteCreate_NeverPublishesTheWorldFrame()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
BindGeneration(lifetime);
|
|
|
|
lifetime.RegisterEntityWithInitialResidence(
|
|
Spawn(0x70000001u, CenterCell),
|
|
isLocalPlayer: false);
|
|
|
|
// Without the frame a remote conductor must park rather than commit a
|
|
// landblock-local origin as if it were already a world position.
|
|
Assert.False(lifetime.Physics.TryGetWorldFrameOffset(
|
|
CenterCell,
|
|
out _,
|
|
out _));
|
|
}
|
|
|
|
[Theory]
|
|
// One landblock east is +192 m on X; one north is +192 m on Y.
|
|
[InlineData(0xAAB60001u, 192f, 0f)]
|
|
[InlineData(0xA8B60001u, -192f, 0f)]
|
|
[InlineData(0xA9B70001u, 0f, 192f)]
|
|
[InlineData(0xA9B50001u, 0f, -192f)]
|
|
public void NeighbouringLandblocks_ConvertAt192MetresPerStep(
|
|
uint cellId,
|
|
float expectedX,
|
|
float expectedY)
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
BindGeneration(lifetime);
|
|
lifetime.Physics.ObserveLocalWorldFrame(
|
|
CenterCell,
|
|
teleportAdvanced: false);
|
|
|
|
Assert.True(lifetime.Physics.TryGetWorldFrameOffset(
|
|
cellId,
|
|
out float offsetX,
|
|
out float offsetY));
|
|
Assert.Equal(expectedX, offsetX);
|
|
Assert.Equal(expectedY, offsetY);
|
|
}
|
|
|
|
[Fact]
|
|
public void OrdinaryMovementAcrossALandblockBoundary_DoesNotRebaseTheFrame()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
BindGeneration(lifetime);
|
|
lifetime.Physics.ObserveLocalWorldFrame(
|
|
CenterCell,
|
|
teleportAdvanced: false);
|
|
|
|
// Walking east into the next landblock: an accepted Position, but no
|
|
// teleport. The streamed world keeps its origin, so the frame must
|
|
// stay put and that landblock must still convert to +192 m.
|
|
lifetime.Physics.ObserveLocalWorldFrame(
|
|
0xAAB60001u,
|
|
teleportAdvanced: false);
|
|
|
|
Assert.True(lifetime.Physics.TryGetWorldFrameOffset(
|
|
0xAAB60001u,
|
|
out float offsetX,
|
|
out float offsetY));
|
|
Assert.Equal(192f, offsetX);
|
|
Assert.Equal(0f, offsetY);
|
|
}
|
|
|
|
[Fact]
|
|
public void AcceptedTeleport_RebasesTheFrameOnTheDestination()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
BindGeneration(lifetime);
|
|
lifetime.Physics.ObserveLocalWorldFrame(
|
|
CenterCell,
|
|
teleportAdvanced: false);
|
|
|
|
lifetime.Physics.ObserveLocalWorldFrame(
|
|
0xAAB60001u,
|
|
teleportAdvanced: true);
|
|
|
|
// The destination is now the origin, and the departure landblock sits
|
|
// one step west of it.
|
|
Assert.True(lifetime.Physics.TryGetWorldFrameOffset(
|
|
0xAAB60001u,
|
|
out float destinationX,
|
|
out float destinationY));
|
|
Assert.Equal(0f, destinationX);
|
|
Assert.Equal(0f, destinationY);
|
|
|
|
Assert.True(lifetime.Physics.TryGetWorldFrameOffset(
|
|
CenterCell,
|
|
out float sourceX,
|
|
out _));
|
|
Assert.Equal(-192f, sourceX);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #284: waiting for the world frame is legitimate only while the
|
|
/// local-player Create is still outstanding. The frame is published once
|
|
/// per session from that Create, so once it has been accepted WITHOUT
|
|
/// publishing one - a local-player CreateObject carrying no landblock -
|
|
/// no later pump can ever supply it, and every remote placement would
|
|
/// retry forever in silence. That is a violated invariant, not a wait.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ALocalPlayerCreateWithNoLandblock_MakesTheFrameUnreachable()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
BindGeneration(lifetime);
|
|
|
|
// Before the local player is seen at all, waiting is legitimate.
|
|
lifetime.Physics.ThrowIfWorldFrameUnreachable(CenterCell);
|
|
|
|
lifetime.Physics.ObserveLocalPlayerCreate(0u);
|
|
|
|
InvalidOperationException error =
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
lifetime.Physics.ThrowIfWorldFrameUnreachable(CenterCell));
|
|
Assert.Contains("world frame is unreachable", error.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void AnAcceptedLocalPlayerCreate_LeavesTheFrameReachable()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
BindGeneration(lifetime);
|
|
|
|
lifetime.Physics.ObserveLocalPlayerCreate(CenterCell);
|
|
|
|
// The frame exists, so nothing is unreachable and nothing throws.
|
|
lifetime.Physics.ThrowIfWorldFrameUnreachable(CenterCell);
|
|
Assert.True(lifetime.Physics.TryGetWorldFrameOffset(
|
|
CenterCell,
|
|
out _,
|
|
out _));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #284: a missing world frame used to report itself as
|
|
/// RetrySetupUnavailable, sending anyone diagnosing a parked placement to
|
|
/// the asset pipeline rather than the absent local-player Create. Both
|
|
/// remain retryable - only the reported cause differs.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ParkReasons_AreDistinctAndBothRetryable()
|
|
{
|
|
Assert.True(RuntimeSetPositionMoverPreparationStatus
|
|
.RetrySetupUnavailable.IsRetryable());
|
|
Assert.True(RuntimeSetPositionMoverPreparationStatus
|
|
.RetryWorldFrameUnavailable.IsRetryable());
|
|
Assert.False(RuntimeSetPositionMoverPreparationStatus
|
|
.RejectedAuthority.IsRetryable());
|
|
Assert.False(RuntimeSetPositionMoverPreparationStatus
|
|
.InvalidData.IsRetryable());
|
|
Assert.False(RuntimeSetPositionMoverPreparationStatus
|
|
.Prepared.IsRetryable());
|
|
|
|
Assert.Equal(
|
|
RuntimeSetPositionParkReason.AwaitingSetupCollision,
|
|
RuntimeSetPositionMoverPreparationStatus.RetrySetupUnavailable
|
|
.ParkReason());
|
|
Assert.Equal(
|
|
RuntimeSetPositionParkReason.AwaitingWorldFrame,
|
|
RuntimeSetPositionMoverPreparationStatus.RetryWorldFrameUnavailable
|
|
.ParkReason());
|
|
Assert.Equal(
|
|
RuntimeSetPositionParkReason.None,
|
|
RuntimeSetPositionMoverPreparationStatus.Prepared.ParkReason());
|
|
}
|
|
|
|
[Fact]
|
|
public void AZeroCellIdNeitherPublishesNorResolves()
|
|
{
|
|
using var lifetime = new RuntimeEntityObjectLifetime();
|
|
BindGeneration(lifetime);
|
|
|
|
lifetime.Physics.ObserveLocalWorldFrame(0u, teleportAdvanced: false);
|
|
Assert.False(lifetime.Physics.TryGetWorldFrameOffset(
|
|
CenterCell,
|
|
out _,
|
|
out _));
|
|
|
|
lifetime.Physics.ObserveLocalWorldFrame(
|
|
CenterCell,
|
|
teleportAdvanced: false);
|
|
Assert.False(lifetime.Physics.TryGetWorldFrameOffset(
|
|
0u,
|
|
out _,
|
|
out _));
|
|
}
|
|
|
|
private static void BindGeneration(RuntimeEntityObjectLifetime lifetime)
|
|
{
|
|
var generation = new RuntimeGenerationToken(1UL);
|
|
lifetime.BindEventContext(() => generation, static () => 1UL);
|
|
}
|
|
|
|
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
cell, 1f, 2f, 3f, 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: null,
|
|
MotionTableId: 0x09000001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: 1f,
|
|
Friction: 0.5f,
|
|
Elasticity: 0.05f,
|
|
Translucency: null,
|
|
Velocity: Vector3.Zero,
|
|
Acceleration: null,
|
|
AngularVelocity: Vector3.Zero,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: timestamps);
|
|
return new WorldSession.EntitySpawn(
|
|
Guid: guid,
|
|
Position: position,
|
|
SetupTableId: null,
|
|
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
|
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
|
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: null,
|
|
ObjScale: 1f,
|
|
Name: "world-frame-fixture",
|
|
ItemType: null,
|
|
MotionState: null,
|
|
MotionTableId: 0x09000001u,
|
|
PhysicsState: physics.RawState,
|
|
ObjectDescriptionFlags: 0x8u,
|
|
Friction: 0.5f,
|
|
Elasticity: 0.05f,
|
|
InstanceSequence: 1,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
}
|