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:
parent
3a4782048e
commit
9355ddcec6
20 changed files with 1714 additions and 74 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue