acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs
Erik 6921a02744 refactor(physics): delete legacy PhysicsEngine.Resolve/ResolvePlacement/HasCellSurface (C5a, AP-1/AD-1)
Member-wise deletion of the three legacy resolver members named in
docs/research/2026-08-05-c5a-contract.md: PhysicsEngine.Resolve,
PhysicsEngine.HasCellSurface, and PhysicsEngine.ResolvePlacement. An
exhaustive receiver census over src/ found zero production callers of any
of the three — every production placement writer already reaches the
canonical PhysicsEngine.SetPosition transaction exclusively through
RuntimeSetPositionState (three call sites total). The deletion is purely
member-wise: IsSpawnCellReady and AdjustPosition, which shared the same
source region as the deleted members, are preserved byte-identical — every
remaining production caller of either (including PhysicsCameraCollisionProbe,
AdjustPosition's sole surviving production caller) is unaffected.

Companion changes:
- PlayerMovementController's 3-argument SetPosition test overload is renamed
  to SeedPlacementForTest (internal) and CommitPreparedPosition is deleted;
  83 call sites across 19 test files were mechanically renamed to match.
- Seven pinned test dispositions from the contract are executed:
  3.1 (PhysicsEngineTests.cs: 11 legacy-resolver tests deleted, 6
  ResolveWithTransition tests kept), 3.2/3.3/3.4 (re-point to canonical
  SetPosition, with TransitionScratchDifferentialTests.cs additionally
  gaining positive IsCommitted assertions after each bitwise comparison so
  the differential proves a placement actually committed, not just that two
  possibly-uncommitted results match), 3.5 (Runtime rename), and 3.6
  (PlayerMovementPlacementTransactionTests.cs rewritten — its xmldoc now
  states plainly that the render-root publish moved to
  RuntimeSetPositionState.cs, but the sticky-release relocation claim was
  false and is retracted; this disposition's coverage loss is the sticky
  release path, not silently absorbed elsewhere).
- Stale `PhysicsEngine.Resolve`/`Resolve` doc citations in CellTransit.cs,
  PlayerMovementController.cs, and HeadlessSessionWorldProjection.cs are
  corrected to name the surviving canonical entry points by symbol
  (SetPosition, AdjustSetPosition/AdjustPosition, ResolveWithTransition)
  rather than fragile line numbers.

Retires AP-1 and AD-1 in docs/architecture/retail-divergence-register.md:
both rows described production zero-delta placement routing remaining on
the legacy resolver pending the Slice 4B2/4B route cutover; that resolver
no longer exists, so the condition each row tracked is now structurally
false rather than merely narrowed. AP-145 (routed through the prior commit)
and this commit's AP-1/AD-1 together bring the section counts to 101 AP / 47
AD active rows.

Builds on the AP-145 fix (previous commit) — this commit's staged tree was
independently rebuilt and its four suites independently rerun on top of
that commit before this commit was created, in addition to the combined
rebuild/rerun below.

Full-solution build: 0 errors (21 pre-existing warnings, all unrelated).
Suite results (combined tree): Core 4270/4271 passed (1 skip; the single
DatSoundCacheTests concurrent-decode-dedup failure is a known load-sensitive
race, confirmed passing standalone and unrelated to this change), Runtime
1176/1176, Headless 86/86, App 4132/4135 (3 skips).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:11:31 +02:00

504 lines
19 KiB
C#

using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeLocalPlayerMovementStateTests
{
[Fact]
public void ViewProjectsTheExactCanonicalControllerAndAutorunOwner()
{
var controller = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 0x50000001u,
};
controller.SeedPlacementForTest(
new Vector3(11f, 12f, 13f),
0xA9B40001u,
new Vector3(11f, 12f, 13f));
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
Assert.Same(movement, movement.View);
Assert.True(movement.Execute(RuntimeMovementCommand.ToggleRunLock));
RuntimeMovementSnapshot snapshot = movement.View.Snapshot;
Assert.True(snapshot.HasController);
Assert.Equal(0x50000001u, snapshot.LocalEntityId);
Assert.Equal(controller.CellPosition, snapshot.Position);
Assert.Equal(controller.BodyVelocity, snapshot.Velocity);
Assert.Equal(controller.IsAirborne, snapshot.IsAirborne);
Assert.Equal(controller.SimTimeSeconds, snapshot.SimulationTimeSeconds);
Assert.True(snapshot.AutoRunActive);
Assert.Equal(movement.Revision, snapshot.Revision);
}
[Fact]
public void GraphicalAndDirectCommandsMutateOneAutorunLatch()
{
using var movement = new RuntimeLocalPlayerMovementState();
var input = new MovementInput(
Forward: true,
Run: true);
Assert.True(movement.Execute(RuntimeMovementCommand.ToggleRunLock));
movement.SetCommandInput(input);
Assert.True(movement.AutoRunActive);
Assert.True(movement.View.Snapshot.AutoRunActive);
Assert.True(movement.HasCommandInput);
Assert.Equal(input, movement.CommandInput);
Assert.Equal(input, movement.View.Snapshot.CommandInput);
Assert.True(movement.Execute(RuntimeMovementCommand.Stop));
Assert.False(movement.AutoRunActive);
Assert.False(movement.View.Snapshot.AutoRunActive);
Assert.False(movement.HasCommandInput);
Assert.False(movement.View.Snapshot.HasCommandInput);
}
[Fact]
public void CommandInputIsDeduplicatedAndResetWithSessionIntent()
{
using var movement = new RuntimeLocalPlayerMovementState();
var input = new MovementInput(
StrafeLeft: true,
Run: true);
movement.SetCommandInput(input);
long revision = movement.Revision;
movement.SetCommandInput(input);
Assert.Equal(revision, movement.Revision);
Assert.True(movement.Snapshot.HasCommandInput);
movement.ResetInputIntent();
Assert.False(movement.HasCommandInput);
Assert.Equal(default, movement.CommandInput);
Assert.Equal(revision + 1, movement.Revision);
}
[Theory]
[InlineData(RuntimeMovementCommand.Ready, MotionCommand.Ready)]
[InlineData(RuntimeMovementCommand.Crouch, MotionCommand.Crouch)]
[InlineData(RuntimeMovementCommand.Sit, MotionCommand.Sitting)]
[InlineData(RuntimeMovementCommand.Sleep, MotionCommand.Sleeping)]
public void StanceCommandsUseCanonicalControllerAndEmitOneMovementEdge(
RuntimeMovementCommand command,
uint expectedMotion)
{
var controller = new PlayerMovementController(new PhysicsEngine());
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
movement.SetCommandInput(
new MovementInput(Forward: true, Run: true));
Assert.True(movement.Execute(command));
Assert.False(movement.HasCommandInput);
Assert.Equal(
expectedMotion,
controller.Motion.RawState.ForwardCommand);
MovementResult first = controller.Update(
1f / 60f,
default);
MovementResult second = controller.Update(
1f / 60f,
default);
Assert.True(first.ShouldSendMovementEvent);
Assert.False(second.ShouldSendMovementEvent);
if (command == RuntimeMovementCommand.Ready)
{
Assert.Null(first.ForwardCommand);
}
else
{
Assert.Equal(expectedMotion, first.ForwardCommand);
}
}
[Fact]
public void ConcurrentRuntimeInstancesHaveIndependentMovementState()
{
using var first = new RuntimeLocalPlayerMovementState();
using var second = new RuntimeLocalPlayerMovementState();
var firstController = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 1u,
};
var secondController = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 2u,
};
first.Controller = firstController;
second.Controller = secondController;
first.Execute(RuntimeMovementCommand.ToggleRunLock);
firstController.SeedPlacementForTest(Vector3.One, 0xA9B40001u, Vector3.One);
secondController.SeedPlacementForTest(
new Vector3(2f),
0xA9B50001u,
new Vector3(2f));
Assert.True(first.Snapshot.AutoRunActive);
Assert.False(second.Snapshot.AutoRunActive);
Assert.Equal(1u, first.Snapshot.LocalEntityId);
Assert.Equal(2u, second.Snapshot.LocalEntityId);
Assert.NotEqual(first.Snapshot.Position, second.Snapshot.Position);
}
[Fact]
public void MotionPreparationPublishesOnlyTheConstructionSeam()
{
using var movement = new RuntimeLocalPlayerMovementState();
IRuntimeLocalPlayerMotionSource source = movement;
var committed = new PlayerMovementController(new PhysicsEngine());
var candidate = new PlayerMovementController(new PhysicsEngine());
movement.Controller = committed;
bool drained = false;
using (movement.BeginMotionPreparation(
candidate,
() =>
{
drained = true;
Assert.Same(committed.Motion, source.Motion);
}))
{
Assert.True(drained);
Assert.Same(committed, movement.Controller);
Assert.Same(candidate.Motion, source.Motion);
}
Assert.Same(committed.Motion, source.Motion);
}
[Fact]
public void TerminalDisposalConvergesWhenPreparationLeaseUnwindsLater()
{
var movement = new RuntimeLocalPlayerMovementState();
var controller = new PlayerMovementController(new PhysicsEngine());
IDisposable preparation = movement.BeginMotionPreparation(controller);
movement.Controller = controller;
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
movement.Dispose();
preparation.Dispose();
RuntimeLocalMovementOwnershipSnapshot ownership =
movement.CaptureOwnership();
Assert.True(ownership.IsConverged);
Assert.Throws<ObjectDisposedException>(
() => movement.Execute(RuntimeMovementCommand.ToggleRunLock));
}
[Fact]
public void TypedSkillOptionsPreserveCompleteValuesAndFallbackPerField()
{
Assert.Equal(
new PlayerMovementConstructionOptions(321, 654),
PlayerMovementConstructionOptions.From(
new RuntimeMovementSkillSnapshot(321, 654, 1)));
Assert.Equal(
new PlayerMovementConstructionOptions(
PlayerMovementConstructionOptions.FallbackRunSkill,
654),
PlayerMovementConstructionOptions.From(
new RuntimeMovementSkillSnapshot(-1, 654, 1)));
Assert.Equal(
new PlayerMovementConstructionOptions(
321,
PlayerMovementConstructionOptions.FallbackJumpSkill),
PlayerMovementConstructionOptions.From(
new RuntimeMovementSkillSnapshot(321, -1, 1)));
}
[Fact]
public void WarmMovementSnapshotsAndOwnershipCaptureAllocateNothing()
{
using var movement = new RuntimeLocalPlayerMovementState();
movement.Controller = new PlayerMovementController(new PhysicsEngine())
{
LocalEntityId = 7u,
};
for (int i = 0; i < 1_000; i++)
{
_ = movement.Snapshot;
_ = movement.CaptureOwnership();
}
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100_000; i++)
{
_ = movement.Snapshot;
_ = movement.CaptureOwnership();
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0L, allocated);
}
// ── C3c-F1 (2026-08-02): movement-stats application seam ──────────────
//
// The typed seam is the ONLY route by which server stat recomputes
// reach the controller; the publication lifecycle decides whether the
// write lands (live/dormant) or is a typed displaced drop (terminal).
private static RuntimeMovementSkillState CompleteSkillState()
{
var skills = new RuntimeMovementSkillState();
skills.Update(runSkill: 240, jumpSkill: 180);
skills.UpdateBurden(0.5f);
skills.UpdateStamina(37);
// BF_PLAYER (0x8) + BF_PLAYER_KILLER (0x20) → ObjectInfoState.IsPK.
skills.UpdateOwnPwdBitfield(0x28u);
skills.UpdatePlayerKillerStatus(1, 123.5f);
return skills;
}
private static PlayerMovementController NewDormantRuntimeController()
{
PlayerMovementController controller =
PlayerMovementController.CreatePublicationCandidate(
new PhysicsEngine(),
PlayerMovementConstructionOptions.Fallback);
controller.SealPublicationCandidate();
controller.CommitRuntimeOwnership(new RetailObjectQuantumClock());
return controller;
}
private static (float RunRate, float JumpVz, bool CanJump, int JumpCost,
ObjectInfoState PvpFlags) CaptureStatObservables(
PlayerMovementController controller)
{
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.InqRunRate(out float runRate));
Assert.True(weenie.InqJumpVelocity(1.0f, out float jumpVz));
bool canJump = weenie.CanJump(1.0f);
Assert.True(((PlayerWeenie)weenie).JumpStaminaCost(1.0f, out int cost));
return (runRate, jumpVz, canJump, cost, controller.OwnPvpFlags);
}
[Fact]
public void ApplyCharacterMovementStats_LiveApplicationIsByteIdenticalToTheRetiredDirectPath()
{
RuntimeMovementSkillState skills = CompleteSkillState();
RuntimeMovementSkillSnapshot snapshot = skills.Snapshot;
// Old direct path (the deleted RuntimeMovementSkillProjection.ApplyTo
// body), applied through the public gated setters.
var direct = new PlayerMovementController(new PhysicsEngine());
direct.SetCharacterSkills(snapshot.RunSkill, snapshot.JumpSkill);
direct.SetCharacterBurden(snapshot.Burden);
direct.SetCharacterStamina(snapshot.CurrentStamina);
direct.OwnPvpFlags = EntityCollisionFlagsExt
.FromPwdBitfield(snapshot.OwnPwdBitfield)
.ToMoverState();
direct.SetCharacterPkStatus(
snapshot.PlayerKillerStatus,
snapshot.LastPkAttackTimestamp);
var routed = new PlayerMovementController(new PhysicsEngine());
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = routed,
};
Assert.Equal(
RuntimeMovementStatsApplication.AppliedLive,
movement.ApplyCharacterMovementStats(skills));
Assert.Equal(
CaptureStatObservables(direct),
CaptureStatObservables(routed));
Assert.Equal(ObjectInfoState.IsPK, routed.OwnPvpFlags);
}
[Fact]
public void ApplyCharacterMovementStats_DormantWindowWriteLandsOnTheControllerThatGoesLive()
{
RuntimeMovementSkillState skills = CompleteSkillState();
PlayerMovementController controller = NewDormantRuntimeController();
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
// The committed-but-unactivated first-entry window: the dormant
// controller is installed in the movement owner while activation
// waits on cell streaming, and inbound recomputes keep pumping.
Assert.True(controller.IsRuntimeOwnedDormant);
Assert.Throws<InvalidOperationException>(
() => controller.SetCharacterSkills(1, 1));
Assert.Equal(
RuntimeMovementStatsApplication.AppliedDormant,
movement.ApplyCharacterMovementStats(skills));
// Activation flips the SAME instance live — the applied values are
// already current when movement starts.
controller.ActivateRuntimePublication();
Assert.True(controller.IsRuntimePublished);
(float runRate, _, _, _, ObjectInfoState pvp) =
CaptureStatObservables(controller);
Assert.Equal(
PlayerWeenie.GetRunRate(0.5f, 240),
runRate,
precision: 5);
Assert.Equal(ObjectInfoState.IsPK, pvp);
}
[Fact]
public void ApplyCharacterMovementStats_TerminalControllerReportsTypedDisplacedDrop()
{
RuntimeMovementSkillState skills = CompleteSkillState();
PlayerMovementController controller = NewDormantRuntimeController();
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
controller.ActivateRuntimePublication();
var before = CaptureStatObservables(controller);
// The Motion surface is gated once terminal; capture the weenie
// reference while the controller is still published.
IWeenieObject weenie = controller.Motion.WeenieObj!;
// Post-teardown transient truth: the controller retired while still
// reachable through a displaced recompute callback. The public
// setter still throws (the seal working); the seam reports the
// typed drop instead and mutates nothing.
controller.RetireRuntimePublication();
Assert.Throws<InvalidOperationException>(
() => controller.SetCharacterSkills(1, 1));
Assert.Equal(
RuntimeMovementStatsApplication.DroppedDisplacedController,
movement.ApplyCharacterMovementStats(skills));
Assert.True(weenie.InqRunRate(out float runRate));
Assert.Equal(before.RunRate, runRate);
Assert.Equal(before.PvpFlags, controller.OwnPvpFlags);
}
[Fact]
public void ApplyCharacterMovementStats_SealedCandidateIsATypedDisplacedDrop()
{
// Defensive lifecycle-matrix row: a sealed candidate is frozen for
// the Prepare→Commit validation handshake and never installed into
// the movement owner in production (Prepare requires the owner
// empty; Commit installs it already-dormant within the same
// synchronous conductor step), so only a direct controller-level
// call can observe this row.
PlayerMovementController controller =
PlayerMovementController.CreatePublicationCandidate(
new PhysicsEngine(),
PlayerMovementConstructionOptions.Fallback);
controller.SealPublicationCandidate();
Assert.Equal(
RuntimeMovementStatsApplication.DroppedDisplacedController,
controller.ApplyCharacterMovementStats(
CompleteSkillState().Snapshot));
}
[Fact]
public void ApplyCharacterMovementStats_AbsentControllerAndIncompleteSnapshotDropSilently()
{
var skills = new RuntimeMovementSkillState();
using var movement = new RuntimeLocalPlayerMovementState();
Assert.Equal(
RuntimeMovementStatsApplication.DroppedNoController,
movement.ApplyCharacterMovementStats(skills));
movement.Controller = new PlayerMovementController(new PhysicsEngine());
Assert.Equal(
RuntimeMovementStatsApplication.DroppedIncompleteSnapshot,
movement.ApplyCharacterMovementStats(skills));
}
[Fact]
public void ApplyCharacterMovementStats_DisposedOwnerToleratesTheDisplacedCallback()
{
var movement = new RuntimeLocalPlayerMovementState
{
Controller = new PlayerMovementController(new PhysicsEngine()),
};
movement.Dispose();
// The displaced-callback-rejection pattern: a recompute landing
// after terminal disposal observes a typed drop, never a fault.
Assert.Equal(
RuntimeMovementStatsApplication.DroppedNoController,
movement.ApplyCharacterMovementStats(CompleteSkillState()));
Assert.False(movement.ReportExhaustion());
}
[Fact]
public void ReportExhaustion_DispatchesOnlyThroughALiveController()
{
using var movement = new RuntimeLocalPlayerMovementState();
Assert.False(movement.ReportExhaustion());
PlayerMovementController controller = NewDormantRuntimeController();
movement.Controller = controller;
Assert.False(movement.ReportExhaustion());
controller.ActivateRuntimePublication();
Assert.True(movement.ReportExhaustion());
controller.RetireRuntimePublication();
Assert.False(movement.ReportExhaustion());
}
[Fact]
public void ApplyServerPhysicsState_DormantDropsForActivationAndLiveAppliesExactly()
{
// The second connected-gate crash chain
// (logs/connected-world-gate-20260802-125907): an inbound local-
// player SetState pushed ApplyPhysicsState at the dormant
// first-entry controller. The typed entry decides by lifecycle.
PlayerMovementController controller =
PlayerMovementController.CreatePublicationCandidate(
new PhysicsEngine(),
PlayerMovementConstructionOptions.Fallback);
PhysicsBody body = controller.PhysicsBody;
PhysicsStateFlags initial = body.State;
PhysicsStateFlags pushed =
PhysicsStateFlags.Gravity
| PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.Ethereal;
Assert.NotEqual(initial, pushed);
controller.SealPublicationCandidate();
controller.CommitRuntimeOwnership(new RetailObjectQuantumClock());
// Dormant: the activation transaction owns the dormant body's
// physics state; the push is dropped and the body is untouched.
Assert.Equal(
RuntimeServerPhysicsStateApplication.DroppedDormantActivationOwned,
controller.ApplyServerPhysicsState(pushed));
Assert.Equal(initial, body.State);
// Published: byte-identical to the direct ApplyPhysicsState body.
controller.ActivateRuntimePublication();
Assert.Equal(
RuntimeServerPhysicsStateApplication.AppliedLive,
controller.ApplyServerPhysicsState(pushed));
Assert.Equal(pushed, body.State);
// Terminal: a displaced push, typed instead of a fault, mutating
// nothing.
controller.RetireRuntimePublication();
Assert.Throws<InvalidOperationException>(
() => controller.ApplyPhysicsState(initial));
Assert.Equal(
RuntimeServerPhysicsStateApplication.DroppedDisplacedController,
controller.ApplyServerPhysicsState(initial));
Assert.Equal(pushed, body.State);
}
}