acdream/tests/AcDream.Runtime.Tests/Physics/RuntimeWorldFrameTests.cs
Erik 97d11e6c7f fix(runtime): name why a placement is parked and fail closed when it cannot resolve
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>
2026-08-03 13:55:16 +02:00

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);
}
}