feat(R4-V2): MoveToManager verbatim - all 33 members + conformance harness (closes M1/M3/M4/M5/M6/M10/M14-core)

The retail server-directed-movement brain (0x00529010-0x0052a987),
Core-only with every App dependency as a ctor/property seam for the
V4/V5 cutovers: node-plan builders for all four movement types
(TurnToObject's desired-heading clobber quirk VERBATIM; TurnToHeading's
immediate BeginNextNode - ACE's one-tick-late gap not copied),
PerformMovement (cancel 0x36 + unstick first), BeginNextNode with the
sticky handoff order (radius/height/tlid read BEFORE CleanUp),
BeginMoveForward (GetCommand walk/run cascade + stored-params
write-back + progress-clock seed), HandleMoveToPosition (chase arrival
dist <= distance_to_object per the adjudicated BN inversion; fail
distance -> 0x3D; progress >= 0.25 units/s over >= 1 s, incremental AND
overall; fail_progress_count write-only - retail has NO give-up
threshold and none was invented), HandleTurnToHeading (20/340 aux
deadband; the Ghidra-confirmed heading_diff mirror), HandleUpdateTarget
0x0052a7d0 (deferred-start: object moves wait for the first Ok
callback; retargets reset the progress clock without requeueing),
UseTime's initialized gate, InitializeLocalVariables per retail (flags
word + context_id zeroed, floats stale, FLT_MAX resets - not ACE's
transpositions).

TDD catch: default(Quaternion) is the ZERO quaternion, not identity -
a fresh manager's heading computations would silently read 90 degrees;
explicit IdentityPosition resets match the decomp's identity-Frame
semantics. Also pinned: retail's explicit double adjust_motion in
_DoMotion/_StopMotion; entry points never drain pending_actions (only
PerformMovement's cancel does) - re-issues must route through
PerformMovement, documented + tested.

101 new conformance tests incl. three end-to-end scripted drives
(chase turn->run->walk-demote->arrive; flee; frozen-heading
TurnToObject through retarget). Full suite: 3,961 passed.

Implemented by a dedicated agent against the V0-pinned spec; scope +
suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 11:43:50 +02:00
parent e0d2492cbb
commit addc8e97a8
13 changed files with 3779 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R4-V2 — verbatim port of retail's <c>MoveToManager::MovementNode</c>
/// (<c>acclient.h:57702</c>, struct #6350):
/// <code>
/// struct __cppobj MoveToManager::MovementNode : DLListData
/// { // +0 dllist_next, +4 dllist_prev (DLListData)
/// MovementTypes::Type type; // +8 — only 7 (MoveToPosition) and 9 (TurnToHeading) ever queued
/// float heading; // +0xc — only meaningful for type 9
/// };
/// </code>
///
/// <para>
/// <b>NAME WATCH (r4-port-plan.md §3 "New code target"):</b> named
/// <c>MoveToNode</c>, NOT <c>MovementNode</c>, to avoid colliding with R2's
/// <see cref="MotionNode"/> (the UNRELATED <c>CMotionInterp::pending_motions</c>
/// node — a different queue on a different class; see the AD-34 register
/// wording on both node types' shared "managed collection standing in for
/// retail's intrusive DLList/LList" pattern).
/// </para>
///
/// <para>
/// Retail allocates these with <c>operator new(0x10)</c> and links them onto
/// <c>MoveToManager::pending_actions</c> (a <c>DLList</c> — doubly-linked,
/// unlike <c>CMotionInterp</c>'s singly-linked <c>LList</c>) via
/// <c>DLListBase::InsertAfter</c> (tail-append; r4-moveto-decomp.md §4a).
/// Ported as a managed <see cref="System.Collections.Generic.LinkedList{T}"/>
/// of this value type — same pattern as <see cref="MotionNode"/>'s port
/// (AD-34 wording): node identity semantics preserved via
/// <c>LinkedListNode&lt;MoveToNode&gt;</c> references rather than raw
/// prev/next pointers, FIFO order preserved via tail-append +
/// <c>RemoveFirst</c>.
/// </para>
/// </summary>
/// <param name="Type">Retail <c>type</c> (+8) — only
/// <see cref="MovementType.MoveToPosition"/> (7) and
/// <see cref="MovementType.TurnToHeading"/> (9) are ever queued
/// (r4-moveto-decomp.md §4a: <c>AddMoveToPositionNode</c> /
/// <c>AddTurnToHeadingNode</c> are the only two producers;
/// <see cref="MoveToManager.BeginNextNode"/>'s dispatch is a defensive
/// <c>if/if</c>, not a full switch — an unknown type stalls rather than
/// throwing, matching the raw's shape).</param>
/// <param name="Heading">Retail <c>heading</c> (+0xc) — only meaningful for
/// <see cref="MovementType.TurnToHeading"/> nodes; zero/unused for
/// <see cref="MovementType.MoveToPosition"/> nodes.</param>
public readonly record struct MoveToNode(MovementType Type, float Heading);

View file

@ -0,0 +1,295 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>HandleMoveToPosition</c> Phase 2 (<c>00529d80</c>, raw
/// 307283-307331) and <c>CheckProgressMade</c> (<c>005290f0</c>, raw
/// 306385-306431), per r4-moveto-decomp.md §6b/§5b: arrival predicate
/// (chase <c>dist &lt;= DistanceToObject</c> vs flee
/// <c>dist &gt;= MinDistance</c>), fail-distance (<see cref="WeenieError.YouChargedTooFar"/>
/// 0x3D), and the 1-second / 0.25-units-per-second progress window (BOTH
/// incremental AND overall rates).
/// </summary>
public sealed class MoveToManagerArrivalAndProgressTests
{
// ── CheckProgressMade — the progress-clock table (§5b) ─────────────────
[Fact]
public void CheckProgressMade_WithinOneSecondWindow_AlwaysTrue_TooSoonToJudge()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false });
h.Advance(0.5); // < 1.0s since PreviousDistanceTime
Assert.True(h.Manager.CheckProgressMade(currentDistance: 19.9f));
}
[Fact]
public void CheckProgressMade_ExactlyOneSecond_StillTooSoon_Inclusive()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false });
h.Advance(1.0); // elapsed <= 1.0 -> still true (§5b: "elapsed <= 1.0 -> return 1")
Assert.True(h.Manager.CheckProgressMade(currentDistance: 19.9f));
}
[Fact]
public void CheckProgressMade_AfterWindow_SufficientIncrementalAndOverallRate_True()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false });
// PreviousDistance/OriginalDistance seeded to 20 at t=0.
h.Advance(2.0); // 2s elapsed
// Closed 1 unit/s over 2s = 2 units closed -> rate 1.0 >= 0.25 both ways.
bool progress = h.Manager.CheckProgressMade(currentDistance: 18f);
Assert.True(progress);
Assert.Equal(0u, h.Manager.FailProgressCount);
Assert.Equal(18f, h.Manager.PreviousDistance, 2); // incremental checkpoint advanced
}
[Fact]
public void CheckProgressMade_InsufficientIncrementalRate_False_CheckpointDoesNotAdvance()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false });
h.Advance(2.0);
// Closed only 0.1 unit over 2s -> rate 0.05 < 0.25 -> no progress;
// checkpoint (PreviousDistance) must NOT advance.
bool progress = h.Manager.CheckProgressMade(currentDistance: 19.9f);
Assert.False(progress);
Assert.Equal(20f, h.Manager.PreviousDistance, 2); // unchanged
}
[Fact]
public void CheckProgressMade_GoodIncrementalButBadOverallRate_False()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false });
// OriginalDistance/OriginalDistanceTime were seeded to (20, t=0) by
// BeginMoveForward and NEVER move again except on arrival/retarget —
// only PreviousDistance (the incremental checkpoint) advances on a
// passing tick. Simulate a long slow crawl: many small incremental
// passes that each individually clear 0.25/s over their own 1s+
// window, but the OVERALL rate since t=0 stays under 0.25/s because
// the total elapsed time dominates.
h.Advance(2.0);
Assert.True(h.Manager.CheckProgressMade(19f)); // incremental 0.5/s since t=0 — overall also 0.5/s here, still passes.
// Now advance a huge amount of time with only a tiny further close —
// incremental since the LAST checkpoint (t=2, dist=19) is healthy
// relative to its own short window, but overall since t=0 (dist 20)
// over the huge elapsed time is far under 0.25/s.
h.Advance(200.0);
bool progress = h.Manager.CheckProgressMade(18.9f); // incremental: 0.1/200s -> fails incremental too in this construction; assert false either way (both gates must independently pass).
Assert.False(progress);
}
[Fact]
public void CheckProgressMade_MovingAway_UsesOpeningDistance()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
// pure-away move: MoveTowards=false, MoveAway=true, MinDistance large
// so get_command picks WalkForward+movingAway.
var p = new MovementParameters { MoveTowards = false, MoveAway = true, MinDistance = 50f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
Assert.True(h.Manager.MovingAway);
h.Advance(2.0);
// Distance to the (now-behind) target GREW from ~20 to 25 -> opening
// at 2.5/s -> progress (moving_away: progress = curr - previous).
bool progress = h.Manager.CheckProgressMade(25f);
Assert.True(progress);
}
// ── Arrival predicate (§6b Phase 2) — chase vs flee ────────────────────
[Fact]
public void HandleMoveToPosition_Chase_ArrivesWhenDistLessOrEqualDto()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 5f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
h.DrainPendingMotions();
// Walk the mover to within DistanceToObject and let enough time pass
// for CheckProgressMade to evaluate true.
h.WorldPosition = new Position(1u, new Vector3(16f, 0f, 0f), Quaternion.Identity); // dist=4 <= dto(5)
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
// Arrival -> node popped, CurrentCommand cleared, BeginNextNode ran
// (queue now empty, non-sticky) -> CleanUp + StopCompletely ->
// MovementTypeState back to Invalid.
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void HandleMoveToPosition_Chase_NotArrivedYet_StaysActive()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
h.DrainPendingMotions();
h.WorldPosition = new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity); // dist=15, still far
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
[Fact]
public void HandleMoveToPosition_Flee_ArrivesWhenDistGreaterOrEqualMinDistance()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { MoveTowards = false, MoveAway = true, MinDistance = 10f, UseSpheres = false };
// Start close (dist=5 < MinDistance=10) so get_command picks
// WalkForward+movingAway (pure-away band, §5c).
h.Manager.MoveToPosition(new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), p);
Assert.True(h.Manager.MovingAway);
h.DrainPendingMotions();
// Mover has fled to distance 12 (>= MinDistance 10) -> arrived.
h.WorldPosition = new Position(1u, new Vector3(-7f, 0f, 0f), Quaternion.Identity); // dist to (5,0,0) = 12
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
// ── Fail-distance (§6b, WeenieError.YouChargedTooFar) ──────────────────
[Fact]
public void HandleMoveToPosition_ProgressMadeButOverFailDistance_CancelsAsYouChargedTooFar()
{
// §6b Phase 2 ordering: the fail_distance check lives INSIDE the
// "CheckProgressMade == true, but not yet arrived" branch — a
// no-progress tick never reaches it at all (that tick only
// increments FailProgressCount). So the fail-distance trigger
// requires: progress WAS made (rate >= 0.25 both ways) toward the
// target, arrival not yet reached, AND total displacement from
// StartingPosition exceeds FailDistance — e.g. the mover overshot
// past the target along a path that looped far from the start.
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = 5f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
h.DrainPendingMotions();
// Mover advanced to (8,0,0): 12 units closed toward the target over
// 2s (rate 6/s, passes both incremental+overall) but has traveled
// 8 units from StartingPosition(0,0,0) — over FailDistance(5) —
// while still 12 units short of arrival (dto=0.6).
h.WorldPosition = new Position(1u, new Vector3(8f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void HandleMoveToPosition_NoProgressButWithinFailDistance_StaysActive_NoCancel()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = 100f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
h.DrainPendingMotions();
h.WorldPosition = new Position(1u, new Vector3(0f, 1f, 0f), Quaternion.Identity); // 1 unit from start, well under FailDistance
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
// ── FailProgressCount write-only bookkeeping (§8, do-not-invent) ───────
[Fact]
public void FailProgressCount_IncrementsOnStall_ButNoGiveUpThresholdExists()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = float.MaxValue, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
h.DrainPendingMotions();
// Stall many times (no progress, not interpolating, not animating) —
// FailProgressCount should climb with NO cap and NO resulting
// cancellation, since the counter is write-only in retail (§8).
for (int i = 0; i < 20; i++)
{
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
}
Assert.True(h.Manager.FailProgressCount >= 20);
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); // still active, no give-up
}
[Fact]
public void FailProgressCount_NotIncremented_WhenInterpolating()
{
var h = new MoveToManagerHarness { IsInterpolatingValue = true };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = float.MaxValue, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
h.DrainPendingMotions();
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.FailProgressCount);
}
}

