feat(R4-V5): LOCAL PLAYER cutover - B.6 auto-walk DELETED; P1 autonomous gate ported; TS-36 bound (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23)
The local player now runs server MoveTos through the same verbatim MoveToManager remotes got in V4. One commit, GameWindow + controller, per the no-fan-out rule for coupled slices. P1 gate (V0-pins.md P1, ported verbatim): CPhysics::SetObjectMovement (0x00509690 @0050972e) drops any movement event whose wire autonomous byte is set when the addressed object IsThePlayer - ACE's self-addressed MoveToState reflection (MovementData.cs:162 IsAutonomous=1) never reaches unpack_movement, which is what makes the retail unconditional unpack-head interrupt safe. Gate placed AFTER the sequence gates (retail order). The stale "ACE follows every mt=0x06 with an mt=0x00" comment block dies with the code it excused (its causal story was pre-#75; refuted in V0-pins P1). Run-rate re-anchor (P1 contingency NOT needed - no AD row): the echo tap ApplyServerRunRate is deleted outright. Both retail feeds already exist: PlayerDescription skills via SetCharacterSkills (K-fix7) into InqRunRate (preferred by apply_run_to_command/get_state_velocity), and the mt-6/7 my_run_rate wire write (M13) now performed for the player by the shared RouteServerMoveTo. The tap's InterpretedState.ForwardSpeed overwrite was a pre-R3 mechanism that fought the ported machinery. B.6 auto-walk deleted wholesale (~330 lines): fields, Begin/End/ DriveServerAutoWalk, IsServerAutoWalking, AutoWalkArrived, the autoWalkConsumedMotion gates, the #69 turn-dir edge synthesizer, and the relocated AutoWalkArrivalEpsilon/AutoWalkTurnRateFor constants (AD-26 retired - the invented 5/30-degree bands are gone; arrival is retail's distance predicate; turn-first is the TurnToHeading node). TS-36 retired: Motion.InterruptCurrentMovement binds to MoveTo.CancelMoveTo(ActionCancelled). Movement-key edges (ctor-default params carry the 0x8000 CancelMoveTo bit), Shift (set_hold_run interrupt:true), jump(), StopCompletely, and teleport all cancel a running moveto through the retail chain - verified by controller-level tests, not assumed. MoveToComplete seam WIDENED to natural completion (Core): retail's BeginNextNode empty-queue completion is inline CleanUp+StopCompletely (raw @00529d47) and notifies nothing - the client-addition seam had to fire there (both sticky and non-sticky exits) or AD-27's deferred close-range Use/PickUp re-send never fires. Never fires on CancelMoveTo. AD-27 re-anchored from the deleted AutoWalkArrived event. InstallSpeculativeTurnToTarget rewired through the player's manager (retail 9a/9b client-initiated shape): close-range -> TurnToObject, far -> MoveToObject; AP-23 radius buckets + the #77 CanCharge prediction survive as the params source (row re-anchored). Per-tick: MoveTo.UseTime() at the old DriveServerAutoWalk slot (provisional until R6, per the plan's placement decision); the P4 player-side TargetTracker twin (fields + pre-Update feed) mirrors the remote adapter (AP-79 row widened). HitGround dual-call added on the player landing edge AND the remote landing site - the latter closes a V4 wiring-contract gap the adversarial review caught (retail 2d order: minterp then moveto; without it a landing NPC's moveto never re-arms). Also from the adversarial review: the mt-8 unresolvable-target degrade now performs retail's params.desired_heading = wire_heading substitution (decomp 2f case 8; invisible against ACE per P6, required for the verbatim degrade); the V4 remote MoveToManager binding gets a real curTime clock (the ctor stub advanced 1/30s per READ, skewing the progress/fail-distance windows - note: the pending V4 NPC smoke ran on the skewed clock); TS-33 row extended with the orientation-diff gap (ApproxPositionEqual vs retail Frame::is_equal full-frame compare - a stationary heading snap does not reach the wire; masked against ACE, R7 outbound scope owns the fix). MoveToMath gains HeadingFromYaw/YawFromHeading (the P5 scalar bridge for yaw-authoritative bodies - the player's heading snap must write Yaw, not the quaternion the controller re-derives every frame). Tests: 3,956 green (+8). New: MoveToManagerCompletionSeamTests (arrival fires once with None, no refire, cancel never fires, sticky handoff order) + PlayerMoveToCutoverTests (EnterPlayerModeNow-shape rig: walks to arrival with zero user input and zero MotionStateChanged frames - the #75 invariant by construction; TurnToHeading rotates Yaw and snaps exact; W-edge and jump cancel without firing complete). W6 edge suite retargeted from the deleted echo tap to a direct apply pass (the regression lives in ApplyInterpretedMovement, not the wire trigger). Spec: docs/research/2026-07-03-r4-moveto/r4-port-plan.md section 3 V5. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
e4553a0262
commit
b3decdfac6
9 changed files with 947 additions and 688 deletions
282
tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
Normal file
282
tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
using Position = AcDream.Core.Physics.Position;
|
||||
|
||||
namespace AcDream.Core.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 — local-player MoveTo cutover: a <see cref="MoveToManager"/> bound
|
||||
/// to the controller exactly the way <c>GameWindow.EnterPlayerModeNow</c>
|
||||
/// binds it (Yaw-authoritative heading seams via the P5 bridge, Contact
|
||||
/// transient bit, SimTimeSeconds clock) drives the player's body through
|
||||
/// <see cref="PlayerMovementController.Update"/> with NO user input — the
|
||||
/// manager's <c>_DoMotion</c> dispatches land in InterpretedState and the
|
||||
/// controller's per-frame sections turn them into Yaw rotation + body
|
||||
/// velocity. Also pins the TS-36 retirement: any input edge (movement key,
|
||||
/// jump) cancels the moveto through the retail
|
||||
/// InterruptCurrentMovement → CancelMoveTo chain, and a cancel never fires
|
||||
/// the MoveToComplete completion seam (AD-27's deferred-Use contract).
|
||||
/// </summary>
|
||||
public class PlayerMoveToCutoverTests
|
||||
{
|
||||
private const uint NC = 0x8000003Du;
|
||||
private const uint Ready = 0x41000003u;
|
||||
private const uint Walk = 0x45000005u;
|
||||
private const uint Run = 0x44000007u;
|
||||
private const uint TurnRight = 0x6500000Du;
|
||||
|
||||
private sealed class Loader : IAnimationLoader
|
||||
{
|
||||
private readonly System.Collections.Generic.Dictionary<uint, Animation> _anims = new();
|
||||
public void Register(uint id, Animation anim) => _anims[id] = anim;
|
||||
public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null;
|
||||
}
|
||||
|
||||
private static Animation MakeAnim(int frames)
|
||||
{
|
||||
var anim = new Animation();
|
||||
for (int f = 0; f < frames; f++)
|
||||
{
|
||||
var pf = new AnimationFrame(1);
|
||||
pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity });
|
||||
anim.PartFrames.Add(pf);
|
||||
}
|
||||
return anim;
|
||||
}
|
||||
|
||||
private static MotionData MakeMd(uint animId)
|
||||
{
|
||||
var md = new MotionData();
|
||||
QualifiedDataId<Animation> qid = animId;
|
||||
md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f });
|
||||
return md;
|
||||
}
|
||||
|
||||
private static AnimationSequencer MakeSequencer()
|
||||
{
|
||||
var setup = new Setup();
|
||||
setup.Parts.Add(0x01000000u);
|
||||
setup.DefaultScale.Add(Vector3.One);
|
||||
|
||||
var loader = new Loader();
|
||||
loader.Register(0x300u, MakeAnim(4));
|
||||
loader.Register(0x301u, MakeAnim(6));
|
||||
loader.Register(0x302u, MakeAnim(6));
|
||||
loader.Register(0x303u, MakeAnim(6));
|
||||
|
||||
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC };
|
||||
mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
|
||||
mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u);
|
||||
mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u);
|
||||
mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u);
|
||||
// The MoveToManager's aux-turn steering / TurnToHeading nodes
|
||||
// dispatch TurnRight (adjust_motion normalizes TurnLeft into it) —
|
||||
// without a table cycle the real sink's false return becomes a
|
||||
// _DoMotion error and the manager cancels the moveto (retail
|
||||
// dispatch-failure semantics), so the fixture needs one.
|
||||
mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u);
|
||||
return new AnimationSequencer(setup, mt, loader);
|
||||
}
|
||||
|
||||
private static PhysicsEngine MakeFlatEngine()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)50);
|
||||
var heightTable = new float[256];
|
||||
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
|
||||
var terrain = new TerrainSurface(heights, heightTable);
|
||||
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private sealed class Rig
|
||||
{
|
||||
public required PlayerMovementController Controller;
|
||||
public required MoveToManager MoveTo;
|
||||
public int MoveToCompleteCount;
|
||||
public WeenieError LastCompleteError;
|
||||
}
|
||||
|
||||
/// <summary>The EnterPlayerModeNow bind set, verbatim shape: real sink,
|
||||
/// Yaw-authoritative heading seams (P5 bridge), Contact bit, false
|
||||
/// isInterpolating, SimTimeSeconds clock, TS-36 interrupt binding.</summary>
|
||||
private static Rig MakeRig()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f; // heading 90 = facing +X
|
||||
var seq = MakeSequencer();
|
||||
controller.Motion.DefaultSink = new MotionTableDispatchSink(seq);
|
||||
controller.Motion.RemoveLinkAnimations = seq.RemoveAllLinkAnimations;
|
||||
controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState();
|
||||
controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions;
|
||||
|
||||
var rig = new Rig { Controller = controller, MoveTo = null! };
|
||||
var moveTo = new MoveToManager(
|
||||
controller.Motion,
|
||||
stopCompletely: () => controller.Motion.StopCompletely(),
|
||||
getPosition: () => new Position(
|
||||
controller.CellId, controller.Position, controller.BodyOrientation),
|
||||
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
|
||||
setHeading: (h, _) => controller.Yaw = MoveToMath.YawFromHeading(h),
|
||||
getOwnRadius: () => 0f,
|
||||
getOwnHeight: () => 0f,
|
||||
contact: () => controller.BodyInContact,
|
||||
isInterpolating: () => false,
|
||||
getVelocity: () => controller.BodyVelocity,
|
||||
getSelfId: () => 0x5000000Au,
|
||||
setTarget: (_, _, _, _) => { },
|
||||
clearTarget: () => { },
|
||||
getTargetQuantum: () => 0.0,
|
||||
setTargetQuantum: _ => { },
|
||||
curTime: () => controller.SimTimeSeconds);
|
||||
moveTo.MoveToComplete = err =>
|
||||
{
|
||||
rig.MoveToCompleteCount++;
|
||||
rig.LastCompleteError = err;
|
||||
};
|
||||
controller.MoveTo = moveTo;
|
||||
controller.Motion.InterruptCurrentMovement =
|
||||
() => moveTo.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
rig.MoveTo = moveTo;
|
||||
return rig;
|
||||
}
|
||||
|
||||
/// <summary>Stands in for the player sequencer's MotionDone feed (bound
|
||||
/// in production via the R3-W2 MotionTableManager seam) — without it,
|
||||
/// pending_motions never drains in this fixture and the manager's
|
||||
/// wait-for-anims gates wedge.</summary>
|
||||
private static void Drain(PlayerMovementController c)
|
||||
{
|
||||
while (c.Motion.MotionsPending())
|
||||
c.Motion.MotionDone(0, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerMoveToPosition_WalksToArrival_WithNoUserInput()
|
||||
{
|
||||
var rig = MakeRig();
|
||||
var c = rig.Controller;
|
||||
var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90)
|
||||
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.MoveToPosition,
|
||||
Pos = new Position(c.CellId, dest, Quaternion.Identity),
|
||||
Params = new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f },
|
||||
});
|
||||
Drain(c);
|
||||
Assert.True(rig.MoveTo.IsMovingTo());
|
||||
|
||||
for (int f = 0; f < 1200 && rig.MoveTo.IsMovingTo(); f++)
|
||||
{
|
||||
var result = c.Update(1f / 60f, new MovementInput());
|
||||
// The #75 invariant, now by construction: manager-driven motion
|
||||
// never registers as a user motion-state change, so no outbound
|
||||
// MoveToState is built mid-moveto.
|
||||
Assert.False(result.MotionStateChanged,
|
||||
$"manager-driven frame {f} must not flag MotionStateChanged");
|
||||
Drain(c);
|
||||
}
|
||||
|
||||
Assert.False(rig.MoveTo.IsMovingTo(), "moveto must arrive within the frame budget");
|
||||
float distLeft = Vector2.Distance(
|
||||
new Vector2(c.Position.X, c.Position.Y), new Vector2(dest.X, dest.Y));
|
||||
Assert.True(distLeft <= 1.0f,
|
||||
$"body must stop at the arrival radius; {distLeft:F2} m left");
|
||||
Assert.Equal(1, rig.MoveToCompleteCount);
|
||||
Assert.Equal(WeenieError.None, rig.LastCompleteError);
|
||||
Assert.Equal(Ready, c.Motion.InterpretedState.ForwardCommand);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerTurnToHeading_RotatesYaw_AndSnapsExact()
|
||||
{
|
||||
var rig = MakeRig();
|
||||
var c = rig.Controller;
|
||||
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.TurnToHeading,
|
||||
Params = new MovementParameters { DesiredHeading = 180f }, // South = -Y
|
||||
});
|
||||
Drain(c);
|
||||
Assert.True(rig.MoveTo.IsMovingTo());
|
||||
|
||||
for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++)
|
||||
{
|
||||
c.Update(1f / 60f, new MovementInput());
|
||||
Drain(c);
|
||||
}
|
||||
|
||||
Assert.False(rig.MoveTo.IsMovingTo(), "turn-to must complete within the frame budget");
|
||||
// HandleTurnToHeading's arrival snap (the ONE heading snap in the
|
||||
// family, 0052a146) writes through the Yaw seam: heading 180 → yaw
|
||||
// -π/2 per the P5 bridge.
|
||||
Assert.Equal(MoveToMath.YawFromHeading(180f), c.Yaw, 3);
|
||||
Assert.Equal(1, rig.MoveToCompleteCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovementKeyEdge_CancelsMoveTo_WithoutFiringComplete()
|
||||
{
|
||||
var rig = MakeRig();
|
||||
var c = rig.Controller;
|
||||
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.MoveToPosition,
|
||||
Pos = new Position(c.CellId, new Vector3(150f, 96f, 50f), Quaternion.Identity),
|
||||
Params = new MovementParameters { UseSpheres = false },
|
||||
});
|
||||
Drain(c);
|
||||
Assert.True(rig.MoveTo.IsMovingTo());
|
||||
c.Update(1f / 60f, new MovementInput());
|
||||
Drain(c);
|
||||
Assert.True(rig.MoveTo.IsMovingTo());
|
||||
|
||||
// W press edge → DoMotion(ctor-default params, CancelMoveTo bit set)
|
||||
// → InterruptCurrentMovement → CancelMoveTo(ActionCancelled).
|
||||
var result = c.Update(1f / 60f, new MovementInput { Forward = true });
|
||||
|
||||
Assert.False(rig.MoveTo.IsMovingTo(), "user input must cancel the moveto (TS-36)");
|
||||
Assert.Equal(0, rig.MoveToCompleteCount);
|
||||
// The cancel-frame's state change IS user intent — it must go on
|
||||
// the wire (the former !IsServerAutoWalking guard is gone).
|
||||
Assert.True(result.MotionStateChanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpRelease_CancelsMoveTo()
|
||||
{
|
||||
var rig = MakeRig();
|
||||
var c = rig.Controller;
|
||||
|
||||
rig.MoveTo.PerformMovement(new MovementStruct
|
||||
{
|
||||
Type = MovementType.MoveToPosition,
|
||||
Pos = new Position(c.CellId, new Vector3(150f, 96f, 50f), Quaternion.Identity),
|
||||
Params = new MovementParameters { UseSpheres = false },
|
||||
});
|
||||
Drain(c);
|
||||
Assert.True(rig.MoveTo.IsMovingTo());
|
||||
|
||||
// Charge one frame, release the next — jump() fires on the release
|
||||
// edge and its interrupt site cancels the moveto (the exact
|
||||
// silent-double-motion failure the TS-36 register row predicted).
|
||||
c.Update(1f / 60f, new MovementInput { Jump = true });
|
||||
c.Update(1f / 60f, new MovementInput());
|
||||
|
||||
Assert.False(rig.MoveTo.IsMovingTo(), "jump must cancel the moveto (TS-36)");
|
||||
Assert.Equal(0, rig.MoveToCompleteCount);
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +107,21 @@ public class W6EdgeDrivenMovementTests
|
|||
return controller;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The full apply pass the W6b live bug rode in on. Pre-R4-V5 the
|
||||
/// trigger was <c>ApplyServerRunRate</c> (the ACE autonomous-echo tap);
|
||||
/// V5's P1 gate drops that echo before it reaches the player, and the
|
||||
/// tap is deleted — but the regression these tests pin lives in
|
||||
/// <c>ApplyInterpretedMovement</c>'s entry-caching, which any
|
||||
/// apply_current_movement pass (HitGround re-apply, future R6 per-tick
|
||||
/// order) still exercises. Same two statements the deleted tap ran.
|
||||
/// </summary>
|
||||
private static void RunApplyPass(PlayerMovementController controller, float forwardSpeed)
|
||||
{
|
||||
controller.Motion.InterpretedState.ForwardSpeed = forwardSpeed;
|
||||
controller.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPass_WithRealSink_ForwardSelfHeals()
|
||||
{
|
||||
|
|
@ -120,8 +135,9 @@ public class W6EdgeDrivenMovementTests
|
|||
|
||||
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
|
||||
|
||||
// The live killer: the UM-echo pass (~10Hz in the real client).
|
||||
controller.ApplyServerRunRate(4.5f);
|
||||
// The live killer: a full apply pass mid-hold (was the ~10Hz
|
||||
// UM-echo tap pre-V5; see RunApplyPass).
|
||||
RunApplyPass(controller, 4.5f);
|
||||
|
||||
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
|
||||
Assert.True(controller.Motion.get_state_velocity().Length() > 1f,
|
||||
|
|
@ -139,7 +155,7 @@ public class W6EdgeDrivenMovementTests
|
|||
for (int f = 0; f < 120; f++)
|
||||
{
|
||||
if (f % 6 == 3)
|
||||
controller.ApplyServerRunRate(4.5f); // ACE echo cadence
|
||||
RunApplyPass(controller, 4.5f); // ex-ACE-echo cadence
|
||||
pos = controller.Update(1f / 60f, input).Position;
|
||||
}
|
||||
|
||||
|
|
@ -163,7 +179,7 @@ public class W6EdgeDrivenMovementTests
|
|||
// Shift pressed (walk) — the set_hold_run edge demotes to walk.
|
||||
for (int f = 0; f < 30; f++)
|
||||
{
|
||||
if (f == 10) controller.ApplyServerRunRate(1.0f); // echo mid-walk
|
||||
if (f == 10) RunApplyPass(controller, 1.0f); // apply pass mid-walk
|
||||
controller.Update(1f / 60f, new MovementInput { Forward = true, Run = false });
|
||||
}
|
||||
Assert.Equal(Walk, controller.Motion.InterpretedState.ForwardCommand);
|
||||
|
|
@ -171,7 +187,7 @@ public class W6EdgeDrivenMovementTests
|
|||
// Shift released — promote back to run; survives another echo.
|
||||
for (int f = 0; f < 30; f++)
|
||||
{
|
||||
if (f == 10) controller.ApplyServerRunRate(4.5f);
|
||||
if (f == 10) RunApplyPass(controller, 4.5f);
|
||||
controller.Update(1f / 60f, new MovementInput { Forward = true, Run = true });
|
||||
}
|
||||
Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 — the <c>MoveToComplete</c> CLIENT-ADDITION seam contract (see the
|
||||
/// seam's doc on <see cref="MoveToManager.MoveToComplete"/>): fires with
|
||||
/// <see cref="WeenieError.None"/> on NATURAL COMPLETION (the
|
||||
/// <see cref="MoveToManager.BeginNextNode"/> empty-queue exits, both sticky
|
||||
/// and non-sticky, plus <c>CleanUpAndCallWeenie</c>'s instant-success path)
|
||||
/// and NEVER on <see cref="MoveToManager.CancelMoveTo"/>. The App layer's
|
||||
/// AD-27 re-anchor (deferred Use/PickUp re-send on arrival) depends on
|
||||
/// exactly this split: arrival fires the deferred action once; a user-input
|
||||
/// cancel must not.
|
||||
/// </summary>
|
||||
public sealed class MoveToManagerCompletionSeamTests
|
||||
{
|
||||
/// <summary>Arms a position move 5 m dead ahead (heading 90 = +X,
|
||||
/// facing it) and drives the scripted body to arrival via UseTime.</summary>
|
||||
private static MoveToManagerHarness DriveToArrival()
|
||||
{
|
||||
var h = new MoveToManagerHarness();
|
||||
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||
h.Heading = 90f;
|
||||
h.Manager.MoveToPosition(
|
||||
new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity),
|
||||
new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f });
|
||||
h.DrainPendingMotions();
|
||||
|
||||
for (int i = 0; i < 200 && h.Manager.IsMovingTo(); i++)
|
||||
{
|
||||
h.Tick();
|
||||
var cur = h.WorldPosition.Frame.Origin;
|
||||
h.WorldPosition = new Position(
|
||||
1u, cur + new Vector3(0.2f, 0f, 0f), Quaternion.Identity);
|
||||
h.Manager.UseTime();
|
||||
h.DrainPendingMotions();
|
||||
}
|
||||
|
||||
Assert.False(h.Manager.IsMovingTo(),
|
||||
"scripted drive must reach arrival within the tick budget");
|
||||
return h;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaturalArrival_FiresMoveToComplete_OnceWithNone()
|
||||
{
|
||||
var h = DriveToArrival();
|
||||
|
||||
Assert.Single(h.MoveToCompleteCalls);
|
||||
Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AfterArrival_ExtraUseTimeTicks_DoNotRefire()
|
||||
{
|
||||
var h = DriveToArrival();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
h.Tick();
|
||||
h.Manager.UseTime();
|
||||
}
|
||||
|
||||
Assert.Single(h.MoveToCompleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelMoveTo_DoesNotFireMoveToComplete()
|
||||
{
|
||||
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 });
|
||||
Assert.True(h.Manager.IsMovingTo());
|
||||
|
||||
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
|
||||
Assert.False(h.Manager.IsMovingTo());
|
||||
Assert.Empty(h.MoveToCompleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StickyCompletion_FiresMoveToComplete_AfterStickToHandoff()
|
||||
{
|
||||
// In-range sticky object move: MoveToObject arms the tracker; the
|
||||
// first Ok callback finds the target already inside
|
||||
// DistanceToObject, so no motion nodes are needed and BeginNextNode
|
||||
// lands straight on the empty-queue STICKY exit — StickTo gets the
|
||||
// pre-CleanUp tlid/radius/height, then the completion seam fires.
|
||||
var h = new MoveToManagerHarness();
|
||||
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||
h.Heading = 90f;
|
||||
h.Manager.MoveToObject(
|
||||
objectId: 0x1000u, topLevelId: 0x1000u, radius: 0.5f, height: 1f,
|
||||
new MovementParameters { Sticky = true, DistanceToObject = 5f });
|
||||
h.DrainPendingMotions();
|
||||
|
||||
var target = new Position(1u, new Vector3(1f, 0f, 0f), Quaternion.Identity);
|
||||
h.Manager.HandleUpdateTarget(
|
||||
new TargetInfo(0x1000u, TargetStatus.Ok, target, target));
|
||||
h.DrainPendingMotions();
|
||||
|
||||
// Some builds need the turn/settle nodes ticked through first.
|
||||
for (int i = 0; i < 50 && h.Manager.IsMovingTo(); i++)
|
||||
{
|
||||
h.Tick();
|
||||
h.Manager.UseTime();
|
||||
h.DrainPendingMotions();
|
||||
}
|
||||
|
||||
Assert.False(h.Manager.IsMovingTo());
|
||||
Assert.Single(h.StickToCalls);
|
||||
Assert.Equal((0x1000u, 0.5f, 1f), h.StickToCalls[0]);
|
||||
Assert.Single(h.MoveToCompleteCalls);
|
||||
Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue