acdream/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTestHarness.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

138 lines
6.2 KiB
C#

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