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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue