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:
parent
e0d2492cbb
commit
addc8e97a8
13 changed files with 3779 additions and 0 deletions
1592
src/AcDream.Core/Physics/Motion/MoveToManager.cs
Normal file
1592
src/AcDream.Core/Physics/Motion/MoveToManager.cs
Normal file
File diff suppressed because it is too large
Load diff
47
src/AcDream.Core/Physics/Motion/MoveToNode.cs
Normal file
47
src/AcDream.Core/Physics/Motion/MoveToNode.cs
Normal 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<MoveToNode></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);
|
||||||
|
|
@ -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 <= 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 >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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue