acdream/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs
Erik 9355ddcec6 feat(physics): Campaign P P1 - stat-coupled movement (burden/stamina/vitae)
Ports the retail CACQualities/EncumbranceSystem/MovementSystem chain
(named-retail decomp pc 256393/412901-414050/416169-416320/695958+) so
PlayerWeenie's run rate, jump height, jump permission, and jump stamina
cost are real functions of burden, current stamina, and vitae/skill
enchantments instead of stubs.

Core:
- New EncumbranceSystem.cs (delegates to the already-verified
  BurdenMath formulas — one source of truth for the burden HUD and
  movement physics) and MovementSystem.cs (GetRunRate/GetJumpHeight/
  JumpStaminaCost/GetJumpPower, decomp-cited; ACE cross-referenced
  where BN dropped the general-case arithmetic entirely).
- PlayerWeenie rewritten as the CACQualities-shaped composition:
  CanJump gates on burden (<2.0 load, UN-8 — x87 polarity resolved by
  plausibility, Ghidra MCP unavailable this slice), JumpStaminaCost
  returns the real ceil((load+0.5)*power*8+2) cost and always affords
  it (matches decomp — retail's own function never refuses; "weak"
  jump comes entirely from the stamina==0 skill-zeroing gate inside
  InqRunRate/InqJumpVelocity, not a hard refusal), SetStamina wires a
  null="unknown, don't gate" sentinel preserving every pre-P1 test.
- EnchantmentMath.GetMod gained an optional StatModType flag filter
  (GetSkillMod convenience wrapper) so the SAME vitae/family-stacking
  machinery already used for vital-max buffs now also answers "what's
  the vitae+skill-enchantment-adjusted Run/Jump skill" — reusing the
  M3 active-enchantment state, not a new engine.

Runtime:
- RuntimeCharacterState now stores the pre-EnchantSkill base run/jump
  skill and recomputes the adjusted value (vitae first, then matching
  Skill-flagged buffs, floor 0.5, truncate) on every base push AND on
  every Spellbook.EnchantmentsChanged notification — a vitae change
  alone moves the produced rate without a fresh PlayerDescription.
- RuntimeMovementSkillState extended with Burden/CurrentStamina
  (RuntimeMovementSkillProjection.ApplyTo pushes both through the
  existing seam); LiveSessionEventRouter recomputes burden from the
  same Strength+aug-property+EncumbranceVal inputs the burden HUD
  already assembles (reacting to the same ClientObjectTable events)
  and pushes current stamina from LocalPlayerState vital updates.
- Wires the previously dead-lettered ReportExhaustion() R3-W4 seam:
  LiveSessionRuntimeFactory's OnMovementStatsUpdated callback re-
  applies the current snapshot to the live controller and forces an
  immediate movement re-evaluation on any skill/burden/stamina change.

Register: retires TS-5 (CanJump/JumpStaminaCost stubs) and AP-25 (no
vitae in pushed skill). Adds AP-127 (two minor unmodeled retail bonus
properties + the stamina-buff-adjusts-local-copy nuance, deliberately
out of the bounded "run/jump query path only" scope) and UN-8 (the
CanJump x87 polarity call, flagged for a future Ghidra MCP
confirmation pass). Extends TS-23 (PlayerKillerStatus not parsed) to
cover JumpStaminaCost's new pk parameter, hardcoded false pending P3.

Full pseudocode + retail citations + the vitae/skill-level finding in
docs/research/2026-07-30-stat-coupled-movement-pseudocode.md.