View file

@ -0,0 +1,147 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>HandleMoveToPosition</c> Phase 1 aux-turn steering
/// (<c>00529d80</c>, raw 307187-307280) per r4-moveto-decomp.md §6b: the
/// 20°/340° deadband, direction pick (diff ≥ 180 → TurnLeft else TurnRight),
/// no-redundant-reissue, and the "stop aux while animating" branch.
/// </summary>
public sealed class MoveToManagerAuxTurnTests
{
/// <summary>Drives a manager into an active MoveToPosition (heading
/// already settled so BeginMoveForward runs on the first BeginNextNode),
/// then drains the interp's pending_motions queue via a synthetic
/// MotionDone callback — standing in for "the WalkForward/RunForward
/// animation-table dispatch cycle has started/completed" the way a real
/// AnimationSequencer would signal it. Without this, MotionsPending()
/// stays true forever (BeginMoveForward's own _DoMotion dispatch
/// enqueues a node that nothing else in this bare harness ever pops),
/// and HandleMoveToPosition's Phase 1 would perpetually take the
/// "animating, stop aux" branch — never exercising the deadband/turn
/// logic this test file targets.</summary>
private static MoveToManagerHarness ArmMoving(float initialHeading, Vector3 targetXY)
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = initialHeading;
var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, targetXY, Quaternion.Identity), p);
h.DrainPendingMotions();
return h;
}
[Fact]
public void WithinDeadband_NoAuxTurnIssued()
{
// Target due east (heading 90); mover already facing 85 -> diff 5,
// inside [0,20] deadband.
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 85f; // simulate drift after the initial turn-to-face completed
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.AuxCommand);
}
[Fact]
public void JustOutsideDeadband_Positive_IssuesTurnRight()
{
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 40f; // diff = 90-40 = 50 -> outside [0,20][340,360)
h.Manager.HandleMoveToPosition();
Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand);
}
[Fact]
public void DiffAtOrAbove180_IssuesTurnLeft()
{
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 300f; // diff = 90-300 = -210 -> +360 = 150... need >=180 for TurnLeft; pick 260.
h.Heading = 260f; // diff = 90-260=-170 -> +360=190 (>=180) -> TurnLeft
h.Manager.HandleMoveToPosition();
Assert.Equal(MotionCommand.TurnLeft, h.Manager.AuxCommand);
}
[Fact]
public void DeadbandUpperBoundary_340_NoTurn()
{
var h = ArmMoving(initialHeading: 0f, targetXY: new Vector3(0f, 20f, 0f)); // target heading 0
h.Heading = 20f; // diff = 0-20=-20 -> +360=340 -> boundary INCLUSIVE (diff >= 340)
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.AuxCommand);
}
[Fact]
public void DeadbandLowerBoundary_20_NoTurn()
{
var h = ArmMoving(initialHeading: 0f, targetXY: new Vector3(0f, 20f, 0f)); // target heading 0
h.Heading = -20f % 360f; // normalize below
h.Heading = 340f; // diff = 0-340 = -340 -> +360=20 -> boundary INCLUSIVE (diff <= 20)
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.AuxCommand);
}
[Fact]
public void NoRedundantReissue_SameDirectionTwice_DoesNotRedispatch()
{
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 40f; // outside deadband -> TurnRight
h.Manager.HandleMoveToPosition();
uint firstAux = h.Manager.AuxCommand;
Assert.Equal(MotionCommand.TurnRight, firstAux);
// Drain the interp's pending_motions queue (the TurnRight dispatch
// just enqueued a node) so Phase 1's "not animating" branch runs
// again on the second tick — otherwise MotionsPending() would stay
// true and Phase 1 would take the "animating, stop aux" branch
// instead of exercising the redundant-reissue guard this test
// targets.
h.DrainPendingMotions();
int stopCallsBefore = h.StopCompletelyCalls; // unrelated counter, just for isolation
// Second tick, still outside deadband, same direction -> _DoMotion
// should NOT be re-issued (turn != AuxCommand test fails since
// AuxCommand already equals TurnRight) — assert AuxCommand is
// unchanged (still TurnRight) as the observable proxy.
h.Manager.HandleMoveToPosition();
Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand);
Assert.Equal(stopCallsBefore, h.StopCompletelyCalls);
}
[Fact]
public void AnimatingMotionsPending_StopsAuxTurn_DoesNotStartNew()
{
var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f));
h.Heading = 40f;
h.Manager.HandleMoveToPosition();
Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand);
// Simulate an animation-table motion still pending by queueing a
// node onto the REAL MotionInterpreter's pending_motions.
h.Interp.AddToQueue(0, MotionCommand.WalkForward, 0);
Assert.True(h.Interp.MotionsPending());
h.Manager.HandleMoveToPosition();
Assert.Equal(0u, h.Manager.AuxCommand);
}
}

View file

@ -0,0 +1,157 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>MoveToManager::BeginMoveForward</c> (<c>00529a00</c>, raw
/// 306957-307042) per r4-moveto-decomp.md §4c: dispatched motion id / hold
/// key, the write-back to the STORED params, and the progress-clock seed.
/// Also exercises the run→walk demote inside <c>WalkRunThreshhold</c> (the
/// R3 visual-pass expected-diff this closes).
/// </summary>
public sealed class MoveToManagerBeginMoveForwardTests
{
[Fact]
public void FarFromTarget_CanRunCanWalk_DispatchesRunForward()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f; // already facing target — no aux turn needed
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f };
// Distance far beyond threshold+dto -> Run.
h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p);
// MoveToPosition's node plan queues [TurnToHeading(face)] first since
// heading(0->target)=90 != current heading is not tested here (we
// set Heading=90 already so diff=0, GetCommand still picks motion
// because distance is huge, so a turn node is queued anyway — but
// since diff==0 the queued turn will complete immediately in
// BeginNextNode's synchronous dispatch, landing directly on
// BeginMoveForward).
// ForwardCommand (post-adjust_motion, dispatched to the interp) is
// RunForward; CurrentCommand (the manager's OWN field) stores the
// PRE-adjust command GetCommand chose — get_command's own body only
// ever returns WalkForward/WalkBackward/0 (§5c) — the Run promotion
// happens downstream, inside adjust_motion (_DoMotion §7a), and is
// never written back into CurrentCommand.
Assert.Equal(MotionCommand.RunForward, h.ForwardCommand);
Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply);
Assert.Equal(MotionCommand.WalkForward, h.Manager.CurrentCommand);
}
[Fact]
public void WithinWalkRunThreshold_DemotesToWalk()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f };
// dist - dto = 10 - 0.6 = 9.4 <= 15 -> walk.
h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), p);
Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand);
Assert.Equal(HoldKey.None, h.Manager.Params.HoldKeyToApply);
}
[Fact]
public void CanCharge_FastPathWins_RunsEvenWhenClose()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, CanCharge = true };
h.Manager.MoveToPosition(new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity), p);
Assert.Equal(MotionCommand.RunForward, h.ForwardCommand);
Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply);
}
[Fact]
public void HoldKeyWriteBack_ToStoredParams_NotJustLocalCopy()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, HoldKeyToApply = HoldKey.Invalid };
h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p);
Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply);
}
[Fact]
public void ProgressClockSeeded_PreviousAndOriginalEqualCurrentDistance()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.CurTime = 5.0;
// UseSpheres defaults true on a fresh MovementParameters, and
// MoveToPosition's own params (copied into Params BEFORE
// BeginMoveForward runs) drive GetCurrentDistance's use_spheres
// branch: cylinder distance = center distance - ownRadius(0.5) -
// targetRadius(0, position moves always zero SoughtObjectRadius).
var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
Assert.Equal(20f, h.Manager.PreviousDistance, 2);
Assert.Equal(20f, h.Manager.OriginalDistance, 2);
Assert.Equal(5.0, h.Manager.PreviousDistanceTime, 3);
Assert.Equal(5.0, h.Manager.OriginalDistanceTime, 3);
}
[Fact]
public void ProgressClockSeeded_UseSpheresDefault_UsesCylinderDistance()
{
var h = new MoveToManagerHarness { OwnRadius = 0.5f };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.CurTime = 5.0;
var p = new MovementParameters { DistanceToObject = 0.6f }; // UseSpheres=true (default)
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p);
// center distance 20 - ownRadius 0.5 - targetRadius 0 (position
// moves zero SoughtObjectRadius, §3c) = 19.5.
Assert.Equal(19.5f, h.Manager.PreviousDistance, 2);
Assert.Equal(19.5f, h.Manager.OriginalDistance, 2);
}
[Fact]
public void CancelMoveToBit_ClearedOnLocalParams_DoesNotSelfCancel()
{
// If the 0x8000 CancelMoveTo bit were NOT cleared on the local
// params passed into _DoMotion, InterruptCurrentMovement-style
// cancellation logic downstream could tear down THIS moveto before
// it starts. We assert the observable effect: the manager is still
// MovingTo after BeginMoveForward dispatches.
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
Assert.True(h.Manager.IsMovingTo());
}
[Fact]
public void NoPhysicsObj_CancelsWithNoPhysicsObjectCode()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
Assert.True(h.Manager.IsMovingTo());
h.Manager.HasPhysicsObj = false;
h.Manager.BeginMoveForward();
Assert.False(h.Manager.IsMovingTo());
}
}

View file

@ -0,0 +1,154 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — scripted end-to-end table drive (r4-port-plan.md §3 V2 test
/// list): positions fed per tick -> expected node pops + dispatched motion
/// ids/hold keys, including the run-to-walk demote as the mover closes to
/// within <c>WalkRunThreshhold</c> of the target — the exact R3 visual-pass
/// expected-diff this closes ("auto-walk-at-run walk-pace legs (R4)").
/// </summary>
public sealed class MoveToManagerEndToEndTableDriveTests
{
[Fact]
public void ChaseSequence_TurnFirst_ThenRun_ThenDemoteToWalk_ThenArrive()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f; // facing NORTH; target is due EAST -> a turn-to-face node is required first.
var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p);
// Step 1: node plan = [TurnToHeading(90), MoveToPosition]. Heading
// diff (0->90) is large -> BeginTurnToHeading armed a real turn
// (not the "already there" early-out).
Assert.Equal(2, System.Linq.Enumerable.Count(h.Manager.PendingActions));
Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand);
h.DrainPendingMotions();
// Step 2: the turn completes (heading snaps to 90 via
// HandleTurnToHeading's arrival branch) -> BeginNextNode pops to the
// MoveToPosition node -> BeginMoveForward dispatches. Far from the
// target (100 units, minus threshold 15 well exceeded) -> RunForward.
h.Heading = 91f; // "passed" 90
h.Manager.HandleTurnToHeading();
h.DrainPendingMotions();
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
Assert.Single(h.Manager.PendingActions);
Assert.Equal(MotionCommand.RunForward, h.ForwardCommand);
Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply);
// Step 3: close the distance to just inside the walk/run threshold.
// Phase 2 of HandleMoveToPosition doesn't re-run get_command; only
// BeginMoveForward does (dispatched once per node, on arm) — so the
// walk-demote re-evaluation requires a fresh moveto issue. Route it
// through PerformMovement (NOT a direct MoveToPosition call) — this
// is the retail-faithful re-issue shape (§3a: cancel current +
// unstick FIRST, THEN dispatch) and avoids stacking a stale node
// onto the still-populated queue the way a bare second
// MoveToPosition call would (MoveToPosition itself does not drain;
// only PerformMovement's CancelMoveTo call does).
h.WorldPosition = new Position(1u, new Vector3(90f, 0f, 0f), Quaternion.Identity); // 10 units from target
h.Heading = 90f; // already facing it
var pClose = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, UseSpheres = false };
h.Manager.PerformMovement(new MovementStruct
{
Type = MovementType.MoveToPosition,
Pos = new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity),
Params = pClose,
});
// PerformMovement's CancelMoveTo (§3a) stops the in-flight motion
// FIRST, which itself enqueues a pending_motions node -- so
// BeginTurnToHeading's wait-for-anims gate (§4d) defers the "already
// facing it" early-out to the NEXT drain, not synchronously inside
// this call. Drain twice: once for the cancel's own stop dispatch,
// once more for whatever BeginTurnToHeading/BeginMoveForward issues
// once armed.
h.DrainPendingMotions();
h.Manager.BeginNextNode(); // re-check the head node now that anims have drained
h.DrainPendingMotions();
Assert.Single(h.Manager.PendingActions); // the stale queue was drained by PerformMovement's CancelMoveTo; the "already facing it" turn completed instantly once anims cleared.
// dist=10, dto=0.6 -> dist-dto=9.4 <= 15 -> WALK.
Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand);
Assert.Equal(HoldKey.None, h.Manager.Params.HoldKeyToApply);
// Step 4: arrive.
h.WorldPosition = new Position(1u, new Vector3(99.7f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Empty(h.Manager.PendingActions);
}
[Fact]
public void FleeSequence_WalksBackward_InsideMinBand_ArrivesWhenFarEnough()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, new Vector3(3f, 0f, 0f), Quaternion.Identity);
h.Heading = 270f; // facing the threat (target) which is behind at origin -- WalkBackward needs no turn.
// towards_and_away band: dist(3) inside [MinDistance(5)... wait need
// dist < min for the inside-band WalkBackward pick]. Use MinDistance
// 5 with mover at distance 3 from the target (origin) -> inside
// band -> WalkBackward, no turn node queued (§5d asymmetry).
var p = new MovementParameters { MoveTowards = true, MoveAway = true, MinDistance = 5f, DistanceToObject = 8f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, Vector3.Zero, Quaternion.Identity), p);
Assert.True(h.Manager.MovingAway);
Assert.Equal(MotionCommand.WalkBackward, h.Manager.CurrentCommand); // pre-adjust id (get_command's own return)
// adjust_motion normalizes WalkBackward -> WalkForward with a
// negative BackwardsFactor-scaled speed, dispatched as ForwardCommand.
Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand);
Assert.True(h.ForwardSpeed < 0f);
h.DrainPendingMotions();
// Flee to distance 6 (>= MinDistance 5) -> arrived.
h.WorldPosition = new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
h.Manager.HandleMoveToPosition();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void TurnToObjectSequence_DeferredStart_ThenRetargetIgnored_ThenArrivesOnHeadingPass()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
const uint targetId = 0x5000CCCCu;
h.Manager.TurnToObject(targetId, targetId, new MovementParameters());
Assert.Empty(h.Manager.PendingActions); // deferred (§3d)
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90
h.Manager.HandleUpdateTarget(new TargetInfo(targetId, TargetStatus.Ok, target, target));
Assert.True(h.Manager.Initialized);
Assert.Single(h.Manager.PendingActions);
Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand);
h.DrainPendingMotions();
// Retarget while running: TurnToObject gets no handling (heading frozen).
var target2 = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0
h.Manager.HandleUpdateTarget(new TargetInfo(targetId, TargetStatus.Ok, target2, target2));
var soughtBefore = h.Manager.SoughtPosition;
Assert.Equal(soughtBefore, h.Manager.SoughtPosition); // sanity: unchanged by its own read
// Complete the turn toward the ORIGINAL (frozen) heading (90), not target2's.
h.Heading = 91f;
h.Manager.HandleTurnToHeading();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Equal(90f, h.Heading, 2); // snapped to the frozen heading, unaffected by the retarget.
}
}

View file

