test(physics): Phase W triage — fix stale Path6/tick-gate/ComputeOffset tests (behavior changed by L.3.2/L.4/L.5)

Four tests were asserting pre-change behavior after intentional production
changes:

#2 BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide
  b1af56e (L.4, 2026-04-30) added a steep-normal gate in Path 6 that
  fires BEFORE SetCollide. Airborne sphere hitting steep poly now returns
  Slid + Collide=false (slide-tangent interim fix). Updated assertion +
  renamed to ReturnsSlid.

#7 PlayerMovementControllerTests.Update_ForwardInput_MovesInFacingDirection
#8 DispatcherToMovementIntegrationTests.Dispatcher_W_held_produces_forward_motion
  235de33 (L.5, 2026-04-30) added _physicsAccum accumulator gate: a single
  Update(1.0f) only integrates one MaxQuantum (0.1s ~ 0.312m at walk speed),
  not the full 1s. Time is carried in accumulator (not dropped). Fixed both
  tests to loop Update(MaxQuantum) for ~11 ticks to accumulate >2m of real
  forward motion, preserving the original distance-threshold assertion intent.

#9 PositionManagerTests.ComputeOffset_BothActive_Combined
  842dfcd (L.3.2, 2026-05-03) changed ComputeOffset from additive
  (rootMotion + correction) to replace semantics: when AdjustOffset returns
  non-zero, it REPLACES root motion (retail Frame::operator= semantics).
  offset.Y = 0 (not 0.4); root motion is dropped when catch-up engages.
  Updated assertion and renamed to CorrectionReplacesRootMotion.

Suite: 9 failures → 5 (only the 5 known-bug tests remain red).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-02 16:43:02 +02:00
parent 21609a7cd7
commit 4bc99fc6fd
4 changed files with 48 additions and 19 deletions

View file

@ -100,7 +100,14 @@ public class DispatcherToMovementIntegrationTests
Assert.True(input.Forward);
Assert.False(input.Run);
var result = controller.Update(1.0f, input);
// L.5 physics-tick gate (235de33, 2026-04-30): Update() integrates
// only one MaxQuantum (~0.1s) physics step per call, matching
// retail's 30Hz physics gate. Drive the controller one MaxQuantum
// at a time for ~1s to accumulate real forward motion.
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);
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
}

View file

@ -42,10 +42,19 @@ public class PlayerMovementControllerTests
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 };
var result = controller.Update(1.0f, input); // 1 second
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 ~4 units in +X (walk speed).
// 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");
}

View file

@ -396,14 +396,18 @@ public class BSPStepUpTests
/// <summary>
/// Airborne mover descending toward a steep slope (normal.Z &lt; FloorZ):
/// Path 6 should still set the Collide flag (it fires for any polygon hit,
/// walkable or not).
/// Path 6 returns <see cref="TransitionState.Slid"/> and does NOT set
/// the Collide flag — the steep-normal slide-tangent branch (L.4,
/// commit b1af56e, 2026-04-30) intercepts the hit before SetCollide is
/// called and projects the move along the steep face instead, keeping the
/// body airborne with the falling animation.
///
/// <para>Retail: set_collide fires unconditionally when sphere_intersects_poly
/// hits; the walkable check happens later in the Collide-flag handler.</para>
/// <para>This is a documented intentional deviation from retail (retail calls
/// set_collide unconditionally; our interim port uses slide-tangent while
/// the retail step_up_slide / cliff_slide chain port is completed).</para>
/// </summary>
[Fact]
public void C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide()
public void C3_Path6_AirborneMoverHitsSteepSlope_ReturnsSlid()
{
var (root, resolved) = BSPStepUpFixtures.SlopedUnwalkable();
@ -423,11 +427,13 @@ public class BSPStepUpTests
root, resolved, t, localSphere, null,
currPos, Vector3.UnitZ, 1.0f);
// After L.2.2: Collide flag set, Adjusted returned.
// Currently: Slid (wall-slide).
Assert.Equal(TransitionState.Adjusted, result);
Assert.True(t.SpherePath.Collide,
"Expected Collide flag set when airborne sphere hits slope (L.2.2)");
// L.4 slide-tangent (b1af56e, 2026-04-30): steep polygon hit by
// airborne sphere returns Slid (not Adjusted) and does NOT set
// the Collide flag — the into-wall displacement is removed and
// CollisionNormal/SlidingNormal are set instead.
Assert.Equal(TransitionState.Slid, result);
Assert.False(t.SpherePath.Collide,
"Collide must NOT be set when the L.4 steep-slope slide-tangent fires");
}
// =========================================================================

View file

@ -120,11 +120,18 @@ public sealed class PositionManagerTests
}
// =========================================================================
// Test 5: both sources active — combined delta
// Test 5: both sources active — correction REPLACES root motion
//
// retail-faithful semantics (842dfcd, L.3.2, 2026-05-03):
// when InterpolationManager.AdjustOffset returns a non-zero correction,
// ComputeOffset returns the correction alone — it does NOT add root
// motion on top. Mirrors retail's PositionManager::adjust_offset
// (acclient @ 0x00555190) which calls Frame::operator= to OVERWRITE
// the rootOffset frame when catch-up engages.
// =========================================================================
[Fact]
public void ComputeOffset_BothActive_Combined()
public void ComputeOffset_BothActive_CorrectionReplacesRootMotion()
{
var pm = Make();
var interp = new InterpolationManager();
@ -132,9 +139,9 @@ public sealed class PositionManagerTests
// Enqueue target 1m ahead on +X
interp.Enqueue(new Vector3(1f, 0f, 0f), heading: 0f, isMovingTo: false);
// rootMotion = (0, 4, 0) × 0.1 = (0, 0.4, 0)
// correction ≈ (0.8, 0, 0)
// combined ≈ (0.8, 0.4, 0)
// correction ≈ (0.8, 0, 0) — replaces root motion (0, 0.4, 0).
// retail-faithful: correction overwrites root motion, Y is dropped.
// (842dfcd, 2026-05-03: switched from additive to replace semantics)
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
@ -144,7 +151,7 @@ public sealed class PositionManagerTests
maxSpeed: 4f);
Assert.Equal(0.8f, offset.X, precision: 3);
Assert.Equal(0.4f, offset.Y, precision: 3);
Assert.Equal(0f, offset.Y, precision: 3); // root motion dropped — correction replaces
Assert.Equal(0f, offset.Z, precision: 3);
}