acdream/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerAuxTurnTests.cs
Erik addc8e97a8 feat(R4-V2): MoveToManager verbatim - all 33 members + conformance harness (closes M1/M3/M4/M5/M6/M10/M14-core)
The retail server-directed-movement brain (0x00529010-0x0052a987),
Core-only with every App dependency as a ctor/property seam for the
V4/V5 cutovers: node-plan builders for all four movement types
(TurnToObject's desired-heading clobber quirk VERBATIM; TurnToHeading's
immediate BeginNextNode - ACE's one-tick-late gap not copied),
PerformMovement (cancel 0x36 + unstick first), BeginNextNode with the
sticky handoff order (radius/height/tlid read BEFORE CleanUp),
BeginMoveForward (GetCommand walk/run cascade + stored-params
write-back + progress-clock seed), HandleMoveToPosition (chase arrival
dist <= distance_to_object per the adjudicated BN inversion; fail
distance -> 0x3D; progress >= 0.25 units/s over >= 1 s, incremental AND
overall; fail_progress_count write-only - retail has NO give-up
threshold and none was invented), HandleTurnToHeading (20/340 aux
deadband; the Ghidra-confirmed heading_diff mirror), HandleUpdateTarget
0x0052a7d0 (deferred-start: object moves wait for the first Ok
callback; retargets reset the progress clock without requeueing),
UseTime's initialized gate, InitializeLocalVariables per retail (flags
word + context_id zeroed, floats stale, FLT_MAX resets - not ACE's
transpositions).

TDD catch: default(Quaternion) is the ZERO quaternion, not identity -
a fresh manager's heading computations would silently read 90 degrees;
explicit IdentityPosition resets match the decomp's identity-Frame
semantics. Also pinned: retail's explicit double adjust_motion in
_DoMotion/_StopMotion; entry points never drain pending_actions (only
PerformMovement's cancel does) - re-issues must route through
PerformMovement, documented + tested.

101 new conformance tests incl. three end-to-end scripted drives
(chase turn->run->walk-demote->arrive; flee; frozen-heading
TurnToObject through retarget). Full suite: 3,961 passed.

Implemented by a dedicated agent against the V0-pinned spec; scope +
suite independently verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:43:50 +02:00

147 lines
5.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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