@ -0,0 +1,158 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>HandleUpdateTarget</c> (<c>0052a7d0</c>, raw 307802-307867,
/// decomp §6d): the P4 TargetTracker feed's deferred-start lifecycle
/// (Initialized=false = the first callback vs true = an in-flight retarget),
/// context/target-id filtering, self-target instant success, NoObject vs
/// ObjectGone status handling, and the retarget progress-clock reset.
/// </summary>
public sealed class MoveToManagerHandleUpdateTargetTests
{
private const uint TargetId = 0x50004444u;
private static MoveToManagerHarness ArmMoveToObject(float ownRadius = 0.5f, float ownHeight = 2f)
{
var h = new MoveToManagerHarness { OwnRadius = ownRadius, OwnHeight = ownHeight };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
h.Manager.MoveToObject(TargetId, TargetId, radius: 1f, height: 2f, new MovementParameters());
return h;
}
[Fact]
public void IgnoresUpdate_ForADifferentTarget()
{
var h = ArmMoveToObject();
var wrongTargetPos = new Position(1u, new Vector3(5f, 5f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x59999999u, TargetStatus.Ok, wrongTargetPos, wrongTargetPos));
Assert.False(h.Manager.Initialized);
Assert.Empty(h.Manager.PendingActions);
}
[Fact]
public void FirstCallback_NonOkStatus_CancelsAsNoObject()
{
var h = ArmMoveToObject();
var pos = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.ExitWorld, pos, pos));
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void FirstCallback_OkStatus_BuildsNodePlan_SetsInitialized()
{
var h = ArmMoveToObject();
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target));
Assert.True(h.Manager.Initialized);
Assert.NotEmpty(h.Manager.PendingActions);
}
[Fact]
public void FirstCallback_OrdinaryTarget_DoesNotFireMoveToComplete()
{
// MoveToObject's OWN self-target branch (§3b) already short-circuits
// via CleanUp+StopCompletely BEFORE any HandleUpdateTarget ever
// fires for a same-id target — so HandleUpdateTarget's self-target
// instant-success path (§6d: "top_level_object_id ==
// physics_obj->id") is reachable only in the deferred-start window,
// and is covered by construction in
// MoveToManagerNodePlanTests.MoveToObject_SelfTarget_*
// (MoveToObject never even reaches SetTarget for a self-id, so the
// callback path is dead in practice — retail's redundant guard).
// This test isolates the ORDINARY path: MoveToComplete's only
// trigger is CleanUpAndCallWeenie, never a plain node-plan build.
var h = ArmMoveToObject();
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target));
Assert.Empty(h.MoveToCompleteCalls);
}
[Fact]
public void Retarget_NonOkStatus_CancelsAsObjectGone()
{
var h = ArmMoveToObject();
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target));
Assert.True(h.Manager.Initialized);
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.ExitWorld, target, target));
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void Retarget_UpdatesPositions_ResetsProgressClock_DoesNotRequeueNodes()
{
var h = ArmMoveToObject();
var target1 = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target1, target1));
int nodeCountAfterFirst = System.Linq.Enumerable.Count(h.Manager.PendingActions);
Assert.True(nodeCountAfterFirst > 0);
h.Advance(3.0); // simulate time passing, progress clock advanced by BeginMoveForward
var target2 = new Position(1u, new Vector3(20f, 5f, 0f), Quaternion.Identity);
var interp2 = new Position(1u, new Vector3(19f, 5f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target2, interp2));
Assert.Equal(target2, h.Manager.CurrentTargetPosition);
Assert.Equal(interp2, h.Manager.SoughtPosition);
Assert.Equal(float.MaxValue, h.Manager.PreviousDistance);
Assert.Equal(float.MaxValue, h.Manager.OriginalDistance);
// Node count unchanged by the retarget itself (no requeue) — the
// running MoveToPosition node keeps steering toward the moved
// CurrentTargetPosition on its own next tick.
Assert.Equal(nodeCountAfterFirst, System.Linq.Enumerable.Count(h.Manager.PendingActions));
}
[Fact]
public void Retarget_TurnToObject_GetsNoRetargetHandling_HeadingFrozen()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
h.Manager.TurnToObject(TargetId, TargetId, new MovementParameters());
var target1 = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90
h.Manager.TurnToObject_Internal(target1); // first callback (direct, mirrors deferred-start call shape)
Assert.True(h.Manager.Initialized);
var soughtAfterFirst = h.Manager.SoughtPosition;
// A retarget-shaped HandleUpdateTarget call for a TurnToObject
// manager: since Initialized is already true, this takes the
// "retarget" branch, which only updates state for MoveToObject —
// TurnToObject gets NO handling at all (decomp §6d note).
var target2 = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target2, target2));
Assert.Equal(soughtAfterFirst, h.Manager.SoughtPosition); // untouched
}
[Fact]
public void NoPhysicsObj_CancelsWithNoPhysicsObjectCode()
{
var h = ArmMoveToObject();
h.Manager.HasPhysicsObj = false;
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target));
Assert.False(h.Manager.IsMovingTo());
}
}

View file

