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

@ -3,7 +3,9 @@ using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Social;
using AcDream.Core.Spells;
using AcDream.Runtime.Gameplay;
@ -44,7 +46,14 @@ public sealed record LiveCharacterSessionBindings(
Action<int, int>? OnSkillsUpdated,
Action<GameEvents.CharacterConfirmationRequest>? OnConfirmationRequest,
Action<GameEvents.CharacterConfirmationDone>? OnConfirmationDone,
Func<double>? ClientTime);
Func<double>? ClientTime,
// Campaign P Slice P1 (2026-07-30): fires after MovementSkills' burden,
// stamina, OR (vitae/enchantment-adjusted) skill values change mid-
// session — the reactive re-apply-to-the-live-controller seam, mirroring
// OnSkillsUpdated's existing shape. Optional/nullable so every existing
// caller (including Headless's OnSkillsUpdated: null pattern) compiles
// unchanged.
Action? OnMovementStatsUpdated = null);
public sealed record LiveSocialSessionBindings(
ChatLog Chat,
@ -153,10 +162,15 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
social.TurbineChat,
onSkillsUpdated: (runSkill, jumpSkill) =>
{
character.Character.MovementSkills.Update(
// Campaign P Slice P1 (2026-07-30): route the PD/skill
// base through the vitae/enchantment-adjusted recompute
// (CEnchantmentRegistry::EnchantSkill) instead of writing
// MovementSkills directly — see the pseudocode doc §9.
character.Character.UpdateMovementSkillBase(
runSkill,
jumpSkill);
character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill);
character.OnMovementStatsUpdated?.Invoke();
},
resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus,
onShortcuts: inventory.OnShortcuts,
@ -174,6 +188,52 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
externalContainers: inventory.ExternalContainers,
accepting: IsAccepting));
ConstructionCheckpoint();
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
// the SAME event set IndicatorBarController.UpdateBurden already
// reacts to (Strength + augmentation property 0xE6 +
// EncumbranceVal property 5, falling back to SumCarriedBurden).
// See the pseudocode doc §9.
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectAdded += h,
h => inventory.Objects.ObjectAdded -= h,
() => RecomputeBurden(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectUpdated += h,
h => inventory.Objects.ObjectUpdated -= h,
() => RecomputeBurden(inventory, character));
SubscribeToRecompute<ClientObject>(
h => inventory.Objects.ObjectRemoved += h,
h => inventory.Objects.ObjectRemoved -= h,
() => RecomputeBurden(inventory, character));
SubscribeToRecompute<ClientObjectMove>(
h => inventory.Objects.ObjectMoved += h,
h => inventory.Objects.ObjectMoved -= h,
() => RecomputeBurden(inventory, character));
SubscribeToRecompute<uint>(
h => inventory.Objects.ContainerContentsReplaced += h,
h => inventory.Objects.ContainerContentsReplaced -= h,
() => RecomputeBurden(inventory, character));
SubscribeParameterless(
h => inventory.Objects.Cleared += h,
h => inventory.Objects.Cleared -= h,
() => RecomputeBurden(inventory, character));
Subscribe<LocalPlayerState.AttributeKind>(
h => character.Character.LocalPlayer.AttributeChanged += h,
h => character.Character.LocalPlayer.AttributeChanged -= h,
kind =>
{
if (kind == LocalPlayerState.AttributeKind.Strength)
RecomputeBurden(inventory, character);
});
// Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's
// stamina==0 effective-skill-zeroing gate (pseudocode doc §5).
Subscribe<LocalPlayerState.VitalKind>(
h => character.Character.LocalPlayer.Changed += h,
h => character.Character.LocalPlayer.Changed -= h,
kind => RecomputeStamina(kind, character));
_subscriptions.Add(new CombatChatTranslator(
character.Combat,
social.Chat,
@ -259,6 +319,89 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
ConstructionCheckpoint();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): a payload-typed event whose ONLY
/// job is "something relevant changed, recompute" — thin wrapper over
/// <see cref="Subscribe{T}"/> that discards the payload.
/// </summary>
private void SubscribeToRecompute<T>(
Action<Action<T>> attach,
Action<Action<T>> detach,
Action recompute) =>
Subscribe(attach, detach, (T _) => recompute());
/// <summary>
/// Campaign P Slice P1 (2026-07-30): the parameterless-event analogue of
/// <see cref="Subscribe{T}"/> (<c>ClientObjectTable.Cleared</c> carries
/// no payload).
/// </summary>
private void SubscribeParameterless(
Action<Action> attach,
Action<Action> detach,
Action sink)
{
Action handler = () =>
{
if (Volatile.Read(ref _accepting) != 0)
sink();
};
attach(handler);
_subscriptions.Add(() => detach(handler));
ConstructionCheckpoint();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): retail <c>CACQualities::InqLoad</c>
/// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal
/// property 5, falling back to the summed carried burden) — the SAME
/// input assembly <c>IndicatorBarController.UpdateBurden</c> /
/// <c>InventoryController.RefreshBurden</c> already use for the burden
/// HUD. See the pseudocode doc §2/§9.
/// </summary>
private static void RecomputeBurden(
LiveInventorySessionBindings inventory,
LiveCharacterSessionBindings character)
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
int strength = (int)(character.Character.LocalPlayer
.GetAttribute(LocalPlayerState.AttributeKind.Strength)?.Current ?? 0u);
int aug = playerObject?.Properties.GetInt(
(uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0;
int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug);
int burden = playerObject is not null
&& playerObject.Properties.Ints.TryGetValue(
(uint)PropertyInt.EncumbranceVal, out int wireBurden)
? wireBurden
: inventory.Objects.SumCarriedBurden(player);
float load = EncumbranceSystem.Load(capacity, burden);
character.Character.MovementSkills.UpdateBurden(load);
character.OnMovementStatsUpdated?.Invoke();
}
/// <summary>
/// Campaign P Slice P1 (2026-07-30): pushes current-stamina vital
/// changes into <see cref="RuntimeMovementSkillState"/> — feeds
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>'s stamina==0
/// effective-skill-zeroing gate (pseudocode doc §5).
/// </summary>
private static void RecomputeStamina(
LocalPlayerState.VitalKind kind,
LiveCharacterSessionBindings character)
{
if (kind != LocalPlayerState.VitalKind.Stamina) return;
if (character.Character.LocalPlayer.Get(LocalPlayerState.VitalKind.Stamina)
is not LocalPlayerState.VitalSnapshot stamina)
{
return;
}
character.Character.MovementSkills.UpdateStamina((int)stamina.Current);
character.OnMovementStatsUpdated?.Invoke();
}
private void ConstructionCheckpoint() =>
_constructionCheckpoint?.Invoke(++_constructionStep);