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>
New SpellMetadata + SpellTable. Loads docs/research/data/spells.csv at
GameWindow construction (3,956 spells x 11 useful fields including
Family for buff stacking which issue #6 needs). The CSV is copied to
bin/<config>/net10.0/data/spells.csv via the csproj <None Include>
entry; SpellTable.LoadFromCsv resolves relative to AppContext.BaseDirectory.
Hand-rolled CSV parser handles RFC 4180 quoted fields with embedded
commas (the Description column) + escaped double-quotes ("" -> ").
No external CsvHelper dep. Falls back to SpellTable.Empty + console
warning if the file is missing (tooling contexts).
Spellbook now accepts an optional SpellTable in its constructor +
exposes TryGetMetadata(spellId, out SpellMetadata). When the table is
absent (legacy `new Spellbook()` calls), TryGetMetadata returns false
gracefully so existing tests keep passing.
GameWindow:
- SpellTable field initialized via LoadSpellTable() helper that
handles the missing-file case + emits the spells: loaded N entries
log line.
- SpellBook field constructor-initialized with the loaded SpellTable
so TryGetMetadata works for the live session.
10 new tests (SpellTableTests):
- Empty table behavior
- Header-only loads to empty
- Single row populates all metadata
- Quoted Description with embedded commas
- Blank lines skipped
- Bad-spell-id rows silently skipped (third-party data is messy)
- Unknown spell-id lookup returns false
- ParseRow primitive: simple comma split, quoted-field with comma,
escaped double-quote.
Total tests: 818 -> 828.
Closes#11. Phase G (issue #6 — fold enchantment buffs into vital max
via EnchantmentMath using SpellTable.Family for stacking) unblocked.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>