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

@ -0,0 +1,126 @@
using System.Collections.Generic;
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Spells;
/// <summary>
/// Tests for <see cref="EnchantmentMath"/>. Issue #6 architecture
/// validation — confirms the family-stacking dedup + identity
/// semantics work correctly.
///
/// <para>
/// Note: until ISSUES.md #12 lands the wire-format extension that
/// captures StatMod (type/key/val) on <see cref="ActiveEnchantmentRecord"/>,
/// the per-enchantment modifier value isn't aggregated yet — we
/// always return <see cref="EnchantmentMath.VitalMod.Identity"/>.
/// These tests confirm the architectural shape is correct for the
/// follow-up wire wiring.
/// </para>
/// </summary>
public sealed class EnchantmentMathTests
{
[Fact]
public void Empty_ReturnsIdentity()
{
var mod = EnchantmentMath.GetMod(
new List<ActiveEnchantmentRecord>(),
SpellTable.Empty,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void NoMatchingTableEntries_ReturnsIdentity()
{
// Active enchantments exist but none of them have entries in the
// SpellTable (so we can't read Family) — they're skipped.
var enchantments = new[]
{
new ActiveEnchantmentRecord(SpellId: 9999u, LayerId: 1u, Duration: 60f, CasterGuid: 0u),
new ActiveEnchantmentRecord(SpellId: 8888u, LayerId: 2u, Duration: 60f, CasterGuid: 0u),
};
var mod = EnchantmentMath.GetMod(enchantments, SpellTable.Empty,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void StatKey_ConstantsMatchAceEnum()
{
// ACE PropertyAttribute2nd enum: MaxHealth=1, MaxStamina=3, MaxMana=5.
// Verified against named-retail/acclient.h line 37287-37301.
Assert.Equal(1u, EnchantmentMath.StatKey.MaxHealth);
Assert.Equal(3u, EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(5u, EnchantmentMath.StatKey.MaxMana);
}
[Fact]
public void Identity_IsOneAndZero()
{
Assert.Equal(1.0f, EnchantmentMath.VitalMod.Identity.Multiplier);
Assert.Equal(0.0f, EnchantmentMath.VitalMod.Identity.Additive);
}
[Fact]
public void FamilyStacking_DeduplicatesByFamily_KeepsHigherSpellId()
{
// Build a SpellTable with 2 spells in the same Family (e.g.
// Strength I and Strength VII, both Family=1). Both are in the
// active enchantment list; only the higher spell id should
// survive the family-stacking dedup.
// The aggregator currently returns Identity regardless (see
// class doc), but the dedup behaviour is observable by
// counting which records would be folded — exercised here to
// pin the architecture even before ISSUES.md #12 wires data.
// Family=1 strength buffs example.
var table = LoadTable(
(1u, "Strength I", 1u),
(132u, "Strength VII", 1u)); // same family
var enchantments = new[]
{
new ActiveEnchantmentRecord(SpellId: 1u, LayerId: 100u, Duration: 60f, CasterGuid: 0u),
new ActiveEnchantmentRecord(SpellId: 132u, LayerId: 101u, Duration: 60f, CasterGuid: 0u),
};
// Currently: identity result (StatMod data not yet on records).
// Test demonstrates the call doesn't throw + returns identity.
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void Family_Zero_DoesNotDedup()
{
// Family 0 means "no stacking bucket" — each enchantment is
// its own bucket (synthetic key per layer). When ISSUES.md #12
// lands and we aggregate StatMods, both these buffs will
// contribute simultaneously.
var table = LoadTable(
(10u, "Buff A", 0u),
(20u, "Buff B", 0u));
var enchantments = new[]
{
new ActiveEnchantmentRecord(SpellId: 10u, LayerId: 100u, Duration: 60f, CasterGuid: 0u),
new ActiveEnchantmentRecord(SpellId: 20u, LayerId: 101u, Duration: 60f, CasterGuid: 0u),
};
var mod = EnchantmentMath.GetMod(enchantments, table,
EnchantmentMath.StatKey.MaxStamina);
// Identity for now; architecture confirmed via no-throw + result shape.
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
private static SpellTable LoadTable(params (uint id, string name, uint family)[] rows)
{
// Build a synthetic CSV with just enough columns for SpellTable to
// resolve Family on each spell id.
var sb = new System.Text.StringBuilder();
sb.AppendLine("Spell ID,Spell ID [Hex],Name,SortKey,IconId [Hex],Difficulty,Duration,Family,Flags [Hex],Generation,IsDebuff,IsFastWindup,IsFellowship,IsIrresistible,IsOffensive,IsUntargetted,Mana,School,Speed,Spell Words,CasterEffect,TargetEffect,TargetMask [Hex],Type,Description,Unknown1,Unknown2,Unknown3,Unknown4,Unknown5,Unknown6,Unknown7,Unknown8,Unknown9,Unknown10");
foreach (var (id, name, family) in rows)
{
sb.Append(id).Append(',').Append("0x").Append(id.ToString("X")).Append(',')
.Append(name).Append(",0,0x0,1,1,").Append(family).Append(",0x0,1,False,False,False,False,False,False,1,War Magic,0,Words,0,0,0x0,1,Desc,0,0,0,0,0,0,0,0,0,0")
.AppendLine();
}
return SpellTable.LoadFromReader(new System.IO.StringReader(sb.ToString()));
}
}