@ -0,0 +1,100 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>MoveToManager</c> construction / <c>InitializeLocalVariables</c>
/// (<c>00529250</c>, raw 306490-306534) / <c>Destroy</c> (<c>005294b0</c>) /
/// <c>is_moving_to</c> (<c>00529220</c>). Per r4-moveto-decomp.md §1: the ctor
/// zeroes the FLAGS WORD + context_id only (NOT ACE's A2/A3 full-struct-reset
/// transposition — scalar param fields keep stale values since every entry
/// point copies all ten fields anyway), resets both progress-clock pairs to
/// FLT_MAX/now, and resets Sought/CurrentTarget positions but NOT
/// StartingPosition.
/// </summary>
public sealed class MoveToManagerLifecycleTests
{
[Fact]
public void FreshManager_MovementTypeIsInvalid()
{
var h = new MoveToManagerHarness();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.False(h.Manager.IsMovingTo());
}
[Fact]
public void FreshManager_ProgressClocksAreFltMax()
{
var h = new MoveToManagerHarness();
Assert.Equal(float.MaxValue, h.Manager.PreviousDistance);
Assert.Equal(float.MaxValue, h.Manager.OriginalDistance);
}
[Fact]
public void FreshManager_BitfieldFlagsAllClear_ScalarsUntouchedByCtorReset()
{
// InitializeLocalVariables clears ONLY the bitfield + context_id.
// The scalar fields (DistanceToObject etc.) are NOT part of that
// clear — but since Params starts as `new MovementParameters()`
// (retail ctor defaults), the scalars already hold their defaults
// here; the "stale values survive InitializeLocalVariables" claim is
// exercised by MoveToManagerCancelAndCleanupTests (a scalar surviving
// a CleanUp/InitializeLocalVariables round-trip after being changed
// by an entry point).
var h = new MoveToManagerHarness();
Assert.False(h.Manager.Params.CanWalk);
Assert.False(h.Manager.Params.CanRun);
Assert.False(h.Manager.Params.MoveTowards);
Assert.False(h.Manager.Params.CancelMoveTo);
Assert.Equal(0u, h.Manager.Params.ContextId);
}
[Fact]
public void FreshManager_SoughtPositionAndCurrentTargetAreIdentityFrameAtCellZero()
{
// NOT default(Position) — default(Quaternion) is the ZERO
// quaternion, not identity. Retail resets to a genuine identity
// frame (decomp §1c) at cell id 0.
var h = new MoveToManagerHarness();
var expected = new Position(0u, System.Numerics.Vector3.Zero, System.Numerics.Quaternion.Identity);
Assert.Equal(expected, h.Manager.SoughtPosition);
Assert.Equal(expected, h.Manager.CurrentTargetPosition);
Assert.Equal(0f, MoveToMath.GetHeading(h.Manager.SoughtPosition.Frame.Orientation));
}
[Fact]
public void FreshManager_PendingActionsEmpty()
{
var h = new MoveToManagerHarness();
Assert.Empty(h.Manager.PendingActions);
}
[Fact]
public void Destroy_DrainsPendingActions_ThenReInitializes()
{
var h = new MoveToManagerHarness();
h.Manager.AddMoveToPositionNode();
h.Manager.AddTurnToHeadingNode(90f);
Assert.Equal(2, System.Linq.Enumerable.Count(h.Manager.PendingActions));
h.Manager.Destroy();
Assert.Empty(h.Manager.PendingActions);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void IsMovingTo_TrueAfterMoveToPosition_FalseAfterCancel()
{
var h = new MoveToManagerHarness();
h.Manager.MoveToPosition(new Position(1u, new System.Numerics.Vector3(10f, 0f, 0f), System.Numerics.Quaternion.Identity), new MovementParameters());
Assert.True(h.Manager.IsMovingTo());
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.False(h.Manager.IsMovingTo());
}
}

View file

@ -0,0 +1,306 @@
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — node-plan goldens for each movement type. Per r4-moveto-decomp.md
/// §3c (MoveToPosition), §6f (MoveToObject_Internal), §3e (TurnToHeading),
/// §6g (TurnToObject_Internal): the SHAPE of <c>pending_actions</c> right
/// after the entry point (deferred moves) or the internal builder (object
/// moves, first target callback) runs.
/// </summary>
public sealed class MoveToManagerNodePlanTests
{
// ── MoveToPosition (§3c): [TurnToHeading(face)] → [MoveToPosition] ────
[Fact]
public void MoveToPosition_NeedsMotion_QueuesTurnThenMove()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
// Target due east (+X) -> heading 90. Far enough that get_command
// says motion is needed (default DistanceToObject=0.6).
h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), new MovementParameters());
var nodes = h.Manager.PendingActions.ToList();
Assert.Equal(2, nodes.Count);
Assert.Equal(MovementType.TurnToHeading, nodes[0].Type);
Assert.Equal(90f, nodes[0].Heading, 2);
Assert.Equal(MovementType.MoveToPosition, nodes[1].Type);
}
[Fact]
public void MoveToPosition_AlreadyInRange_NoMotionNodesQueued()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
// Target within default DistanceToObject (0.6) -> get_command idles.
h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), new MovementParameters());
Assert.Empty(h.Manager.PendingActions);
}
[Fact]
public void MoveToPosition_UseFinalHeading_AppendsAbsoluteFinalTurnNode()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 270f };
h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), p);
var nodes = h.Manager.PendingActions.ToList();
// Turn-to-face(90) -> MoveToPosition -> Turn-to-final(270, ABSOLUTE).
Assert.Equal(3, nodes.Count);
Assert.Equal(MovementType.TurnToHeading, nodes[2].Type);
Assert.Equal(270f, nodes[2].Heading, 2);
}
[Fact]
public void MoveToPosition_UseFinalHeadingOnly_NoMotionNeeded_QueuesOnlyFinalTurn()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 45f };
// Already in range -> no move/turn-to-face nodes, only the final turn.
h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p);
var nodes = h.Manager.PendingActions.ToList();
Assert.Single(nodes);
Assert.Equal(MovementType.TurnToHeading, nodes[0].Type);
Assert.Equal(45f, nodes[0].Heading, 2);
}
[Fact]
public void MoveToPosition_ClearsStickyBit_EvenIfArgumentRequestedIt()
{
var h = new MoveToManagerHarness();
var p = new MovementParameters { Sticky = true };
h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p);
Assert.False(h.Manager.Params.Sticky);
}
[Fact]
public void MoveToPosition_MovementTypeAndStartingPositionSet()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(2u, new Vector3(1f, 2f, 3f), Quaternion.Identity);
// Distance 10 (> default DistanceToObject 0.6) so the move plan
// actually queues motion and MovementTypeState stays MoveToPosition
// instead of completing instantly via the empty-queue BeginNextNode
// path (see MoveToPosition_AlreadyInRange_NoMotionNodesQueued for
// that degenerate case).
h.Manager.MoveToPosition(new Position(2u, new Vector3(1f, 12f, 3f), Quaternion.Identity), new MovementParameters());
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
Assert.Equal(h.WorldPosition, h.Manager.StartingPosition);
}
// ── TurnToHeading (§3e): ONE node, immediate BeginNextNode ─────────────
[Fact]
public void TurnToHeading_QueuesExactlyOneNode_WithDesiredHeading()
{
var h = new MoveToManagerHarness();
h.Heading = 0f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 123f });
var nodes = h.Manager.PendingActions.ToList();
// BeginNextNode ran immediately and BeginTurnToHeading may have
// already popped the node if heading matched (it won't here — 123
// != 0), so the node should still be present as "in flight" (its
// dispatch doesn't remove it — only arrival does). We assert via
// CurrentCommand instead of raw queue count, since BeginNextNode
// does run synchronously.
Assert.Equal(MovementType.TurnToHeading, h.Manager.MovementTypeState);
Assert.NotEqual(0u, h.Manager.CurrentCommand);
Assert.Single(nodes);
Assert.Equal(123f, nodes[0].Heading, 2);
}
[Fact]
public void TurnToHeading_ClearsStickyBit()
{
var h = new MoveToManagerHarness();
h.Manager.TurnToHeading(new MovementParameters { Sticky = true, DesiredHeading = 45f });
Assert.False(h.Manager.Params.Sticky);
}
[Fact]
public void TurnToHeading_AlreadyFacingTarget_BeginNextNodeCompletesImmediately()
{
var h = new MoveToManagerHarness();
h.Heading = 90f;
// DesiredHeading == current heading -> BeginTurnToHeading's
// "already there" branch pops + BeginNextNode -> empty queue,
// non-sticky -> CleanUp + StopCompletely -> back to Invalid.
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f });
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Empty(h.Manager.PendingActions);
Assert.True(h.StopCompletelyCalls >= 1);
}
// ── MoveToObject deferred start (§3b + §6f via HandleUpdateTarget) ─────
[Fact]
public void MoveToObject_NoNodesQueuedUntilTargetCallback()
{
var h = new MoveToManagerHarness();
h.Manager.MoveToObject(objectId: 0x50002222u, topLevelId: 0x50002222u, radius: 1f, height: 2f, new MovementParameters());
Assert.Empty(h.Manager.PendingActions);
Assert.Equal(MovementType.MoveToObject, h.Manager.MovementTypeState);
Assert.False(h.Manager.Initialized);
Assert.Single(h.SetTargetCalls);
Assert.Equal((0u, 0x50002222u, 0.5f, 0.0), h.SetTargetCalls[0]);
}
[Fact]
public void MoveToObject_PreservesStickyBit_UnlikePositionMoves()
{
var h = new MoveToManagerHarness();
var p = new MovementParameters { Sticky = true };
h.Manager.MoveToObject(0x50002222u, 0x50002222u, 1f, 2f, p);
Assert.True(h.Manager.Params.Sticky);
}
[Fact]
public void MoveToObject_FirstTargetCallback_BuildsNodePlanViaInternal()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
h.Manager.MoveToObject(0x50002222u, 0x50002222u, radius: 0.5f, height: 2f, new MovementParameters());
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x50002222u, TargetStatus.Ok, target, target));
Assert.True(h.Manager.Initialized);
var nodes = h.Manager.PendingActions.ToList();
Assert.Equal(2, nodes.Count);
Assert.Equal(MovementType.TurnToHeading, nodes[0].Type);
Assert.Equal(90f, nodes[0].Heading, 2);
Assert.Equal(MovementType.MoveToPosition, nodes[1].Type);
}
[Fact]
public void MoveToObject_UseFinalHeading_RelativeToInterpolatedHeading()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 10f };
h.Manager.MoveToObject(0x50002222u, 0x50002222u, 0.5f, 2f, p);
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90
h.Manager.HandleUpdateTarget(new TargetInfo(0x50002222u, TargetStatus.Ok, target, target));
var nodes = h.Manager.PendingActions.ToList();
Assert.Equal(3, nodes.Count);
// RELATIVE: iHeading(90) + desired(10) = 100 -- unlike MoveToPosition's absolute form.
Assert.Equal(100f, nodes[2].Heading, 2);
}
[Fact]
public void MoveToObject_SelfTarget_CleansUpImmediately_NoSetTarget()
{
var h = new MoveToManagerHarness();
h.Manager.MoveToObject(h.SelfId, h.SelfId, 1f, 2f, new MovementParameters());
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Empty(h.SetTargetCalls);
}
// ── TurnToObject deferred start (§3d + §6g) ────────────────────────────
[Fact]
public void TurnToObject_NoNodesQueuedUntilTargetCallback()
{
var h = new MoveToManagerHarness();
h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters());
Assert.Empty(h.Manager.PendingActions);
Assert.False(h.Manager.Initialized);
Assert.Single(h.SetTargetCalls);
}
[Fact]
public void TurnToObject_FirstCallback_QueuesExactlyOneTurnNode_FacingObject()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f; // already facing north — target due EAST forces an actual turn to queue.
// DesiredHeading is clobbered (§3d quirk) — it should NOT appear in
// the final node heading; the final heading is purely "face the
// object" (soughtHeading is 0 for a fresh manager).
h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { DesiredHeading = 200f });
var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 (east)
h.Manager.TurnToObject_Internal(target);
var nodes = h.Manager.PendingActions.ToList();
Assert.Single(nodes);
Assert.Equal(MovementType.TurnToHeading, nodes[0].Type);
Assert.Equal(90f, nodes[0].Heading, 2); // face-the-object (east), NOT 200
Assert.True(h.Manager.Initialized);
}
[Fact]
public void TurnToObject_FirstCallback_AlreadyFacingObject_CompletesImmediately()
{
// When soughtHeading(0) + targetHeading already equals the current
// heading, BeginTurnToHeading's "already there" branch consumes the
// node on the SAME call — the queue is empty by the time
// TurnToObject_Internal returns, but the manager still passed
// through Initialized=true and the CleanUp/StopCompletely tail.
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters());
var target = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0 (north) — already facing it.
h.Manager.TurnToObject_Internal(target);
Assert.Empty(h.Manager.PendingActions);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void TurnToObject_SelfTarget_CleansUpImmediately()
{
var h = new MoveToManagerHarness();
h.Manager.TurnToObject(h.SelfId, h.SelfId, new MovementParameters());
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Empty(h.SetTargetCalls);
}
[Fact]
public void TurnToObject_StopCompletelyOnlyWhenBitSet()
{
var h1 = new MoveToManagerHarness();
h1.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { StopCompletelyFlag = false });
Assert.Equal(0, h1.StopCompletelyCalls);
var h2 = new MoveToManagerHarness();
h2.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { StopCompletelyFlag = true });
Assert.Equal(1, h2.StopCompletelyCalls);
}
}

View file

