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

@ -0,0 +1,59 @@
using AcDream.Core.Items;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Retail <c>EncumbranceSystem</c> (0x004fcc00/40/70, Campaign P Slice P1) —
/// cross-checked 1:1 against the existing <see cref="BurdenMath"/>
/// implementation it delegates to (same formulas, same addresses).
/// </summary>
public class EncumbranceSystemTests
{
[Theory]
[InlineData(0, 0, 0)]
[InlineData(-5, 0, 0)]
[InlineData(100, 0, 15000)] // 150 * 100
[InlineData(100, 3, 24000)] // 150*100 + clamp(3*30,0,150)*100 = 15000 + 9000
[InlineData(100, 100, 30000)] // aug clamps at 150 bonus: 15000 + 150*100
public void EncumbranceCapacity_MatchesBurdenMath(int strength, int aug, int expected)
{
Assert.Equal(expected, EncumbranceSystem.EncumbranceCapacity(strength, aug));
Assert.Equal(
BurdenMath.EncumbranceCapacity(strength, aug),
EncumbranceSystem.EncumbranceCapacity(strength, aug));
}
[Theory]
[InlineData(0, 0, 0f)]
[InlineData(1000, 500, 0.5f)]
[InlineData(1000, 1000, 1.0f)]
[InlineData(1000, 2000, 2.0f)]
public void Load_MatchesBurdenMath(int capacity, int burden, float expected)
{
Assert.Equal(expected, EncumbranceSystem.Load(capacity, burden), precision: 4);
Assert.Equal(
BurdenMath.LoadRatio(capacity, burden),
EncumbranceSystem.Load(capacity, burden),
precision: 5);
}
[Theory]
[InlineData(0f, 1f)]
[InlineData(0.99f, 1f)]
[InlineData(1.0f, 1f)]
[InlineData(1.25f, 0.75f)]
[InlineData(1.5f, 0.5f)]
[InlineData(1.75f, 0.25f)]
[InlineData(2.0f, 0f)]
[InlineData(3.0f, 0f)]
public void LoadMod_KneesAt100And200Percent(float load, float expected)
{
Assert.Equal(expected, EncumbranceSystem.LoadMod(load), precision: 4);
Assert.Equal(
BurdenMath.LoadModifier(load),
EncumbranceSystem.LoadMod(load),
precision: 5);
}
}

View file

@ -0,0 +1,129 @@
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);
}
}

View file

@ -81,4 +81,114 @@ public class PlayerWeenieTests
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);
}
}

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

View file

@ -826,4 +826,39 @@ public class PlayerMovementControllerTests
Assert.False(controller.IsAirborne, "Player should have landed");
Assert.Equal(20f, controller.Position.Z, precision: 1);
}
// ── Campaign P Slice P1 (2026-07-30): burden/stamina push ──────────────
[Fact]
public void SetCharacterBurden_PropagatesToTheWeenieAndGatesCanJump()
{
var controller = new PlayerMovementController(MakeFlatEngine());
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.CanJump(1.0f));
controller.SetCharacterBurden(2.5f);
Assert.False(weenie.CanJump(1.0f));
controller.SetCharacterBurden(0.5f);
Assert.True(weenie.CanJump(1.0f));
}
[Fact]
public void SetCharacterStamina_ZeroesEffectiveSkillOnTheWeenie()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetCharacterSkills(runSkill: 200, jumpSkill: 100);
IWeenieObject weenie = controller.Motion.WeenieObj!;
Assert.True(weenie.InqRunRate(out float baseline));
controller.SetCharacterStamina(0);
Assert.True(weenie.InqRunRate(out float exhausted));
Assert.True(exhausted < baseline);
controller.SetCharacterStamina(-1);
Assert.True(weenie.InqRunRate(out float restored));
Assert.Equal(baseline, restored, precision: 4);
}
}

View file

@ -193,6 +193,127 @@ public sealed class RuntimeCharacterStateTests
Assert.Equal(-1, state.MovementSkills.JumpSkill);
}
// ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ───
// ── run/jump skill (pseudocode doc §9) ─────────────────────────────
[Fact]
public void UpdateMovementSkillBase_NoEnchantments_PushesBaseUnchanged()
{
using var state = new RuntimeCharacterState();
state.UpdateMovementSkillBase(runSkillBase: 210, jumpSkillBase: 165);
Assert.Equal(210, state.MovementSkills.RunSkill);
Assert.Equal(165, state.MovementSkills.JumpSkill);
}
[Fact]
public void UpdateMovementSkillBase_VitaeActive_AppliesMultiplierToPushedSkill()
{
SpellTable table = SpellTableWith((1u, "Vitae", 0u));
using var state = new RuntimeCharacterState(table);
state.Spellbook.OnEnchantmentAdded(MakeVitae(spellId: 1u, val: 0.9f));
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
// CEnchantmentRegistry::EnchantSkill applies vitae first: 200*0.9=180.
Assert.Equal(180, state.MovementSkills.RunSkill);
Assert.Equal(90, state.MovementSkills.JumpSkill);
}
[Fact]
public void EnchantmentsChanged_AfterBaseAlreadyPushed_RecomputesWithoutFreshBase()
{
SpellTable table = SpellTableWith((1u, "Vitae", 0u));
using var state = new RuntimeCharacterState(table);
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
Assert.Equal(200, state.MovementSkills.RunSkill);
// A vitae buff lands mid-session — WITHOUT a fresh PD skill push —
// and the produced run skill must still move (pseudocode doc §9's
// "Spellbook.EnchantmentsChanged -> RecomputeMovementSkills" wire).
state.Spellbook.OnEnchantmentAdded(MakeVitae(spellId: 1u, val: 0.95f));
Assert.Equal(190, state.MovementSkills.RunSkill);
}
[Fact]
public void EnchantmentsChanged_SkillSpecificBuff_AppliesToMatchingSkillOnly()
{
SpellTable table = SpellTableWith((77u, "Run Buff", 0u));
using var state = new RuntimeCharacterState(table);
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
state.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 77u,
LayerId: 1u,
Duration: 60f,
CasterGuid: 0u,
StatModType: EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: RuntimeCharacterState.RunSkillId,
StatModValue: 1.5f,
Bucket: 1u));
Assert.Equal(300, state.MovementSkills.RunSkill); // 200 * 1.5
Assert.Equal(100, state.MovementSkills.JumpSkill); // untouched
}
[Fact]
public void ResetSession_ClearsBurdenStaminaAndSkillBase()
{
using var state = new RuntimeCharacterState();
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
state.MovementSkills.UpdateBurden(1.5f);
state.MovementSkills.UpdateStamina(0);
state.ResetSession();
Assert.Equal(-1, state.MovementSkills.RunSkill);
Assert.Equal(-1, state.MovementSkills.JumpSkill);
Assert.Equal(0f, state.MovementSkills.Burden);
Assert.Equal(-1, state.MovementSkills.CurrentStamina);
Assert.True(state.CaptureOwnership().MovementSkillsAreReset);
// A fresh base push after reset must not still carry the pre-reset
// vitae/enchantment adjustment (spellbook was cleared too).
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
Assert.Equal(200, state.MovementSkills.RunSkill);
}
[Fact]
public void CaptureOwnership_BurdenOrStaminaLeftoverBreaksMovementSkillsReset()
{
using var state = new RuntimeCharacterState();
Assert.True(state.CaptureOwnership().MovementSkillsAreReset);
state.MovementSkills.UpdateBurden(0.5f);
Assert.False(state.CaptureOwnership().MovementSkillsAreReset);
state.MovementSkills.UpdateBurden(0f);
Assert.True(state.CaptureOwnership().MovementSkillsAreReset);
state.MovementSkills.UpdateStamina(80);
Assert.False(state.CaptureOwnership().MovementSkillsAreReset);
}
private static ActiveEnchantmentRecord MakeVitae(uint spellId, float val) =>
new(
spellId, LayerId: 0u, Duration: -1f, CasterGuid: 0u,
StatModType: 0u, StatModKey: 0u, StatModValue: val, Bucket: 4u);
private static SpellTable SpellTableWith(
params (uint id, string name, uint family)[] rows)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine("Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10");
foreach ((uint id, string name, uint family) in rows)
{
sb.Append(id).Append(',').Append("0x").Append(id.ToString("X")).Append(',')
.Append(name).Append(",0,0x0,1,1,").Append(family).Append(",0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0")
.AppendLine();
}
return SpellTable.LoadFromReader(new System.IO.StringReader(sb.ToString()));
}
[Fact]
public void CharacterViewBorrowsExactOwnersWithoutReconstructedState()
{

View file

@ -5,6 +5,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Runtime.Session;
@ -180,6 +181,111 @@ public sealed class LiveSessionEventRouterTests
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
}
// ── Campaign P Slice P1 (2026-07-30): burden/stamina recompute wiring ─
[Fact]
public void ObjectTablePropertyChange_RecomputesAndPushesBurden()
{
using var session = NewSession();
const uint playerGuid = 0x50000001u;
var objects = new ClientObjectTable();
var character = new RuntimeCharacterState();
int movementStatsUpdated = 0;
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
new LiveInventorySessionBindings(
objects,
PlayerGuid: () => playerGuid,
OnShortcuts: null,
OnUseDone: null,
ItemMana: new ItemManaState(),
ExternalContainers: new ExternalContainerState()),
new LiveCharacterSessionBindings(
new CombatState(),
character,
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: null,
OnConfirmationDone: null,
ClientTime: () => 0d,
OnMovementStatsUpdated: () => movementStatsUpdated++),
NewSocialBindings());
router.Attach();
// Strength=100, no augmentation -> capacity=15000 (BurdenMath).
character.LocalPlayer.OnAttributeUpdate(
atType: 1u, ranks: 90u, start: 10u, xp: 0u);
Assert.Equal(1, movementStatsUpdated);
Assert.Equal(0f, character.MovementSkills.Burden, precision: 4);
// EncumbranceVal (property 5) = 7500 -> load = 7500/15000 = 0.5.
var props = new PropertyBundle();
props.Ints[(uint)PropertyInt.EncumbranceVal] = 7500;
objects.UpsertProperties(playerGuid, props);
Assert.True(movementStatsUpdated >= 2);
Assert.Equal(0.5f, character.MovementSkills.Burden, precision: 4);
router.Dispose();
}
[Fact]
public void StaminaVitalChange_PushesCurrentStamina()
{
using var session = NewSession();
var character = new RuntimeCharacterState();
int movementStatsUpdated = 0;
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
NewInventoryBindings(),
new LiveCharacterSessionBindings(
new CombatState(),
character,
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: null,
OnConfirmationDone: null,
ClientTime: () => 0d,
OnMovementStatsUpdated: () => movementStatsUpdated++),
NewSocialBindings());
router.Attach();
Assert.Equal(-1, character.MovementSkills.CurrentStamina);
character.LocalPlayer.OnVitalUpdate(
vitalId: 8u, ranks: 40u, start: 20u, xp: 0u, current: 45u);
Assert.Equal(1, movementStatsUpdated);
Assert.Equal(45, character.MovementSkills.CurrentStamina);
router.Dispose();
}
private static LiveEntitySessionSink NoOpEntitySink() => new(
Spawned: _ => { },
Deleted: _ => { },
PickedUp: _ => { },
MotionUpdated: _ => { },
PositionUpdated: _ => { },
VectorUpdated: _ => { },
StateUpdated: _ => { },
ParentUpdated: _ => { },
TeleportStarted: _ => { },
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
PlayPhysicsScriptType: _ => { });
private static LiveEnvironmentSessionSink NoOpEnvironmentSink() => new(
EnvironChanged: _ => { },
ServerTimeUpdated: _ => { });
private static LiveSessionEventRouter NewRouter(
WorldSession session,
Counters counters,