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>
194 lines
6.3 KiB
C#
194 lines
6.3 KiB
C#
using AcDream.Core.Physics;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Physics;
|
|
|
|
public class PlayerWeenieTests
|
|
{
|
|
[Fact]
|
|
public void InqRunRate_Skill200_ReturnsCorrectRate()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
Assert.True(pw.InqRunRate(out float rate));
|
|
Assert.Equal(2.375f, rate, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqRunRate_Skill800_ReturnsCap()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 800, jumpSkill: 100);
|
|
Assert.True(pw.InqRunRate(out float rate));
|
|
Assert.Equal(4.5f, rate, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqRunRate_Skill0_ReturnsBase()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 0, jumpSkill: 100);
|
|
Assert.True(pw.InqRunRate(out float rate));
|
|
Assert.Equal(1.0f, rate, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqJumpVelocity_FullExtent_Skill100()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 100, jumpSkill: 100);
|
|
Assert.True(pw.InqJumpVelocity(1.0f, out float vz));
|
|
// height = (100/1400) * 22.2 + 0.05 ≈ 1.636
|
|
// vz = sqrt(1.636 * 19.6) ≈ 5.663
|
|
Assert.Equal(5.663f, vz, precision: 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqJumpVelocity_HalfExtent_Skill100()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 100, jumpSkill: 100);
|
|
Assert.True(pw.InqJumpVelocity(0.5f, out float vz));
|
|
// height = (100/1400) * 22.2 * 0.5 + 0.05 ≈ 0.843
|
|
float expectedHeight = 1.0f * (100f / 1400f * 22.2f + 0.05f) * 0.5f;
|
|
float expectedVz = MathF.Sqrt(expectedHeight * 19.6f);
|
|
Assert.Equal(expectedVz, vz, precision: 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqJumpVelocity_ZeroSkill_ClampsToMinHeight()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 0, jumpSkill: 0);
|
|
Assert.True(pw.InqJumpVelocity(1.0f, out float vz));
|
|
// height = max(0.05 * 1.0, 0.35) = 0.35
|
|
// vz = sqrt(0.35 * 19.6) ≈ 2.619
|
|
Assert.Equal(MathF.Sqrt(0.35f * 19.6f), vz, precision: 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetBurdenMod_Unencumbered_Returns1()
|
|
{
|
|
Assert.Equal(1.0f, PlayerWeenie.GetBurdenMod(0f));
|
|
Assert.Equal(1.0f, PlayerWeenie.GetBurdenMod(0.5f));
|
|
Assert.Equal(1.0f, PlayerWeenie.GetBurdenMod(0.99f));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetBurdenMod_Overloaded_Returns0()
|
|
{
|
|
Assert.Equal(0.0f, PlayerWeenie.GetBurdenMod(2.0f));
|
|
Assert.Equal(0.0f, PlayerWeenie.GetBurdenMod(3.0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetBurdenMod_PartialBurden_LinearDecrease()
|
|
{
|
|
Assert.Equal(0.5f, PlayerWeenie.GetBurdenMod(1.5f), precision: 3);
|
|
Assert.Equal(0.75f, PlayerWeenie.GetBurdenMod(1.25f), precision: 3);
|
|
}
|
|
|
|
// ── Campaign P Slice P1 (2026-07-30): CanJump / JumpStaminaCost / ─────
|
|
// ── stamina-zeroing gate ────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void CanJump_DefaultUnencumbered_ReturnsTrue()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
Assert.True(pw.CanJump(1.0f));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0f)]
|
|
[InlineData(1.0f)]
|
|
[InlineData(1.99f)]
|
|
public void CanJump_BelowThreshold_ReturnsTrue(float burden)
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100, burden: burden);
|
|
Assert.True(pw.CanJump(1.0f));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(2.0f)]
|
|
[InlineData(2.5f)]
|
|
[InlineData(3.0f)]
|
|
public void CanJump_AtOrAboveThreshold_ReturnsFalse(float burden)
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100, burden: burden);
|
|
Assert.False(pw.CanJump(1.0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void CanJump_SetBurden_UpdatesGateLive()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
Assert.True(pw.CanJump(1.0f));
|
|
pw.SetBurden(2.5f);
|
|
Assert.False(pw.CanJump(1.0f));
|
|
pw.SetBurden(0.5f);
|
|
Assert.True(pw.CanJump(1.0f));
|
|
}
|
|
|
|
[Fact]
|
|
public void JumpStaminaCost_ReturnsRealNonzeroCost_AndAlwaysAffordable()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
Assert.True(pw.JumpStaminaCost(1.0f, out int cost));
|
|
// burden=0: ceil((0+0.5)*1*8+2) = 6 — no longer the pre-P1 zero stub.
|
|
Assert.Equal(6, cost);
|
|
}
|
|
|
|
[Fact]
|
|
public void JumpStaminaCost_ScalesWithBurden()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100, burden: 1.0f);
|
|
Assert.True(pw.JumpStaminaCost(1.0f, out int cost));
|
|
// ceil((1+0.5)*1*8+2) = 14
|
|
Assert.Equal(14, cost);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqRunRate_ZeroStamina_ZeroesEffectiveSkill()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
pw.SetStamina(0);
|
|
Assert.True(pw.InqRunRate(out float rate));
|
|
// skill forced to 0 -> base rate 1.0 (matches InqRunRate_Skill0_ReturnsBase).
|
|
Assert.Equal(1.0f, rate, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqRunRate_NonzeroStamina_UsesRealSkill()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
pw.SetStamina(50);
|
|
Assert.True(pw.InqRunRate(out float rate));
|
|
Assert.Equal(2.375f, rate, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqRunRate_UnsetStamina_NeverGates()
|
|
{
|
|
// No SetStamina call -> null sentinel -> matches every pre-P1 test's
|
|
// implicit expectation (no gating at all).
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
Assert.True(pw.InqRunRate(out float rate));
|
|
Assert.Equal(2.375f, rate, precision: 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void InqJumpVelocity_ZeroStamina_FloorsAt0Point35Meters()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
pw.SetStamina(0);
|
|
Assert.True(pw.InqJumpVelocity(1.0f, out float vz));
|
|
Assert.Equal(MathF.Sqrt(0.35f * 19.6f), vz, precision: 2);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetStamina_Null_RestoresUnknownSentinel()
|
|
{
|
|
var pw = new PlayerWeenie(runSkill: 200, jumpSkill: 100);
|
|
pw.SetStamina(0);
|
|
Assert.True(pw.InqRunRate(out float zeroedRate));
|
|
Assert.Equal(1.0f, zeroedRate, precision: 3);
|
|
|
|
pw.SetStamina(null);
|
|
Assert.True(pw.InqRunRate(out float restoredRate));
|
|
Assert.Equal(2.375f, restoredRate, precision: 3);
|
|
}
|
|
}
|