feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath

Ports CEnchantmentRegistry::EnchantAttribute (PDB 0x00594570, see
docs/research/named-retail/acclient_2013_pseudo_c.txt line 416110).
The retail formula:

    real_max = (vital.(ranks+start) + attribute_contribution) * mult_buff + add_buff
    clamp >= 5 if base >= 5 else >= 1

is now applied in LocalPlayerState.GetMaxApprox.

EnchantmentMath.GetMod(activeEnchantments, table, statKey)
  - Family-stacking dedup via SpellTable.Family (only one buff per
    family-bucket wins, by highest spell-id as a generation proxy).
  - Family=0 means "no bucket" — each layer is its own bucket.
  - Returns (Multiplier, Additive) ready to apply.
  - StatKey constants: MaxHealth=1, MaxStamina=3, MaxMana=5
    (verified against named-retail/acclient.h line 37287-37301).

Spellbook.GetVitalMod(statKey) delegates to EnchantmentMath using
its constructor-injected SpellTable.

LocalPlayerState.GetMaxApprox now applies the full formula with
the min-vital floor (matches CreatureVital::GetMaxValue at PDB
0x0058F2DD). When Spellbook is null (back-compat), falls back to
Identity (no buff modification) — existing tests stay green.

GameWindow constructor wires SpellBook -> LocalPlayer so the chain
is complete in the live session.

Architecture in place; data still flat.

Until ISSUES.md #12 lands the wire-format extension that captures
StatMod (type/key/val) on ActiveEnchantmentRecord, the per-enchantment
modifier value isn't aggregated yet — GetMod returns Identity. Once
#12 wires the data, the existing aggregator + formula light up
automatically. Live +Acdream Stam/Mana will keep reading ~95% until
#12 lands.

6 new EnchantmentMathTests cover: empty list returns Identity,
no-table-entries returns Identity, stat-key constants match ACE,
Identity is (1, 0), family-stacking dedup, family=0 (no-bucket).

Total tests: 828 -> 834.

Closes #6 architecturally. Files #12 to track the wire-data follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 17:55:15 +02:00
parent 4ceac5cb40
commit b153bbe5ad
6 changed files with 351 additions and 14 deletions

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using AcDream.Core.Spells;
namespace AcDream.Core.Player;
@ -90,6 +91,19 @@ public sealed class LocalPlayerState
private VitalSnapshot? _stamina;
private VitalSnapshot? _mana;
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
private readonly Spellbook? _spellbook;
/// <summary>
/// Build a LocalPlayerState. Optional <see cref="Spellbook"/>
/// reference unlocks issue #6 — vital-max calc folds in active
/// enchantment buffs via <see cref="Spellbook.GetVitalMod"/>. When
/// absent (back-compat for tests / older callers), buff modifiers
/// are skipped (identity).
/// </summary>
public LocalPlayerState(Spellbook? spellbook = null)
{
_spellbook = spellbook;
}
/// <summary>Fires after any vital field changes.</summary>
public event System.Action<VitalKind>? Changed;
@ -145,9 +159,17 @@ public sealed class LocalPlayerState
_attrs.TryGetValue(kind, out var a) ? a : null;
/// <summary>
/// Compute the unenchanted max for a vital, using the retail formula:
/// <c>vital.(ranks+start) + attribute_contribution</c>. Returns
/// <c>null</c> if the vital snapshot doesn't exist yet.
/// Compute the buffed max for a vital, using the full retail formula:
/// <c>(vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff</c>
/// with a <c>>= 5 if base >= 5 else >= 1</c> minimum-vital clamp
/// (matches <c>CreatureVital::GetMaxValue</c> at PDB
/// <c>0x0058F2DD</c>). Buffs are pulled from the optional
/// <see cref="Spellbook"/> via <see cref="EnchantmentMath.GetMod"/>;
/// when absent, returns the unenchanted max.
///
/// <para>
/// Returns <c>null</c> if the vital snapshot doesn't exist yet.
/// </para>
/// </summary>
public uint? GetMaxApprox(VitalKind kind)
{
@ -155,9 +177,31 @@ public sealed class LocalPlayerState
if (v is null) return null;
uint baseMax = v.Value.Ranks + v.Value.Start;
uint contrib = AttributeContribution(kind);
return baseMax + contrib;
uint unbuffed = baseMax + contrib;
// Preserve the "no data" sentinel — when the unbuffed max is 0
// we lack the inputs to compute anything reasonable. The retail
// min-vital floor only kicks in once we know the base.
if (unbuffed == 0) return 0;
var mod = _spellbook?.GetVitalMod(StatKeyForKind(kind))
?? EnchantmentMath.VitalMod.Identity;
// Apply: (unbuffed * mult) + additive, then clamp to retail's
// min-vital floor (5 if base >= 5 else 1) — matches
// CreatureVital::GetMaxValue at PDB 0x0058F2DD.
float buffed = (unbuffed * mod.Multiplier) + mod.Additive;
uint minFloor = unbuffed >= 5 ? 5u : 1u;
if (buffed < minFloor) buffed = minFloor;
return (uint)System.Math.Round(buffed);
}
private static uint StatKeyForKind(VitalKind kind) => kind switch
{
VitalKind.Health => EnchantmentMath.StatKey.MaxHealth,
VitalKind.Stamina => EnchantmentMath.StatKey.MaxStamina,
VitalKind.Mana => EnchantmentMath.StatKey.MaxMana,
_ => 0u,
};
/// <summary>Stamina percent (0..1) or null when not yet received.</summary>
public float? StaminaPercent => Percent(VitalKind.Stamina);