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
|
|
@ -76,11 +76,20 @@ public static class EnchantmentMath
|
|||
/// (only one buff per <see cref="SpellMetadata.Family"/> wins).</param>
|
||||
/// <param name="statKey">Target stat key (ACE
|
||||
/// <c>PropertyAttribute2nd</c> enum value: 1=MaxHealth,
|
||||
/// 3=MaxStamina, 5=MaxMana).</param>
|
||||
/// 3=MaxStamina, 5=MaxMana — or a Skill id when
|
||||
/// <paramref name="requiredStatModTypeFlag"/> is
|
||||
/// <see cref="EnchantmentTypeFlag.Skill"/>).</param>
|
||||
/// <param name="requiredStatModTypeFlag">When set, a record's
|
||||
/// <c>StatModType</c> must carry this flag bit to be considered a
|
||||
/// candidate — disambiguates namespaces that share numeric keys (e.g.
|
||||
/// vital key 5=MaxMana vs skill id 5). <c>null</c> (default) preserves
|
||||
/// the original vitals behavior with no type check, unchanged from
|
||||
/// before Campaign P.</param>
|
||||
public static VitalMod GetMod(
|
||||
IEnumerable<ActiveEnchantmentRecord> enchantments,
|
||||
SpellTable table,
|
||||
uint statKey)
|
||||
uint statKey,
|
||||
uint? requiredStatModTypeFlag = null)
|
||||
{
|
||||
// Family-stacking: bucket the active enchantments by Family and
|
||||
// keep the strongest one per bucket (the one with the largest
|
||||
|
|
@ -129,8 +138,20 @@ public static class EnchantmentMath
|
|||
continue;
|
||||
}
|
||||
|
||||
// Campaign P (2026-07-30): an optional type-flag gate
|
||||
// disambiguates numeric-key collisions across namespaces (e.g.
|
||||
// vital key 5=MaxMana vs skill id 5) — mirrors retail's
|
||||
// CullEnchantmentsFromList `category` argument (2 for
|
||||
// Attribute2nd, 0x10=Skill for EnchantSkill).
|
||||
if (requiredStatModTypeFlag is uint typeFlag
|
||||
&& (ench.StatModType is not uint recordType
|
||||
|| (recordType & typeFlag) == 0))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multiplicative + Additive buffs filter by stat key —
|
||||
// only those targeting the requested vital contribute.
|
||||
// only those targeting the requested vital/skill contribute.
|
||||
if (ench.StatModKey is not uint key || key != statKey) continue;
|
||||
switch (ench.Bucket)
|
||||
{
|
||||
|
|
@ -147,6 +168,35 @@ public static class EnchantmentMath
|
|||
: new VitalMod(multiplier, additive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30) — Skill-namespace convenience over
|
||||
/// <see cref="GetMod"/>, matching retail <c>CEnchantmentRegistry::
|
||||
/// EnchantSkill</c> (0x005947b0): vitae applies unconditionally (same
|
||||
/// as vitals), multiplicative/additive skill buffs are filtered to
|
||||
/// records whose <c>StatModType</c> carries
|
||||
/// <see cref="EnchantmentTypeFlag.Skill"/> AND whose <c>StatModKey</c>
|
||||
/// equals <paramref name="skillId"/> (ACE Skill enum ordinal — Run=24,
|
||||
/// Jump=22). Scoped to the run/jump query path per the P1 plan; not a
|
||||
/// general effective-skill engine.
|
||||
/// </summary>
|
||||
public static VitalMod GetSkillMod(
|
||||
IEnumerable<ActiveEnchantmentRecord> enchantments,
|
||||
SpellTable table,
|
||||
uint skillId) =>
|
||||
GetMod(enchantments, table, skillId, EnchantmentTypeFlag.Skill);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>EnchantmentTypeFlags</c> bits relevant to disambiguating
|
||||
/// <see cref="GetMod"/>'s <c>statKey</c> namespace (ACE
|
||||
/// <c>ACE.Entity.Enum.EnchantmentTypeFlags</c>, cross-referenced —
|
||||
/// StatModType is a bitfield the wire already carries per-enchantment).
|
||||
/// </summary>
|
||||
public static class EnchantmentTypeFlag
|
||||
{
|
||||
public const uint SecondAtt = 0x0000002u;
|
||||
public const uint Skill = 0x0000010u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
|
||||
/// (verified against <c>docs/research/named-retail/acclient.h</c>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ public sealed class Spellbook
|
|||
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
|
||||
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
|
||||
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _vitalModCache = new();
|
||||
private readonly Dictionary<uint, EnchantmentMath.VitalMod> _skillModCache = new();
|
||||
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
|
||||
.Select(_ => new List<uint>()).ToArray();
|
||||
private readonly Dictionary<uint, uint> _desiredComponents = new();
|
||||
|
|
@ -102,6 +103,29 @@ public sealed class Spellbook
|
|||
return calculated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign P Slice P1 (2026-07-30) — combined vitae + skill-enchantment
|
||||
/// buff modifier for a Skill id (ACE Skill enum ordinal — Run=24,
|
||||
/// Jump=22), matching retail <c>CEnchantmentRegistry::EnchantSkill</c>
|
||||
/// (0x005947b0). Mirrors <see cref="GetVitalMod"/>'s caching shape;
|
||||
/// consumed by <c>AcDream.Runtime.Gameplay.RuntimeCharacterState</c>'s
|
||||
/// run/jump skill recompute.
|
||||
/// </summary>
|
||||
public EnchantmentMath.VitalMod GetSkillMod(uint skillId)
|
||||
{
|
||||
if (_skillModCache.TryGetValue(
|
||||
skillId,
|
||||
out EnchantmentMath.VitalMod cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
EnchantmentMath.VitalMod calculated =
|
||||
EnchantmentMath.GetSkillMod(ActiveEnchantments, _table, skillId);
|
||||
_skillModCache.Add(skillId, calculated);
|
||||
return calculated;
|
||||
}
|
||||
|
||||
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
|
||||
public event Action<uint>? SpellLearned;
|
||||
|
||||
|
|
@ -356,6 +380,7 @@ public sealed class Spellbook
|
|||
_activeById.Clear();
|
||||
_enchantmentOrderByBucket.Clear();
|
||||
_vitalModCache.Clear();
|
||||
_skillModCache.Clear();
|
||||
foreach (ActiveEnchantmentRecord enchantment in enchantments)
|
||||
UpsertManifestEnchantment(enchantment);
|
||||
|
||||
|
|
@ -430,6 +455,7 @@ public sealed class Spellbook
|
|||
_activeById.Clear();
|
||||
_enchantmentOrderByBucket.Clear();
|
||||
_vitalModCache.Clear();
|
||||
_skillModCache.Clear();
|
||||
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
|
||||
_desiredComponents.Clear();
|
||||
_spellbookFilters = 0x3FFFu;
|
||||
|
|
@ -448,6 +474,7 @@ public sealed class Spellbook
|
|||
private void NotifyEnchantmentsChanged()
|
||||
{
|
||||
_vitalModCache.Clear();
|
||||
_skillModCache.Clear();
|
||||
EnchantmentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue