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>
This commit is contained in:
Erik 2026-07-30 08:18:06 +02:00
parent 3a4782048e
commit 9355ddcec6
20 changed files with 1714 additions and 74 deletions

View file

@ -228,6 +228,108 @@ public sealed class EnchantmentMathTests
private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) =>
new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 4u);
// ── Campaign P Slice P1 (2026-07-30): GetSkillMod (run/jump skill ─────
// ── vitae/enchantment adjustment) ──────────────────────────────────
private static ActiveEnchantmentRecord MakeSkillMultRecord(
uint spellId, uint layer, uint skillId, float val) =>
new(
spellId, layer, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: skillId,
StatModValue: val,
Bucket: 1u);
private static ActiveEnchantmentRecord MakeSkillAddRecord(
uint spellId, uint layer, uint skillId, float val) =>
new(
spellId, layer, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: skillId,
StatModValue: val,
Bucket: 2u);
[Fact]
public void EnchantmentTypeFlag_Skill_MatchesAceEnchantmentTypeFlags()
{
// ACE.Entity.Enum.EnchantmentTypeFlags.Skill = 0x0000010.
Assert.Equal(0x0000010u, EnchantmentMath.EnchantmentTypeFlag.Skill);
Assert.Equal(0x0000002u, EnchantmentMath.EnchantmentTypeFlag.SecondAtt);
}
[Fact]
public void GetSkillMod_Empty_ReturnsIdentity()
{
var mod = EnchantmentMath.GetSkillMod(
Array.Empty<ActiveEnchantmentRecord>(), SpellTable.Empty, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetSkillMod_MultiplicativeSkillBuff_AppliesWhenSkillIdMatches()
{
var table = LoadTable((50u, "Run Buff", 400u));
var enchantments = new[] { MakeSkillMultRecord(50, 1, skillId: 24u, val: 1.2f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(1.2f, mod.Multiplier, precision: 4);
}
[Fact]
public void GetSkillMod_AdditiveSkillBuff_AppliesWhenSkillIdMatches()
{
var table = LoadTable((51u, "Jump Buff", 401u));
var enchantments = new[] { MakeSkillAddRecord(51, 1, skillId: 22u, val: 15f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 22u);
Assert.Equal(15.0f, mod.Additive, precision: 4);
}
[Fact]
public void GetSkillMod_SkillIdMismatch_DoesNotContribute()
{
var table = LoadTable((52u, "Melee Buff", 402u));
// Buff targets skill id 7 (some melee skill), we ask for Run (24).
var enchantments = new[] { MakeSkillMultRecord(52, 1, skillId: 7u, val: 1.5f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetSkillMod_NamespaceCollision_VitalTypedRecordDoesNotLeakIntoSkillQuery()
{
// A vital-typed (SecondAtt) buff whose numeric StatModKey happens to
// equal a skill id (24=Run) must NOT contribute to a skill query —
// the whole point of the type-flag filter (pseudocode doc §9).
var table = LoadTable((53u, "Vital Buff", 403u));
var enchantments = new[]
{
new ActiveEnchantmentRecord(
53u, 1u, 60f, 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: 24u,
StatModValue: 1.5f,
Bucket: 1u),
};
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
// The SAME record DOES contribute to a vitals query with no type
// filter (GetMod's pre-P1, unmodified default behavior).
var vitalMod = EnchantmentMath.GetMod(enchantments, table, statKey: 24u);
Assert.Equal(1.5f, vitalMod.Multiplier, precision: 4);
}
[Fact]
public void GetSkillMod_Vitae_AppliesUnconditionallyLikeVitals()
{
// CEnchantmentRegistry::EnchantSkill applies _vitae in the IDENTICAL
// position as EnchantAttribute2nd (pseudocode doc §5) — reusing the
// SAME Bucket==4 handling, unconditional on the type-flag filter.
var table = LoadTable((54u, "Vitae", 0u));
var enchantments = new[] { MakeVitaeRecord(54, 0, statKey: 0u, val: 0.9f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(0.9f, mod.Multiplier, precision: 3);
}
private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows)
{
// Build a synthetic CSV with just enough columns for SpellTable to