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
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue