acdream/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerArrivalAndProgressTests.cs
Erik addc8e97a8 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>
2026-07-03 11:43:50 +02:00

295 lines
13 KiB
C#

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