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

@ -288,7 +288,9 @@ public sealed class GameWindow : IDisposable
public readonly AcDream.Core.Items.ItemRepository Items = new();
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = new();
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
// buffs into the max formula via Spellbook.GetVitalMod.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!;
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
@ -397,6 +399,7 @@ public sealed class GameWindow : IDisposable
_worldGameState = worldGameState;
_worldEvents = worldEvents;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
}
/// <summary>

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);

View file

@ -0,0 +1,138 @@
using System.Collections.Generic;
namespace AcDream.Core.Spells;
/// <summary>
/// Aggregates active-enchantment buffs into per-stat <c>(multiplier,
/// additive)</c> modifier pairs, mirroring retail
/// <c>CEnchantmentRegistry::EnchantAttribute</c> at PDB
/// <c>0x00594570</c> (see
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>
/// line 416110).
///
/// <para>
/// <b>Retail formula:</b>
/// </para>
/// <code>
/// for each enchantment in _mult_list (after CullEnchantmentsFromList):
/// if statMod.key == requested_key &amp;&amp; mod-type is multiplicative:
/// multiplier *= statMod.val
/// for each enchantment in _add_list:
/// if statMod.key == requested_key &amp;&amp; mod-type is additive:
/// additive += statMod.val
/// apply family-stacking: only one enchantment per Family wins
/// (highest Generation; tie-broken by latest cast).
/// </code>
///
/// <para>
/// <b>Vitae</b> (death penalty) is a singleton on
/// <c>CEnchantmentRegistry._vitae</c>, applied multiplicatively after
/// the buff lists. We don't yet wire it through.
/// </para>
///
/// <para>
/// <b>Current implementation status:</b> the aggregator iterates
/// <see cref="Spellbook.ActiveEnchantments"/> and applies
/// <see cref="SpellTable"/> family-stacking deduplication, but
/// **returns identity (1.0, 0.0) for stat modifiers** because our
/// <see cref="ActiveEnchantmentRecord"/> doesn't yet carry the
/// <c>StatMod (type/key/val)</c> triad — that requires extending
/// <c>ParseMagicUpdateEnchantment</c> to read the full Enchantment
/// payload (60-64 bytes per holtburger
/// <c>messages/magic/types.rs</c>) and storing it on the record.
/// Filed as ISSUES.md #12. Once that lands, the aggregator's
/// `effectiveMult * mod.Val` and `additive + mod.Val` paths fire and
/// the Vitals HUD percent gap closes.
/// </para>
///
/// <para>
/// <b>Stat keys</b> (ACE <c>PropertyAttribute2nd</c>):
/// <c>MaxHealth=1</c>, <c>MaxStamina=3</c>, <c>MaxMana=5</c>.
/// Verified against
/// <c>docs/research/named-retail/acclient.h</c> line 37287-37301
/// (<c>SecondaryAttribute</c> family).
/// </para>
/// </summary>
public static class EnchantmentMath
{
/// <summary>
/// Combined multiplicative + additive modifier for a stat key.
/// </summary>
public readonly record struct VitalMod(float Multiplier, float Additive)
{
/// <summary>Identity modifier — <c>(1.0, 0.0)</c>. No active
/// buffs apply.</summary>
public static readonly VitalMod Identity = new(1.0f, 0.0f);
}
/// <summary>
/// Compute the combined buff modifier for a given stat key from
/// the player's active enchantments. Returns <see cref="VitalMod.Identity"/>
/// when no relevant buffs are active.
/// </summary>
/// <param name="enchantments">All active enchantment layers
/// (typically <see cref="Spellbook.ActiveEnchantments"/>).</param>
/// <param name="table">Spell metadata table for family-stacking
/// (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>
public static VitalMod GetMod(
IEnumerable<ActiveEnchantmentRecord> enchantments,
SpellTable table,
uint statKey)
{
// Family-stacking: bucket the active enchantments by Family and
// keep the strongest one per bucket (the one with the largest
// SpellId, which in retail correlates with generation level —
// higher level = higher id within a family. Without the
// Generation field, this is a faithful approximation.)
var stronger = new Dictionary<uint, ActiveEnchantmentRecord>();
foreach (var ench in enchantments)
{
if (!table.TryGet(ench.SpellId, out var meta))
continue;
// Family 0 means "no stacking bucket" — these don't dedup;
// pass them through with a synthetic key per layer.
uint bucket = meta.Family == 0 ? ench.LayerId | 0x80000000u : meta.Family;
if (!stronger.TryGetValue(bucket, out var current) ||
ench.SpellId > current.SpellId)
{
stronger[bucket] = ench;
}
}
// Aggregate StatMod values from the deduplicated set.
// Note: ActiveEnchantmentRecord doesn't currently carry
// StatMod (type/key/val). Until ISSUES.md #12 lands the wire
// extension, this loop produces no aggregation effect — we
// keep the architecture in place so the swap-in is local.
float multiplier = 1.0f;
float additive = 0.0f;
foreach (var ench in stronger.Values)
{
// TODO ISSUES.md #12: filter by ench.StatModKey == statKey
// and apply ench.StatModType (multiplicative vs additive)
// with ench.StatModValue. For now, stat-key filtering is
// a no-op since the data isn't on the record yet.
_ = ench;
_ = statKey;
}
return multiplier == 1.0f && additive == 0.0f
? VitalMod.Identity
: new VitalMod(multiplier, additive);
}
/// <summary>
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
/// (verified against <c>docs/research/named-retail/acclient.h</c>
/// line 37287-37301). Used by <see cref="LocalPlayerState"/> to
/// look up the right buff bucket per vital kind.
/// </summary>
public static class StatKey
{
public const uint MaxHealth = 1;
public const uint MaxStamina = 3;
public const uint MaxMana = 5;
}
}

View file

@ -46,6 +46,17 @@ public sealed class Spellbook
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
public SpellTable Metadata => _table;
/// <summary>
/// Issue #6 — combined buff modifier for a vital stat. Aggregates
/// over <see cref="ActiveEnchantments"/> through
/// <see cref="EnchantmentMath.GetMod"/> with family-stacking dedup
/// from the spell metadata table. Returns
/// <see cref="EnchantmentMath.VitalMod.Identity"/> when no buffs
/// apply (or when no SpellTable was wired).
/// </summary>
public EnchantmentMath.VitalMod GetVitalMod(uint statKey) =>
EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey);
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
public event Action<uint>? SpellLearned;