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

129 lines
4.5 KiB
C#

using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Golden-value tables for retail <c>MovementSystem</c>
/// (docs/research/2026-07-30-stat-coupled-movement-pseudocode.md §6),
/// Campaign P Slice P1.
/// </summary>
public class MovementSystemTests
{
[Fact]
public void GetRunRate_Skill800Cap_Returns4Point5()
{
Assert.Equal(4.5f, MovementSystem.GetRunRate(0f, 800), precision: 5);
Assert.Equal(4.5f, MovementSystem.GetRunRate(0f, 999999), precision: 5);
}
[Fact]
public void GetRunRate_Skill200_MatchesFormula()
{
// (1.0 * (200/400 * 11) + 4) / 4 = (5.5 + 4) / 4 = 2.375
Assert.Equal(2.375f, MovementSystem.GetRunRate(0f, 200), precision: 3);
}
[Fact]
public void GetRunRate_Skill0_ReturnsBase()
{
Assert.Equal(1.0f, MovementSystem.GetRunRate(0f, 0), precision: 3);
}
[Theory]
[InlineData(0f, 1f)] // unencumbered
[InlineData(0.99f, 1f)] // just under 100%
[InlineData(1.0f, 1f)] // exactly 100% — still full effectiveness
[InlineData(1.5f, 0.5f)] // knee: halfway to 200%
[InlineData(2.0f, 0f)] // knee floor
[InlineData(3.0f, 0f)] // fully overloaded
public void GetRunRate_LoadKnees_ScaleLinearly(float burden, float expectedLoadMod)
{
// At runSkill=200: rate = (loadMod * 5.5 + 4) / 4.
float expected = (expectedLoadMod * 5.5f + 4f) / 4f;
Assert.Equal(expected, MovementSystem.GetRunRate(burden, 200), precision: 3);
}
[Fact]
public void GetJumpHeight_FullExtent_Skill100_MatchesFormula()
{
// height = 1.0 * (100/1400 * 22.2 + 0.05) * 1.0 = 1.636...
float expected = (100f / 1400f * 22.2f + 0.05f);
Assert.Equal(expected, MovementSystem.GetJumpHeight(0f, 100, 1.0f), precision: 3);
}
[Theory]
[InlineData(0f)]
[InlineData(0.5f)]
[InlineData(1.0f)]
public void GetJumpHeight_ExtentScalesLinearly(float extent)
{
float unscaled = 100f / 1400f * 22.2f + 0.05f;
float expected = System.Math.Max(unscaled * extent, 0.35f);
Assert.Equal(expected, MovementSystem.GetJumpHeight(0f, 100, extent), precision: 3);
}
[Fact]
public void GetJumpHeight_ClampsExtentAbove1()
{
Assert.Equal(
MovementSystem.GetJumpHeight(0f, 100, 1.0f),
MovementSystem.GetJumpHeight(0f, 100, 5.0f),
precision: 5);
}
[Fact]
public void GetJumpHeight_ClampsExtentBelow0()
{
Assert.Equal(0.35f, MovementSystem.GetJumpHeight(0f, 100, -3.0f), precision: 5);
}
[Fact]
public void GetJumpHeight_ZeroSkill_FloorsAt0Point35()
{
Assert.Equal(0.35f, MovementSystem.GetJumpHeight(0f, 0, 1.0f), precision: 5);
}
[Theory]
[InlineData(0f)]
[InlineData(1.0f)]
[InlineData(1.5f)]
[InlineData(2.0f)]
[InlineData(3.0f)]
public void GetJumpHeight_AtHeavyLoad_NeverGoesBelowFloor(float burden)
{
Assert.True(MovementSystem.GetJumpHeight(burden, 100, 1.0f) >= 0.35f);
}
[Theory]
// ceil((load + 0.5) * power * 8 + 2) — verbatim decomp, pc 696014-696024.
[InlineData(0f, 0f, 2)] // ceil((0+0.5)*0*8+2) = ceil(2) = 2
[InlineData(0f, 1f, 6)] // ceil((0+0.5)*1*8+2) = ceil(4+2) = 6
[InlineData(1f, 1f, 14)] // ceil((1+0.5)*1*8+2) = ceil(12+2) = 14
[InlineData(2f, 1f, 22)] // ceil((2+0.5)*1*8+2) = ceil(20+2) = 22
[InlineData(0.3f, 0.7f, 7)] // ceil((0.3+0.5)*0.7*8+2) = ceil(4.48+2) = ceil(6.48) = 7
public void JumpStaminaCost_NonPk_CeilsCorrectly(float burden, float power, int expected)
{
Assert.Equal(expected, MovementSystem.JumpStaminaCost(power, burden, pk: false));
}
[Fact]
public void JumpStaminaCost_Pk_UsesAceTiebreakerFormula()
{
// ACE tiebreaker (pk!=0 branch entirely dropped by BN): (power+1.0)*100.0
Assert.Equal(150, MovementSystem.JumpStaminaCost(0.5f, burden: 1f, pk: true));
Assert.Equal(100, MovementSystem.JumpStaminaCost(0f, burden: 1f, pk: true));
}
[Fact]
public void GetJumpPower_IsAlgebraicInverseOfJumpStaminaCost()
{
// Not consumed by P1 (ported for signature completeness); sanity-check
// the inverse relationship still holds for the non-pk formula shape.
float burden = 1f;
uint stamina = 20u;
float power = MovementSystem.GetJumpPower(stamina, burden, pk: false);
// (stamina - 2) / (burden*8 + 4) = 18 / 12 = 1.5
Assert.Equal(1.5f, power, precision: 3);
}
}