@ -0,0 +1,295 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>BeginNextNode</c>'s sticky handoff (<c>00529cb0</c>, raw
/// 307123-307171, decomp §4b) and <c>CancelMoveTo</c>
/// (<c>00529930</c>, raw 306886-306940, decomp §7c) including the
/// reentrancy invariant (r4-port-plan.md §4: CancelMoveTo →
/// CleanUpAndCallWeenie → StopCompletely → InterruptCurrentMovement →
/// CancelMoveTo no-ops on Invalid).
/// </summary>
public sealed class MoveToManagerStickyAndCancelTests
{
// ── Sticky handoff (§4b) — read BEFORE CleanUp, then StickTo ───────────
[Fact]
public void StickyArrival_ReadsRadiusHeightTlidBeforeCleanUp_ThenCallsStickTo()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
var p = new MovementParameters { Sticky = true };
h.Manager.MoveToObject(0x50005555u, 0x50005555u, radius: 1.5f, height: 2.5f, p);
Assert.True(h.Manager.Params.Sticky); // preserved by MoveToObject (§3b)
var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity); // inside default dto -> arrives instantly
h.Manager.HandleUpdateTarget(new TargetInfo(0x50005555u, TargetStatus.Ok, target, target));
Assert.Single(h.StickToCalls);
Assert.Equal((0x50005555u, 1.5f, 2.5f), h.StickToCalls[0]);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // CleanUp ran
}
[Fact]
public void NonStickyArrival_NoStickToCall_JustCleanUpAndStop()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
var p = new MovementParameters { Sticky = false };
h.Manager.MoveToObject(0x50005555u, 0x50005555u, radius: 1.5f, height: 2.5f, p);
var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x50005555u, TargetStatus.Ok, target, target));
Assert.Empty(h.StickToCalls);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void MoveToPosition_NeverSticks_EvenIfRequested()
{
// §3c: MoveToPosition force-clears the sticky bit — so even an
// arrival that WOULD have stuck (had it been an object move) just
// completes plainly.
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 0f;
var p = new MovementParameters { Sticky = true, DistanceToObject = 0.6f, UseSpheres = false };
h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p); // already in range -> instant complete
Assert.Empty(h.StickToCalls);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void StickyHandoff_UsesSoughtRadiusHeight_NotOwnRadiusHeight()
{
var h = new MoveToManagerHarness { OwnRadius = 99f, OwnHeight = 99f };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
var p = new MovementParameters { Sticky = true };
h.Manager.MoveToObject(0x50006666u, 0x50006666u, radius: 3f, height: 4f, p);
var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x50006666u, TargetStatus.Ok, target, target));
Assert.Equal((0x50006666u, 3f, 4f), h.StickToCalls[0]); // the TARGET's radius/height, not the mover's own.
}
// ── CancelMoveTo (§7c) — drain + CleanUp + Stop; reentrancy ────────────
[Fact]
public void CancelMoveTo_OnInvalidState_IsANoOp()
{
var h = new MoveToManagerHarness();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(0, h.StopCompletelyCalls); // no-op: never even called StopCompletely
Assert.Equal(0, h.ClearTargetCalls);
}
[Fact]
public void CancelMoveTo_DrainsPendingActions()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
Assert.NotEmpty(h.Manager.PendingActions);
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Empty(h.Manager.PendingActions);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void CancelMoveTo_ErrorArgument_NeverReadInBody_SameBehaviorForAnyCode()
{
// §7c: the WeenieError arg is NEVER read — every code produces
// identical observable behavior (drain + CleanUp + Stop).
var h1 = new MoveToManagerHarness();
h1.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h1.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
h1.Manager.CancelMoveTo(WeenieError.YouChargedTooFar);
var h2 = new MoveToManagerHarness();
h2.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h2.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
h2.Manager.CancelMoveTo(WeenieError.NoObject);
Assert.Equal(h1.Manager.MovementTypeState, h2.Manager.MovementTypeState);
Assert.Equal(h1.StopCompletelyCalls, h2.StopCompletelyCalls);
}
[Fact]
public void CancelMoveTo_ClearsTarget_ForObjectMoves()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToObject(0x50007777u, 0x50007777u, 1f, 2f, new MovementParameters());
Assert.Single(h.SetTargetCalls);
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(1, h.ClearTargetCalls);
}
[Fact]
public void CancelMoveTo_DoesNotClearTarget_ForPositionMoves()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
Assert.Equal(0, h.ClearTargetCalls); // TopLevelObjectId is 0 for a position move -> the clear_target gate never fires.
}
[Fact]
public void Reentrancy_StopCompletelyCallback_ReenteringCancelMoveTo_NoOps()
{
// r4-port-plan.md §4 reentrancy invariant: the StopCompletely tail
// of CancelMoveTo/CleanUpAndCallWeenie can re-enter
// InterruptCurrentMovement -> CancelMoveTo (this is exactly what
// happens in production: MotionInterpreter.StopCompletely()
// invokes InterruptCurrentMovement, which V5 binds to
// entity.MoveTo.CancelMoveTo). This must no-op because CleanUp
// already reset movement_type to Invalid BEFORE StopCompletely
// runs. Wire the reentrant callback DIRECTLY (bypassing the shared
// harness, which doesn't expose this seam post-construction) to
// prove the invariant against the real CancelMoveTo/CleanUp
// ordering.
var interp = new MotionInterpreter();
var body = new PhysicsBody { TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active };
interp.PhysicsObj = body;
Position worldPosition = new(1u, Vector3.Zero, Quaternion.Identity);
int stopCompletelyCalls = 0;
int reentrantCancelCalls = 0;
MoveToManager? mgr = null;
mgr = new MoveToManager(
interp,
stopCompletely: () =>
{
stopCompletely_body();
},
getPosition: () => worldPosition,
getHeading: () => 0f,
setHeading: (h, send) => { },
getOwnRadius: () => 0.5f,
getOwnHeight: () => 2f,
contact: () => true,
isInterpolating: () => false,
getVelocity: () => Vector3.Zero,
getSelfId: () => 0x50000001u,
setTarget: (a, b, c, d) => { },
clearTarget: () => { },
getTargetQuantum: () => 0.0,
setTargetQuantum: q => { });
void stopCompletely_body()
{
stopCompletelyCalls++;
// Re-enter: exactly the retail chain
// interp.StopCompletely() -> InterruptCurrentMovement?.Invoke()
// -> entity.MoveTo.CancelMoveTo(ActionCancelled) (V5 binding).
reentrantCancelCalls++;
mgr!.CancelMoveTo(WeenieError.ActionCancelled);
}
mgr.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
int stopCallsAfterArm = stopCompletelyCalls; // MoveToPosition's own unconditional stop (§3c) — 1 call, no reentrancy (MovementTypeState was already Invalid before this call started, so its OWN reentrant CancelMoveTo-from-StopCompletely no-ops too).
mgr.CancelMoveTo(WeenieError.ActionCancelled);
// CancelMoveTo's own StopCompletely fires exactly once more (its
// reentrant CancelMoveTo call finds MovementTypeState already
// Invalid — CleanUp ran first — so it no-ops and does NOT trigger a
// THIRD StopCompletely call).
Assert.Equal(stopCallsAfterArm + 1, stopCompletelyCalls);
Assert.Equal(2, reentrantCancelCalls); // one from MoveToPosition's own stop, one from CancelMoveTo's stop
Assert.Equal(MovementType.Invalid, mgr.MovementTypeState);
}
[Fact]
public void CleanUpAndCallWeenie_FiresMoveToComplete_WithGivenError()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
h.Manager.CleanUpAndCallWeenie(WeenieError.None);
Assert.Single(h.MoveToCompleteCalls);
Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void CleanUpAndCallWeenie_Ordering_MovementTypeAlreadyInvalid_WhenStopCompletelyFires()
{
// §7e: CleanUpAndCallWeenie = CleanUp() THEN StopCompletely() —
// reentrancy-safe ordering (the same ordering CancelMoveTo uses).
// Directly observe MovementTypeState AT THE MOMENT StopCompletely
// is invoked by wiring a probe into the seam.
var interp = new MotionInterpreter();
var body = new PhysicsBody { TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active };
interp.PhysicsObj = body;
Position worldPosition = new(1u, Vector3.Zero, Quaternion.Identity);
MovementType? stateDuringStopCompletely = null;
MoveToManager? mgr = null;
mgr = new MoveToManager(
interp,
stopCompletely: () => stateDuringStopCompletely = mgr!.MovementTypeState,
getPosition: () => worldPosition,
getHeading: () => 0f,
setHeading: (h, send) => { },
getOwnRadius: () => 0.5f,
getOwnHeight: () => 2f,
contact: () => true,
isInterpolating: () => false,
getVelocity: () => Vector3.Zero,
getSelfId: () => 0x50000001u,
setTarget: (a, b, c, d) => { },
clearTarget: () => { },
getTargetQuantum: () => 0.0,
setTargetQuantum: q => { });
mgr.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
mgr.CleanUpAndCallWeenie(WeenieError.None);
Assert.Equal(MovementType.Invalid, stateDuringStopCompletely);
}
[Fact]
public void CleanUp_StopsCurrentAndAuxCommands_ClearsTargetForObjectMoves_ThenReinitializes()
{
var h = new MoveToManagerHarness();
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToObject(0x50008888u, 0x50008888u, 1f, 2f, new MovementParameters());
var target = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x50008888u, TargetStatus.Ok, target, target));
Assert.NotEqual(0u, h.Manager.CurrentCommand);
h.Manager.CleanUp();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Equal(1, h.ClearTargetCalls);
Assert.Equal(0u, h.Manager.CurrentCommand);
}
}

