fix: airborne jump refusal fires at RELEASE, not press (user retail

gate; supersedes CH round-1 item A)

The user's retail description matches the decomp exactly:
charge_jump @0x005281c0 has NO grounded check - it refuses only 0x49
(CanJump encumbrance) and 0x48 (fallen/crouch-family forward commands).
Pressing jump while airborne begins the powerbar and charges normally.
The 0x24 "You can't jump while in the air" comes exclusively from the
RELEASE path (ClientCombatSystem::DoJump @0x0056B110 ->
CMotionInterp::jump -> jump_is_allowed, whose airborne 0x24 our port
already carries test-pinned). A charge held through landing executes a
normal jump on the grounded release.

PlayerMovementController's input orchestration now mirrors
CommenceJump/DoJump:
- Press edge: ChargeJump() decides; a refused charge (0x48/0x49)
  reports and never begins the bar (retail's jump_pending stays 0).
  The invented airborne press-edge 0x24 report (CH user-gate round 1
  item A - added when the press/release split was not yet known) is
  deleted; CommenceJump's in-air fallback text is unreachable with a
  faithful charge_jump.
- Hold: accumulates grounded OR airborne; leaving the ground mid-charge
  no longer force-fires the jump.
- Release: fires jump(); an airborne release refuses 0x24 there.

Tests: the round-1 press-edge test is replaced by two release-semantics
tests (airborne release reports once; held-through-landing grounded
release jumps silently). Runtime 1,619, App 4,987/3, Core jump family
159.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 08:31:22 +02:00
parent 88cdfdc3c7
commit 1390f9477d
2 changed files with 87 additions and 56 deletions

View file

@ -940,17 +940,24 @@ public class PlayerMovementControllerTests
Assert.Null(exception);
}
// ── Campaign CH user-gate round 1 (item A): airborne jump refusal ──────
// ── Airborne jump semantics (2026-08-14 user retail gate; supersedes CH
// user-gate round 1 item A's press-edge report) ─────────────────────────
//
// charge_jump @0x005281c0 has NO grounded check, so pressing jump while
// airborne CHARGES the bar normally; the 0x24 "You can't jump while in
// the air" fires only from the RELEASE path's jump_is_allowed
// (ClientCombatSystem::DoJump @0x0056B110 → CMotionInterp::jump). A
// charge held through landing executes a normal jump on release.
[Fact]
public void JumpPress_RisingEdgeWhileAirborne_ReportsCantJumpInAir_HeldOnlyOnce_NoneAfterLanding()
public void JumpPressWhileAirborne_ChargesSilently_AirborneReleaseReportsCantJumpInAir()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// Launch into the air with an ordinary charged jump — the fix must
// not touch this grounded charge/fire path at all.
// Launch into the air with an ordinary charged jump — the grounded
// charge/fire path stays untouched.
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);
@ -959,30 +966,51 @@ public class PlayerMovementControllerTests
var reported = new List<string>();
controller.OnInterfaceText = (text, _) => reported.Add(text);
// Rising edge while airborne: exactly one "You can't jump while in
// the air" report.
controller.Update(0.016f, new MovementInput(Jump: true));
var report = Assert.Single(reported);
Assert.Equal(ClientTextRefusals.CantJumpInAir, report);
// Holding the key across multiple further updates raises no
// additional reports.
controller.Update(0.016f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: true));
Assert.Single(reported);
// Release, then land.
controller.Update(0.016f, new MovementInput(Jump: false));
for (int i = 0; i < 60 && controller.IsAirborne; i++)
controller.Update(0.05f, new MovementInput());
Assert.False(controller.IsAirborne, "should have landed");
// Landing then pressing again while grounded raises none (the
// grounded charge succeeds normally for an unburdened character).
reported.Clear();
// Rising edge while airborne: NO report — the bar charges normally.
controller.Update(0.016f, new MovementInput(Jump: true));
Assert.Empty(reported);
Assert.True(controller.JumpCharge.IsCharging);
// Holding stays silent and keeps charging.
controller.Update(0.016f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: true));
Assert.Empty(reported);
// RELEASING while still airborne: exactly one 0x24 report, and no
// second launch.
Assert.True(controller.IsAirborne);
controller.Update(0.016f, new MovementInput(Jump: false));
var report = Assert.Single(reported);
Assert.Equal(ClientTextRefusals.CantJumpInAir, report);
Assert.False(controller.JumpCharge.IsCharging);
}
[Fact]
public void JumpChargedInAir_HeldThroughLanding_GroundedReleaseJumpsSilently()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
// First jump, then begin charging the NEXT jump while airborne.
controller.Update(1.0f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: false));
Assert.True(controller.IsAirborne);
var reported = new List<string>();
controller.OnInterfaceText = (text, _) => reported.Add(text);
// Hold jump through the landing.
for (int i = 0; i < 120 && controller.IsAirborne; i++)
controller.Update(0.05f, new MovementInput(Jump: true));
Assert.False(controller.IsAirborne, "should have landed while holding");
Assert.True(controller.JumpCharge.IsCharging, "charge survives the landing");
Assert.Empty(reported);
// Grounded release: the held charge executes a normal jump.
controller.Update(0.016f, new MovementInput(Jump: false));
Assert.Empty(reported);
Assert.True(controller.IsAirborne, "the held charge fires on grounded release");
}
// ── Campaign P Slice P5 (2026-07-30): ConstraintManager leash arming (#167) ──