Release suite: Core.Tests 3977/2 skips, Runtime.Tests 425/0 skips,
App.Tests 3968/3 skips — all green. (One pre-existing, unrelated Debug-
only flake in LandblockBuildOriginTests reproduces on the pre-P1
baseline and passes in Release; not touched here.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 08:18:06 +02:00

864 lines
34 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;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
public class PlayerMovementControllerTests
{
private static float ObjectTick => PhysicsBody.MinQuantum + 0.001f;
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;
}
[Fact]
public void PerformMovement_ReactivatesCanonicalClock_ExceptForStaticObject()
{
var clock = new RetailObjectQuantumClock();
var controller = new PlayerMovementController(MakeFlatEngine(), clock);
clock.Deactivate();
controller.ApplyPhysicsState(PhysicsStateFlags.None);
controller.Movement.PerformMovement(new MovementStruct
{
Type = (MovementType)99,
});
Assert.True(clock.IsActive);
clock.Deactivate();
controller.ApplyPhysicsState(PhysicsStateFlags.Static);
controller.Movement.PerformMovement(new MovementStruct
{
Type = (MovementType)99,
});
Assert.False(clock.IsActive);
}
[Fact]
public void Update_NoInput_PositionUnchanged()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var result = controller.Update(0.016f, new MovementInput());
Assert.Equal(96f, result.Position.X, precision: 1);
Assert.Equal(96f, result.Position.Y, precision: 1);
}
// ── Indoor-flap root cause: resting-body bit-stability ────────────────────
//
// The indoor render "flap" (textures battling at the cottage doorway) is
// portal-flood membership instability. PortalVisibilityBuilder.Build is a
// proven-deterministic pure function, so the membership can only flip if its
// INPUT (the camera eye, derived from the player RenderPosition) varies.
// Live 6-dp capture (pvinput.log:54) shows the player RenderPosition carries
// a perpetual ~1-ULP flicker at rest (Z 94.000000 <-> 93.999992 — exactly one
// float mantissa step). ComputeRenderPosition is Vector3.Lerp(_prevPhysicsPos,
// _currPhysicsPos, alpha), and Lerp(a, a, t) == a exactly, so a jittering
// RenderPosition at rest means the physics body's resting Position is NOT
// bit-stable between ticks. Retail's authoritative local position is bit-stable
// at rest (validate_transition -> kill_velocity on every grounded contact), so
// retail never flaps.
//
// This test pins the physics-side invariant: a grounded body with no input
// must hold a byte-identical position across many frames. It PASSES — which
// is itself the evidence: the physics resting position is bit-stable, so the
// doorway flap is NOT a physics-rest jitter. See
// docs/research/2026-06-08-flap-physics-diagnosis-REFUTED-its-render-membership.md
// (the flap is render-side portal-flood membership instability at the grazing
// doorway portal under a sweeping camera eye). Kept as a regression guard.
[Fact]
public void Update_AtRestNoInput_RenderPositionBitStableAcrossManyFrames()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
var rest = new Vector3(96f, 96f, 50f);
controller.SetPosition(rest, 0x0001);
// Settle one frame so the resolver establishes its rest state, then
// capture the baseline the body must hold.
var settled = controller.Update(1f / 60f, new MovementInput());
Vector3 baselineRender = settled.RenderPosition;
Vector3 baselinePhysics = settled.Position;
// Hold still for ~10 s of 60 Hz frames (crosses MinQuantum every ~2
// frames, so the 30 Hz physics tick fires throughout — same cadence as
// live). Any deviation, even one ULP, is the flap's root cause.
float maxRenderDev = 0f;
float maxPhysicsDev = 0f;
for (int i = 0; i < 600; i++)
{
var r = controller.Update(1f / 60f, new MovementInput());
maxRenderDev = MathF.Max(maxRenderDev, (r.RenderPosition - baselineRender).Length());
maxPhysicsDev = MathF.Max(maxPhysicsDev, (r.Position - baselinePhysics).Length());
}
Assert.True(
maxRenderDev == 0f && maxPhysicsDev == 0f,
$"resting body drifted: render={maxRenderDev * 1e6f:F3} µm, " +
$"physics={maxPhysicsDev * 1e6f:F3} µm; expected byte-identical rest");
}
// After walking then releasing input, the body must SETTLE to a
// byte-identical resting position — not keep blipping a residual velocity.
// This models the live flap: the player walks to the cottage doorway and
// stops, and the eye then carries a ~1-ULP jitter that flips portal-flood
// membership. Flat-terrain variant: if even this drifts, the residual-after-
// motion path is the root and it is not indoor-specific.
[Fact]
public void Update_WalkThenStop_SettlesToBitStableRest()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
// Walk forward ~0.5 s, then release.
for (int i = 0; i < 30; i++)
controller.Update(1f / 60f, new MovementInput(Forward: true));
// Let velocity decay / state settle.
for (int i = 0; i < 30; i++)
controller.Update(1f / 60f, new MovementInput());
var settled = controller.Update(1f / 60f, new MovementInput());
Vector3 basePos = settled.Position;
Vector3 baseRender = settled.RenderPosition;
float maxPos = 0f, maxRender = 0f;
for (int i = 0; i < 600; i++)
{
var r = controller.Update(1f / 60f, new MovementInput());
maxPos = MathF.Max(maxPos, (r.Position - basePos).Length());
maxRender = MathF.Max(maxRender, (r.RenderPosition - baseRender).Length());
}
Assert.True(maxPos == 0f && maxRender == 0f,
$"post-walk rest drifted: pos={maxPos * 1e6f:F3} µm, render={maxRender * 1e6f:F3} µm");
}
[Fact]
public void Update_ForwardInput_MovesInFacingDirection()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // facing +X
// L.5 physics-tick gate (235de33, 2026-04-30): Update() integrates
// only one MinQuantum (~0.033s) per MaxQuantum (~0.1s) tick, matching
// retail's 30Hz physics. A single Update(1.0f) only advances one
// MaxQuantum step (~0.312m at walk speed 3.12 m/s). Drive the
// controller one MaxQuantum at a time for ~1s to accumulate real
// forward motion (8 × 0.1s = 0.8s × 3.12 m/s ≈ 2.5m).
var input = new MovementInput { Forward = true };
MovementResult result = default;
int ticks = (int)MathF.Ceiling(1.0f / PhysicsBody.MaxQuantum) + 1; // ~11 ticks
for (int i = 0; i < ticks; i++)
result = controller.Update(PhysicsBody.MaxQuantum, input);
// Should have moved >2 units in +X (walk speed over ~1s).
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
}
[Fact]
public void Update_AttachedAnimationWithZeroRootDelta_DoesNotGlideOnForwardEdge()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource((_, _) => { });
MovementResult result = controller.Update(
ObjectTick,
new MovementInput(Forward: true));
Assert.Equal(start, result.Position);
Assert.Equal(0f, controller.BodyVelocity.X);
Assert.Equal(0f, controller.BodyVelocity.Y);
}
[Fact]
public void Update_AttachedAnimationRootDelta_DrivesGroundedBodyAtObjectScale()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
controller.ObjectScale = 2f;
controller.AttachAnimationRootMotionSource((_, frame) =>
frame.Origin = new Vector3(0f, 0.1f, 0f));
MovementResult result = controller.Update(
ObjectTick,
new MovementInput(Forward: true));
// Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles
// the animation-authored 0.1 m displacement to 0.2 m.
Assert.Equal(start.X + 0.2f, result.Position.X, precision: 3);
Assert.Equal(start.Y, result.Position.Y, precision: 3);
}
[Fact]
public void Update_SubQuantumFrames_AdvanceAnimationOnceAtObjectThreshold()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
advances++;
frame.Origin = new Vector3(0f, 0.1f, 0f);
});
MovementResult first = controller.Update(
ObjectTick * 0.5f,
new MovementInput(Forward: true));
MovementResult second = controller.Update(
ObjectTick * 0.5f,
new MovementInput(Forward: true));
Assert.Equal(start, first.Position);
Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3);
Assert.Equal(start.Y, second.Position.Y, precision: 3);
Assert.Equal(1, advances);
}
[Fact]
public void Update_AttachedAnimationFrame_ComposesTranslationBeforeTurn()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f; // body local +Y faces world +X
controller.AttachAnimationRootMotionSource((_, frame) =>
{
frame.Origin = new Vector3(0f, 0.1f, 0f);
frame.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f);
});
MovementResult result = controller.Update(
ObjectTick,
new MovementInput());
// Retail Frame::combine transforms the delta origin by the OLD body
// orientation, then composes the delta orientation. The body therefore
// moves east before finishing the quarter-turn to north.
Assert.Equal(start.X + 0.1f, result.Position.X, precision: 3);
Assert.Equal(start.Y, result.Position.Y, precision: 3);
Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3);
}
[Fact]
public void Update_AttachedAnimationFrame_PreservesCompleteNonCommutingOrientation()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
Quaternion initial = Quaternion.CreateFromAxisAngle(Vector3.UnitX, 1.1f);
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, -0.9f);
controller.SetBodyOrientation(initial);
controller.AttachAnimationRootMotionSource((_, frame) =>
frame.Orientation = delta);
controller.Update(ObjectTick, new MovementInput());
Quaternion expected = Quaternion.Normalize(initial * delta);
float alignment = MathF.Abs(Quaternion.Dot(
expected,
Quaternion.Normalize(controller.BodyOrientation)));
Assert.InRange(alignment, 0.99999f, 1.00001f);
Assert.True(MathF.Abs(Quaternion.Dot(
Quaternion.Normalize(delta * initial),
Quaternion.Normalize(controller.BodyOrientation))) < 0.999f);
}
[Fact]
public void Update_MultiQuantumAnimationFrames_ComposeInTemporalOrder()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
int sample = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
if (sample++ == 0)
{
frame.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
MathF.PI / 2f);
}
else
{
frame.Origin = new Vector3(0f, 0.1f, 0f);
}
});
MovementResult result = controller.Update(
PhysicsBody.MaxQuantum * 2f,
new MovementInput());
// The second local-forward displacement occurs after the first
// quarter-turn, so it moves north. Adding Origins as bare vectors
// would incorrectly move east.
Assert.Equal(start.X, result.Position.X, precision: 3);
Assert.Equal(start.Y + 0.1f, result.Position.Y, precision: 3);
Assert.Equal(MathF.PI / 2f, controller.Yaw, precision: 3);
}
[Fact]
public void Update_ExactMinQuantum_RetainsTimeWithoutAdvancingObject()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
advances++;
frame.Origin = new Vector3(0f, 1f, 0f);
});
MovementResult atThreshold = controller.Update(
PhysicsBody.MinQuantum,
new MovementInput());
Assert.Equal(start, atThreshold.Position);
Assert.Equal(0, advances);
controller.Update(0.001f, new MovementInput());
Assert.Equal(1, advances);
}
[Fact]
public void Update_LargeFrame_MatchesSeparateRetailObjectQuanta()
{
var combined = new PlayerMovementController(MakeFlatEngine());
var split = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
combined.SetPosition(start, 0x0001);
split.SetPosition(start, 0x0001);
int combinedHooks = 0;
int splitHooks = 0;
static void Advance(float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame frame)
{
frame.Origin = new Vector3(0f, dt * 2f, 0f);
frame.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
dt * 0.7f);
}
combined.AttachAnimationRootMotionSource(Advance, () => combinedHooks++);
split.AttachAnimationRootMotionSource(Advance, () => splitHooks++);
combined.Update(PhysicsBody.MaxQuantum * 2f, new MovementInput());
split.Update(PhysicsBody.MaxQuantum, new MovementInput());
split.Update(PhysicsBody.MaxQuantum, new MovementInput());
Assert.Equal(split.Position.X, combined.Position.X, precision: 5);
Assert.Equal(split.Position.Y, combined.Position.Y, precision: 5);
Assert.Equal(split.Position.Z, combined.Position.Z, precision: 5);
float alignment = MathF.Abs(Quaternion.Dot(
Quaternion.Normalize(split.BodyOrientation),
Quaternion.Normalize(combined.BodyOrientation)));
Assert.InRange(alignment, 0.99999f, 1.00001f);
Assert.Equal(2, combinedHooks);
Assert.Equal(2, splitHooks);
}
[Fact]
public void TickHidden_DoesNotAdvancePartArrayButStillProcessesHooks()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
int advances = 0;
int hookPasses = 0;
controller.AttachAnimationRootMotionSource(
(_, frame) =>
{
advances++;
frame.Origin = new Vector3(0f, 1f, 0f);
},
() => hookPasses++);
controller.TickHidden(ObjectTick);
Assert.Equal(0, advances);
Assert.Equal(1, hookPasses);
}
[Fact]
public void InvalidElapsed_VisibleFrameIsPurePresentationRead()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
float initialTime = controller.SimTimeSeconds;
float initialYaw = controller.Yaw;
Vector3 initialPosition = controller.Position;
RawMotionState initialMotion = controller.Motion.RawState;
var hostileInput = new MovementInput(
Forward: true,
TurnLeft: true,
Jump: true,
Run: true);
foreach (float elapsed in new[]
{
float.NaN,
float.PositiveInfinity,
float.NegativeInfinity,
-0.1f,
0f,
})
{
MovementResult result = controller.Update(elapsed, hostileInput);
Assert.False(result.ShouldSendMovementEvent);
}
Assert.Equal(initialTime, controller.SimTimeSeconds);
Assert.Equal(initialYaw, controller.Yaw);
Assert.Equal(initialPosition, controller.Position);
Assert.Equal(initialMotion, controller.Motion.RawState);
controller.Update(ObjectTick, new MovementInput());
controller.Update(ObjectTick, new MovementInput());
Assert.True(controller.AdvancedObjectQuantumLastTick);
controller.Update(float.NaN, hostileInput);
Assert.False(controller.AdvancedObjectQuantumLastTick);
}
[Fact]
public void InvalidElapsed_HiddenFrameDoesNotAdvanceClockOrManagerTail()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
float initialTime = controller.SimTimeSeconds;
int targetPasses = 0;
controller.TickHidden(float.NaN, () => targetPasses++);
controller.TickHidden(float.PositiveInfinity, () => targetPasses++);
controller.TickHidden(-0.1f, () => targetPasses++);
Assert.Equal(initialTime, controller.SimTimeSeconds);
Assert.Equal(0, targetPasses);
Assert.False(controller.AdvancedObjectQuantumLastTick);
controller.TickHidden(ObjectTick);
controller.TickHidden(ObjectTick);
Assert.True(controller.AdvancedObjectQuantumLastTick);
controller.TickHidden(float.PositiveInfinity);
Assert.False(controller.AdvancedObjectQuantumLastTick);
}
[Fact]
public void Update_AirbornePartArrayFrame_SuppressesOriginButPreservesOrientation()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Update(1f, new MovementInput(Jump: true));
Vector3 beforeRelease = controller.Position;
Quaternion beforeOrientation = controller.BodyOrientation;
Quaternion delta = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.4f);
int advances = 0;
controller.AttachAnimationRootMotionSource((_, frame) =>
{
advances++;
frame.Origin = new Vector3(0f, 10f, 0f);
frame.Orientation = delta;
});
controller.Update(ObjectTick, new MovementInput(Jump: false));
Assert.True(controller.IsAirborne);
Assert.Equal(beforeRelease.X, controller.Position.X, precision: 5);
Assert.Equal(beforeRelease.Y, controller.Position.Y, precision: 5);
Quaternion expected = Quaternion.Normalize(beforeOrientation * delta);
float alignment = MathF.Abs(Quaternion.Dot(
expected,
Quaternion.Normalize(controller.BodyOrientation)));
Assert.InRange(alignment, 0.99999f, 1.00001f);
Assert.Equal(1, advances);
}
[Fact]
public void Update_AttachedAnimationTurn_IsNotAppliedByASecondYawIntegrator()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource((dt, frame) =>
{
frame.Orientation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ,
-(MathF.PI / 2f) * dt);
});
controller.Update(
ObjectTick,
new MovementInput(TurnRight: true));
Assert.Equal(
-(MathF.PI / 2f) * ObjectTick,
controller.Yaw,
precision: 4);
}
[Fact]
public void Update_SubQuantumFrame_InterpolatesRenderPositionWithoutAdvancingPhysicsPosition()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
var firstTick = controller.Update(ObjectTick, new MovementInput(Forward: true));
Assert.True(firstTick.Position.X > start.X, "Physics tick should advance the authoritative body position");
Assert.Equal(start.X, firstTick.RenderPosition.X, precision: 4);
var halfFrame = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
Assert.Equal(firstTick.Position.X, halfFrame.Position.X, precision: 4);
Assert.True(halfFrame.RenderPosition.X > start.X, "Render position should move between physics ticks");
Assert.True(halfFrame.RenderPosition.X < firstTick.Position.X,
$"Render X={halfFrame.RenderPosition.X} should stay between {start.X} and {firstTick.Position.X}");
float expectedMidpoint = start.X + ((firstTick.Position.X - start.X) * 0.5f);
Assert.Equal(expectedMidpoint, halfFrame.RenderPosition.X, precision: 3);
}
[Fact]
public void SetPosition_ResnapsRenderInterpolationEndpoints()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
controller.Update(ObjectTick, new MovementInput(Forward: true));
controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput(Forward: true));
var snapped = new Vector3(120f, 80f, 50f);
controller.SetPosition(snapped, 0x0001);
var result = controller.Update(PhysicsBody.MinQuantum * 0.5f, new MovementInput());
Assert.Equal(snapped, result.Position);
Assert.Equal(snapped, result.RenderPosition);
}
[Fact]
public void BlipPosition_ResnapsPoseWithoutStoppingActiveMotion()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
controller.Update(ObjectTick, new MovementInput(Forward: true));
Vector3 velocity = controller.BodyVelocity;
Assert.True(velocity.LengthSquared() > 0f);
var corrected = new Vector3(100f, 98f, 50f);
controller.BlipPosition(corrected, 0x0001, corrected);
Assert.Equal(corrected, controller.Position);
Assert.Equal(corrected, controller.RenderPosition);
Assert.Equal(velocity, controller.BodyVelocity);
}
[Fact]
public void BlipPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var world = new Vector3(150f, 193f, 50f);
var wireLocal = new Vector3(150f, 193f, 50f);
controller.BlipPosition(world, 0xA9B30038u, wireLocal);
Assert.Equal(0xA9B40031u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
Assert.Equal(new Vector3(150f, 1f, 50f), controller.CellPosition.Frame.Origin);
Assert.Equal(world, controller.Position);
}
[Fact]
public void TeleportPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var world = new Vector3(12f, 12f, 50f);
var wireLocal = new Vector3(12f, 12f, 50f);
controller.SetPosition(world, 0xA9B40031u, wireLocal);
Assert.Equal(0xA9B40001u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
Assert.Equal(wireLocal, controller.CellPosition.Frame.Origin);
}
[Fact]
public void Update_HugeQuantumDiscard_ResnapsRenderInterpolationEndpoints()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
int animationAdvances = 0;
int hookPasses = 0;
controller.AttachAnimationRootMotionSource(
(_, frame) =>
{
animationAdvances++;
frame.Origin = new Vector3(0f, 0.1f, 0f);
},
() => hookPasses++);
var moved = controller.Update(ObjectTick, new MovementInput(Forward: true));
int advancesBeforeDiscard = animationAdvances;
int hooksBeforeDiscard = hookPasses;
var stale = controller.Update(PhysicsBody.HugeQuantum + 0.1f, new MovementInput(Forward: true));
Assert.Equal(moved.Position.X, stale.Position.X, precision: 4);
Assert.Equal(stale.Position, stale.RenderPosition);
Assert.Equal(advancesBeforeDiscard, animationAdvances);
Assert.Equal(hooksBeforeDiscard, hookPasses);
}
[Fact]
public void Update_LeftoverAboveMinQuantum_ClampsRenderAlphaToCurrentPhysicsPosition()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
var result = controller.Update(
PhysicsBody.MaxQuantum + PhysicsBody.MinQuantum,
new MovementInput(Forward: true));
Assert.Equal(result.Position.X, result.RenderPosition.X, precision: 4);
Assert.Equal(result.Position.Y, result.RenderPosition.Y, precision: 4);
Assert.Equal(result.Position.Z, result.RenderPosition.Z, precision: 4);
}
[Fact]
public void Update_RunForward_MoveFasterThanWalk()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
var walkInput = new MovementInput { Forward = true };
var walkResult = controller.Update(1.0f, walkInput);
float walkDist = walkResult.Position.X - 96f;
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
var runInput = new MovementInput { Forward = true, Run = true };
var runResult = controller.Update(1.0f, runInput);
float runDist = runResult.Position.X - 96f;
Assert.True(runDist > walkDist, $"Run ({runDist}) should be faster than walk ({walkDist})");
}
[Fact]
public void Update_TurnInput_ChangesYaw()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
float initialYaw = controller.Yaw;
var input = new MovementInput { TurnRight = true };
controller.Update(0.5f, input);
Assert.NotEqual(initialYaw, controller.Yaw);
}
[Fact]
public void MotionStateChanged_WhenStartingToWalk()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// First frame: idle (no input).
controller.Update(0.016f, new MovementInput());
// Second frame: start walking.
var input = new MovementInput { Forward = true };
var result = controller.Update(0.016f, input);
Assert.True(result.MotionStateChanged);
}
[Fact]
public void Update_JumpOnFlatTerrain_BecomesAirborne()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// Charged jump: hold for a full charge (1s dt), then release to fire.
// A full charge gives enough Vz that the player clears the 0.05-unit
// ground-snap threshold within the same integration frame.
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
controller.Update(0.016f, new MovementInput(Jump: false)); // release → jump fires
Assert.True(controller.IsAirborne);
Assert.True(controller.VerticalVelocity > 0f);
}
[Fact]
public void PositionEventGateRequiresContactAndWalkable()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
Assert.True(controller.CanSendPositionEvent);
controller.Update(1.0f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: false));
Assert.True(controller.IsAirborne);
Assert.False(controller.CanSendPositionEvent);
}
[Fact]
public void JumpChargeSnapshot_TracksHeldChargeAndResetsOnRelease()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
Assert.Equal(default, controller.JumpCharge);
controller.Update(0.25f, new MovementInput(Jump: true));
Assert.True(controller.JumpCharge.IsCharging);
Assert.Equal(0.25f, controller.JumpCharge.Power, precision: 3);
controller.Update(0.016f, new MovementInput(Jump: false));
Assert.False(controller.JumpCharge.IsCharging);
Assert.Equal(0f, controller.JumpCharge.Power);
}
[Fact]
public void Update_AirborneFrames_ZRiseThenFalls()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
// Charged jump: hold for a full charge, then release.
controller.Update(1.0f, new MovementInput(Jump: true)); // full charge
controller.Update(0.016f, new MovementInput(Jump: false)); // release → jump fires
float z1 = controller.Position.Z;
// A few frames of rising
controller.Update(0.1f, new MovementInput());
float z2 = controller.Position.Z;
Assert.True(z2 > z1, "Should be rising");
// Many frames — should come back down.
// DefaultJumpVz = 10 m/s → full flight time ≈ 2.04s, so run 50 × 50ms = 2.5s
// to ensure the player has definitely landed.
for (int i = 0; i < 50; i++)
controller.Update(0.05f, new MovementInput());
Assert.False(controller.IsAirborne, "Should have landed");
Assert.Equal(50f, controller.Position.Z, precision: 1);
}
[Fact]
public void Update_WalkOffLedge_BecomesFalling()
{
// Build terrain with a sharp cliff: grid x<5 = Z50, grid x>=5 = Z20.
// heights[x*9+y] is indexed x-major; heightTable[i]=i*1f so
// byte value == Z value directly.
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = (byte)(x < 5 ? 50 : 20);
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
var engine = new PhysicsEngine();
var terrain = new TerrainSurface(heights, heightTable);
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(), worldOffsetX: 0f, worldOffsetY: 0f);
// Position the player just before the cliff edge (localX=118 ≈ grid x=4.92).
// At this point terrain Z is ~51.7 (bilinear interpolation near the high side).
// One step at walk speed will cross into the low region where terrain drops
// ~28 units — more than StepUpHeight=5, triggering the ledge-fall.
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(118f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // facing +X
// Single step — should trigger airborne state because terrain drops sharply.
controller.Update(0.05f, new MovementInput(Forward: true));
Assert.True(controller.IsAirborne, "Player should be airborne after stepping off the cliff");
// Simulate enough frames to fall and land on the Z=20 floor.
for (int i = 0; i < 60; i++)
controller.Update(0.05f, new MovementInput(Forward: true));
Assert.False(controller.IsAirborne, "Player should have landed");
Assert.Equal(20f, controller.Position.Z, precision: 1);
}
// ── Campaign P Slice P1 (2026-07-30): burden/stamina push ──────────────
[Fact]
public void SetCharacterBurden_PropagatesToTheWeenieAndGatesCanJump()
{
var controller = new PlayerMovementController(MakeFlatEngine());
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.CanJump(1.0f));
controller.SetCharacterBurden(2.5f);
Assert.False(weenie.CanJump(1.0f));
controller.SetCharacterBurden(0.5f);
Assert.True(weenie.CanJump(1.0f));
}
[Fact]
public void SetCharacterStamina_ZeroesEffectiveSkillOnTheWeenie()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetCharacterSkills(runSkill: 200, jumpSkill: 100);
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.InqRunRate(out float baseline));
controller.SetCharacterStamina(0);
Assert.True(weenie.InqRunRate(out float exhausted));
Assert.True(exhausted < baseline);
controller.SetCharacterStamina(-1);
Assert.True(weenie.InqRunRate(out float restored));
Assert.Equal(baseline, restored, precision: 4);
}
}