View file

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — shared scripted fake interp-sink/provider harness for
/// <see cref="MoveToManager"/> conformance tests (r4-port-plan.md §3 V2:
/// "Use a scripted fake interp-sink/provider harness — NO real sequencer
/// needed; the manager drives the interp seams; assert the call sequences
/// + state"). Wraps a REAL <see cref="MotionInterpreter"/> bound to a
/// minimal always-grounded <see cref="PhysicsBody"/> (so
/// <c>_DoMotion</c>/<c>_StopMotion</c>'s <c>adjust_motion</c> +
/// <c>DoInterpretedMotion</c>/<c>StopInterpretedMotion</c> chain runs for
/// real, dispatch treated as always-succeeding since no
/// <see cref="IInterpretedMotionSink"/> is wired — matching
/// <c>DoInterpretedMotion</c>'s documented null-sink posture), and exposes
/// every <see cref="MoveToManager"/> ctor seam as a mutable, inspectable
/// field so tests can script position/heading/contact/target-tracker
/// behavior and assert on call sequences.
/// </summary>
internal sealed class MoveToManagerHarness
{
public readonly MotionInterpreter Interp = new();
public readonly PhysicsBody Body = new();
/// <summary>Scripted world position + cell (defaults to cell 1, origin).</summary>
public Position WorldPosition = new(1u, Vector3.Zero, Quaternion.Identity);
/// <summary>Scripted compass heading, degrees (P5 convention).</summary>
public float Heading;
/// <summary>Records every <c>SetHeading(heading, send)</c> call.</summary>
public readonly List<(float Heading, bool Send)> SetHeadingCalls = new();
public float OwnRadius = 0.5f;
public float OwnHeight = 2.0f;
public bool ContactValue = true;
public bool IsInterpolatingValue;
public Vector3 Velocity = Vector3.Zero;
public uint SelfId = 0x50000001u;
/// <summary>Records every <c>StopCompletely()</c> call (count only —
/// retail's <c>CPhysicsObj::StopCompletely</c> takes no args at this
/// seam level).</summary>
public int StopCompletelyCalls;
public readonly List<(uint ContextId, uint ObjectId, float Radius, double Quantum)> SetTargetCalls = new();
public int ClearTargetCalls;
public double TargetQuantum;
public readonly List<double> SetTargetQuantumCalls = new();
public int UnstickCalls;
public readonly List<(uint Tlid, float Radius, float Height)> StickToCalls = new();
public readonly List<WeenieError> MoveToCompleteCalls = new();
/// <summary>Scripted clock — advances by <see cref="TickSeconds"/> only
/// when a test calls <see cref="Tick"/>; reading <c>CurTime</c> alone
/// (e.g. multiple reads within one manager call) does NOT advance it,
/// matching retail's <c>Timer::cur_time</c> being a stable snapshot for
/// the duration of one dispatch.</summary>
public double CurTime;
public const double TickSeconds = 1.0 / 30.0;
public readonly MoveToManager Manager;
public MoveToManagerHarness()
{
Interp.PhysicsObj = Body;
Body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active;
Manager = new MoveToManager(
Interp,
stopCompletely: () => StopCompletelyCalls++,
getPosition: () => WorldPosition,
getHeading: () => Heading,
setHeading: (h, send) => { SetHeadingCalls.Add((h, send)); Heading = h; },
getOwnRadius: () => OwnRadius,
getOwnHeight: () => OwnHeight,
contact: () => ContactValue,
isInterpolating: () => IsInterpolatingValue,
getVelocity: () => Velocity,
getSelfId: () => SelfId,
setTarget: (ctx, obj, radius, quantum) => SetTargetCalls.Add((ctx, obj, radius, quantum)),
clearTarget: () => ClearTargetCalls++,
getTargetQuantum: () => TargetQuantum,
setTargetQuantum: q => { TargetQuantum = q; SetTargetQuantumCalls.Add(q); },
curTime: () => CurTime);
Manager.StickTo = (tlid, radius, height) => StickToCalls.Add((tlid, radius, height));
Manager.MoveToComplete = err => MoveToCompleteCalls.Add(err);
Manager.Unstick = () => UnstickCalls++;
}
/// <summary>Advance the scripted clock by one physics tick (1/30 s).</summary>
public void Tick() => CurTime += TickSeconds;
/// <summary>Advance the scripted clock by an arbitrary amount.</summary>
public void Advance(double seconds) => CurTime += seconds;
/// <summary>
/// Drains the REAL <see cref="MotionInterpreter"/>'s
/// <c>pending_motions</c> queue via synthetic <c>MotionDone</c>
/// callbacks — standing in for "the dispatched motion's animation-table
/// cycle finished", which a live <c>AnimationSequencer</c>/
/// <c>MotionTableManager</c> would signal in production. Every
/// <c>_DoMotion</c>/<c>_StopMotion</c> call that succeeds enqueues a
/// node (retail <c>AddToQueue</c>, decomp's <c>DoInterpretedMotion</c>
/// body); without draining, <see cref="MotionInterpreter.MotionsPending"/>
/// stays true forever in this bare harness, which would wedge
/// <see cref="MoveToManager.BeginTurnToHeading"/>'s wait-for-anims gate
/// and <see cref="MoveToManager.HandleMoveToPosition"/> Phase 1's
/// "animating, stop aux" branch permanently. Call after any manager
/// method that dispatches a motion, before asserting on the NEXT tick's
/// behavior.
/// </summary>
public void DrainPendingMotions()
{
while (Interp.MotionsPending())
Interp.MotionDone(0, true);
}
/// <summary>Current interpreted forward command — the observable proxy
/// for "what motion did MoveToManager just dispatch via _DoMotion",
/// since <see cref="MotionInterpreter.DoInterpretedMotion(uint,MovementParameters)"/>
/// writes through to <see cref="InterpretedMotionState.ApplyMotion"/>
/// when <c>ModifyInterpretedState</c> is set (default true).</summary>
public uint ForwardCommand => Interp.InterpretedState.ForwardCommand;
public uint TurnCommand => Interp.InterpretedState.TurnCommand;
public float ForwardSpeed => Interp.InterpretedState.ForwardSpeed;
}

View file

@ -0,0 +1,240 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>BeginTurnToHeading</c> (<c>00529b90</c>, raw 307046-307120,
/// decomp §4d) and <c>HandleTurnToHeading</c> (<c>0052a0c0</c>, raw
/// 307442-307517, decomp §6c) — the direction-pick table (TurnRight ≤180 vs
/// TurnLeft &gt;180), the "already there" early-outs, the
/// <c>MotionsPending</c> wait gate, the arrival snap
/// (<see cref="MoveToMath.HeadingGreater"/> + the ONE <c>set_heading</c> in
/// the whole family), and the PreviousHeading DIFF-seed quirk.
/// </summary>
public sealed class MoveToManagerTurnToHeadingTests
{
// ── BeginTurnToHeading direction pick (§4d) ────────────────────────────
[Theory]
[InlineData(0f, 90f, MotionCommand.TurnRight)] // diff=90 <=180 -> TurnRight
[InlineData(0f, 170f, MotionCommand.TurnRight)] // diff=170 <=180 -> TurnRight
[InlineData(0f, 190f, MotionCommand.TurnLeft)] // diff=190 >180 -> TurnLeft
[InlineData(0f, 270f, MotionCommand.TurnLeft)] // diff=270 >180 -> TurnLeft
public void DirectionPick_Table(float currentHeading, float targetHeading, uint expectedTurn)
{
var h = new MoveToManagerHarness();
h.Heading = currentHeading;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = targetHeading });
Assert.Equal(expectedTurn, h.Manager.CurrentCommand);
}
[Fact]
public void DirectionPick_ExactlyAt180_TurnRight_NotStrictlyGreater()
{
// diff > 180 is the TurnLeft gate (strict); exactly 180 stays TurnRight.
var h = new MoveToManagerHarness();
h.Heading = 0f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 180f });
Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand);
}
[Fact]
public void AlreadyThere_DiffLessThanOrEqualEpsilon_PopsImmediately_NoDispatch()
{
var h = new MoveToManagerHarness();
h.Heading = 90f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f });
Assert.Equal(0u, h.Manager.CurrentCommand);
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
Assert.Empty(h.Manager.PendingActions);
}
[Fact]
public void AlreadyThere_WrappedNearFullCircle_PopsImmediately()
{
// diff > 180 branch's OWN "already there" check: diff + eps >= 360.
var h = new MoveToManagerHarness();
h.Heading = 0.0001f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 0f });
// diff computed via HeadingDiff(0, 0.0001, TurnRight) ~ -0.0001 -> wraps to ~359.9999
// which is > 180 -> TurnLeft branch -> diff+eps >= 360 check.
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void WaitsForPendingAnimations_BeforeArmingTurn()
{
var h = new MoveToManagerHarness();
h.Heading = 0f;
// Simulate an in-flight animation-table motion BEFORE the turn is armed.
h.Interp.AddToQueue(0, MotionCommand.WalkForward, 0);
Assert.True(h.Interp.MotionsPending());
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f });
// BeginNextNode -> BeginTurnToHeading saw MotionsPending() true and
// returned WITHOUT dispatching — CurrentCommand stays 0, the node
// stays queued.
Assert.Equal(0u, h.Manager.CurrentCommand);
Assert.Single(h.Manager.PendingActions);
Assert.Equal(MovementType.TurnToHeading, h.Manager.MovementTypeState);
}
[Fact]
public void EmptyQueue_CancelsWithNoPhysicsObjectCode()
{
var h = new MoveToManagerHarness();
// Calling BeginTurnToHeading directly with no queued node -> CancelMoveTo(NoPhysicsObject, per A10).
h.Manager.BeginTurnToHeading();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void PreviousHeadingSeededWithDiff_NotAHeading()
{
var h = new MoveToManagerHarness();
h.Heading = 0f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f });
// The quirk: PreviousHeading stores the REMAINING DIFF (90), not the
// target heading value coincidentally equal to it here — verify via
// a case where they'd differ.
Assert.Equal(90f, h.Manager.PreviousHeading, 2);
}
[Fact]
public void PreviousHeadingSeed_DiffersFromTargetHeading_ProvingItsADiffNotAHeading()
{
var h = new MoveToManagerHarness();
h.Heading = 30f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f });
// diff = HeadingDiff(90, 30, TurnRight) = 60 -- NOT 90 (the target
// heading) and NOT 30 (current heading) -- proves PreviousHeading
// stores the DIFF.
Assert.Equal(60f, h.Manager.PreviousHeading, 2);
}
// ── HandleTurnToHeading (§6c): arrival snap + progress test ────────────
[Fact]
public void HandleTurnToHeading_NotCurrentlyTurning_ReArmsViaBeginTurnToHeading()
{
var h = new MoveToManagerHarness();
h.Heading = 0f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f });
h.DrainPendingMotions(); // clear the dispatch so a bare HandleTurnToHeading call doesn't hit the "still turning" path unexpectedly
// Force CurrentCommand to something that isn't a turn (simulating an
// external interrupt that cleared it without popping the node) —
// exercised via CancelMoveTo would drop everything, so instead just
// confirm the normal flow already armed a turn command.
Assert.True(h.Manager.CurrentCommand is MotionCommand.TurnRight or MotionCommand.TurnLeft);
}
[Fact]
public void HandleTurnToHeading_Arrival_SnapsHeadingAndSendsTrue()
{
var h = new MoveToManagerHarness();
h.Heading = 0f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f });
h.DrainPendingMotions();
Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand);
// Advance heading to just past the target (heading_greater says we
// passed it) -- simulates the turn animation having rotated us there.
h.Heading = 91f;
h.Manager.HandleTurnToHeading();
// The ONE heading snap in the whole family: SetHeading(90, send:true).
Assert.Contains((90f, true), h.SetHeadingCalls);
Assert.Equal(90f, h.Heading, 2); // snapped to the EXACT node heading, not 91.
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // queue drained -> complete.
}
[Fact]
public void HandleTurnToHeading_StillTurning_RotationalProgress_ResetsFailCounter()
{
// The first post-BeginTurnToHeading tick compares the LIVE heading
// (still 0, unmoved) against PreviousHeading's quirk-seeded DIFF
// value (170, not a heading) — HeadingDiff(0,170,TurnRight)=190,
// outside (eps,180), so tick 1 reads as NO progress (a numeric
// artifact of the seed, not a real stall) and FailProgressCount
// increments once. From tick 2 onward PreviousHeading holds a REAL
// heading and steady rotation reads as genuine progress.
var h = new MoveToManagerHarness();
h.Heading = 0f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 170f });
h.DrainPendingMotions();
Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand);
Assert.Equal(170f, h.Manager.PreviousHeading, 2); // diff-seeded (quirk)
h.Manager.HandleTurnToHeading(); // tick 1 (heading unmoved) -- the seed artifact tick
Assert.Equal(1u, h.Manager.FailProgressCount);
Assert.Equal(0f, h.Manager.PreviousHeading, 2);
h.Heading = 90f; // tick 2: rotated 90 deg toward the 170 target, hasn't passed it.
h.Manager.HandleTurnToHeading();
Assert.Equal(0u, h.Manager.FailProgressCount); // reset by genuine progress
Assert.Equal(90f, h.Manager.PreviousHeading, 2); // updated to the live heading
}
[Fact]
public void HandleTurnToHeading_NoRotationalProgress_IncrementsFailCounter_WhenNotAnimating()
{
var h = new MoveToManagerHarness();
h.Heading = 0f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 170f });
h.DrainPendingMotions();
// Heading did not move at all -> HeadingDiff(0, 170, TurnRight):
// seeded PreviousHeading was 170; live heading still 0 -> diff =
// HeadingDiff(0, 170, TurnRight) = -170 -> +360 = 190; the progress
// window is (eps,180) exclusive on the high end -- 190 fails it ->
// no progress -> counter increments (not interpolating, not animating).
h.Manager.HandleTurnToHeading();
Assert.Equal(1u, h.Manager.FailProgressCount);
}
[Fact]
public void HandleTurnToHeading_TurnLeftDirection_UsesMirroredHeadingDiff()
{
var h = new MoveToManagerHarness();
h.Heading = 0f;
// diff = HeadingDiff(190,0,TurnRight) = 190 > 180 -> TurnLeft chosen.
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 190f });
h.DrainPendingMotions();
Assert.Equal(MotionCommand.TurnLeft, h.Manager.CurrentCommand);
// Rotate counter-clockwise (heading decreasing toward the target
// from the TurnLeft direction) -- heading_greater(-, node.Heading=190, TurnLeft)
// needs the mirror-aware diff test to register progress correctly.
h.Heading = 350f; // moved 10 deg counter-clockwise from 0 (i.e. toward 190 the "left" way)
h.Manager.HandleTurnToHeading();
// Just verifying no crash / a sane FailProgressCount either way —
// the mirror's behavioral effect is dead in retail (§8, P3
// adjudication: the mirror only affects fail_progress_count
// reset-vs-increment, which is write-only) so this is a smoke test
// for the TurnLeft code path executing without throwing.
Assert.True(h.Manager.FailProgressCount is 0 or 1);
}
}

