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>
295 lines
13 KiB
C#
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 <= DistanceToObject</c> vs flee
|
|
/// <c>dist >= 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);
|
|
}
|
|
}
|