using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using Xunit; namespace AcDream.Core.Tests.Physics.Motion; /// /// R4-V2 — HandleMoveToPosition Phase 2 (00529d80, raw /// 307283-307331) and CheckProgressMade (005290f0, raw /// 306385-306431), per r4-moveto-decomp.md §6b/§5b: arrival predicate /// (chase dist <= DistanceToObject vs flee /// dist >= MinDistance), fail-distance ( /// 0x3D), and the 1-second / 0.25-units-per-second progress window (BOTH /// incremental AND overall rates). /// 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); } }