View file

@ -0,0 +1,150 @@
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using Xunit;
namespace AcDream.Core.Tests.Physics.Motion;
/// <summary>
/// R4-V2 — <c>UseTime</c> (<c>0052a780</c>, raw 307776-307798, decomp §6a):
/// the three-gate tick matrix (grounded / node-exists / object-move
/// initialized), including the uninitialized type-6 stall case from the
/// port plan's V2 test list.
/// </summary>
public sealed class MoveToManagerUseTimeGateTests
{
[Fact]
public void NoNodeQueued_UseTimeIsANoOp()
{
var h = new MoveToManagerHarness();
// Fresh manager: no active move, no nodes.
h.Manager.UseTime();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState);
}
[Fact]
public void NotGrounded_ContactFalse_UseTimeDoesNothing_EvenWithNodesQueued()
{
var h = new MoveToManagerHarness { ContactValue = false };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f; // face the target so BeginMoveForward runs (no turn-to-face node needed)
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters());
h.DrainPendingMotions();
uint commandBefore = h.Manager.CurrentCommand;
// Move the mover without letting UseTime process it (Contact=false blocks the gate).
h.WorldPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity);
h.Advance(5.0);
h.Manager.UseTime();
// State machine did not advance -- still the same command, same type.
Assert.Equal(commandBefore, h.Manager.CurrentCommand);
Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState);
}
[Fact]
public void Grounded_MoveToPositionNode_DispatchesToHandleMoveToPosition()
{
var h = new MoveToManagerHarness { ContactValue = true };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f; // face the target so BeginMoveForward runs (no turn-to-face node needed)
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false });
h.DrainPendingMotions();
// Arrived: move the mover close to the TARGET (20,0,0), well within
// DistanceToObject, and advance time so CheckProgressMade evaluates
// true and the arrival branch pops.
h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
h.Manager.UseTime();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // HandleMoveToPosition ran and completed the move.
}
[Fact]
public void Grounded_TurnToHeadingNode_DispatchesToHandleTurnToHeading()
{
var h = new MoveToManagerHarness { ContactValue = true };
h.Heading = 0f;
h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f });
h.DrainPendingMotions();
Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand);
h.Heading = 91f; // "passed" the target
h.Manager.UseTime();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // HandleTurnToHeading ran and completed the turn.
}
[Fact]
public void ObjectMove_UninitializedType6_StallsUntilFirstTargetCallback()
{
// The port plan's named "uninitialized type-6 stall" case: a
// MoveToObject manager with TopLevelObjectId != 0 and
// MovementTypeState != Invalid, but Initialized still false (no
// HandleUpdateTarget callback has arrived yet) -- and CRITICALLY,
// no node is queued yet either (MoveToObject defers node-building
// to the first callback, §3b), so UseTime's node-exists gate (gate
// 2) already blocks it. This test proves the stall holds even if a
// node WERE somehow present (defense in depth for gate 3).
var h = new MoveToManagerHarness { ContactValue = true };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Manager.MoveToObject(0x5000AAAAu, 0x5000AAAAu, 1f, 2f, new MovementParameters());
Assert.False(h.Manager.Initialized);
Assert.Empty(h.Manager.PendingActions); // gate 2 alone already stalls it
h.Manager.UseTime();
// No crash, no state change -- the manager is still waiting.
Assert.Equal(MovementType.MoveToObject, h.Manager.MovementTypeState);
Assert.False(h.Manager.Initialized);
}
[Fact]
public void ObjectMove_Initialized_PassesGate3_ProcessesNormally()
{
var h = new MoveToManagerHarness { ContactValue = true };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f; // face the target so the internal node plan skips the turn-to-face step
h.Manager.MoveToObject(0x5000BBBBu, 0x5000BBBBu, radius: 0.5f, height: 2f, new MovementParameters { UseSpheres = false });
var target = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity);
h.Manager.HandleUpdateTarget(new TargetInfo(0x5000BBBBu, TargetStatus.Ok, target, target));
Assert.True(h.Manager.Initialized);
h.DrainPendingMotions();
h.WorldPosition = new Position(1u, new Vector3(19.5f, 0f, 0f), Quaternion.Identity); // within DistanceToObject default 0.6
h.Advance(2.0);
h.Manager.UseTime();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // completed via UseTime -> HandleMoveToPosition.
}
[Fact]
public void NonObjectMove_TopLevelIdZero_Gate3AlwaysPasses_RegardlessOfInitialized()
{
// Gate 3: (top_level_object_id == 0 || movement_type == Invalid) ||
// initialized. Position/heading moves never set TopLevelObjectId,
// so the FIRST disjunct alone always satisfies gate 3 -- Initialized
// staying false (as it does for MoveToPosition/TurnToHeading, per
// §3c/§3e's notes) never blocks them.
var h = new MoveToManagerHarness { ContactValue = true };
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
h.Heading = 90f;
h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false });
h.DrainPendingMotions();
Assert.Equal(0u, h.Manager.TopLevelObjectId);
Assert.False(h.Manager.Initialized);
h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity);
h.Advance(2.0);
h.Manager.UseTime();
Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // gate 3 passed via the first disjunct.
}
}