using System.Collections.Generic; using AcDream.Core.Spells; namespace AcDream.Core.Tests.Spells; /// /// Tests for . Issue #6 architecture /// validation — confirms the family-stacking dedup + identity /// semantics work correctly. /// /// /// Note: until ISSUES.md #12 lands the wire-format extension that /// captures StatMod (type/key/val) on , /// the per-enchantment modifier value isn't aggregated yet — we /// always return . /// These tests confirm the architectural shape is correct for the /// follow-up wire wiring. /// /// public sealed class EnchantmentMathTests { [Fact] public void Empty_ReturnsIdentity() { var mod = EnchantmentMath.GetMod( new List(), 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); } [Fact] public void GetMod_MultiplicativeBucket_AppliesProductWhenStatKeyMatches() { // Two multiplicative enchantments on MaxStamina (key=3): values // 1.2 and 1.1 → final multiplier = 1.2 × 1.1 = 1.32. // Different families so neither dedups the other. var table = LoadTable( (10u, "Buff10", 100u), (11u, "Buff11", 200u)); var enchantments = new[] { MakeMultRecord(spellId: 10, layer: 1, statKey: 3u, val: 1.2f), MakeMultRecord(spellId: 11, layer: 2, statKey: 3u, val: 1.1f), }; var mod = EnchantmentMath.GetMod(enchantments, table, EnchantmentMath.StatKey.MaxStamina); Assert.Equal(1.32f, mod.Multiplier, precision: 4); Assert.Equal(0.0f, mod.Additive); } [Fact] public void GetMod_AdditiveBucket_SumsValueWhenStatKeyMatches() { var table = LoadTable( (20u, "Add1", 300u), (21u, "Add2", 301u)); var enchantments = new[] { MakeAddRecord(spellId: 20, layer: 1, statKey: 5u /* MaxMana */, val: 25f), MakeAddRecord(spellId: 21, layer: 2, statKey: 5u, val: 50f), }; var mod = EnchantmentMath.GetMod(enchantments, table, EnchantmentMath.StatKey.MaxMana); Assert.Equal(1.0f, mod.Multiplier); Assert.Equal(75.0f, mod.Additive); } [Fact] public void GetMod_StatKeyMismatch_DoesNotContribute() { var table = LoadTable((30u, "Health buff", 500u)); // Buff modifies MaxHealth (key=1) but we ask for MaxStamina (key=3). var enchantments = new[] { MakeMultRecord(spellId: 30, layer: 1, statKey: 1u /* MaxHealth */, val: 1.5f), }; var mod = EnchantmentMath.GetMod(enchantments, table, EnchantmentMath.StatKey.MaxStamina); Assert.Equal(EnchantmentMath.VitalMod.Identity, mod); } [Fact] public void GetMod_VitaeBucket_AppliedMultiplicativelyAfterBuffs() { // Vitae = 0.85 (15% death penalty) on MaxHealth, plus a +10 // additive from a Restoration buff. Family 0 means each is its // own bucket. var table = LoadTable( (40u, "Restoration", 0u), (41u, "Vitae", 0u)); var enchantments = new[] { MakeAddRecord(spellId: 40, layer: 1, statKey: 1u /* MaxHealth */, val: 10f), MakeVitaeRecord(spellId: 41, layer: 2, statKey: 1u, val: 0.85f), }; var mod = EnchantmentMath.GetMod(enchantments, table, EnchantmentMath.StatKey.MaxHealth); // Vitae multiplier 0.85, additive 10. Assert.Equal(0.85f, mod.Multiplier, precision: 3); Assert.Equal(10.0f, mod.Additive); } [Fact] public void GetMod_Vitae_AppliesEvenWhenStatModKeyIsZero() { // Retail behaviour observed in live trace (2026-04-25): ACE's // Vitae enchantment serializes with StatModKey = 0 (meaning // "any vital"). The Vitae multiplier must apply regardless of // the requested stat key — otherwise +Acdream's 5% death // penalty wouldn't show up in the Vitals HUD percent. var table = LoadTable((666u, "Vitae", 0u)); var enchantments = new[] { MakeVitaeRecord(spellId: 666, layer: 0, statKey: 0u /* "any" */, val: 0.95f), }; // Query for MaxStamina; Vitae key=0 should still apply. var mod = EnchantmentMath.GetMod(enchantments, table, EnchantmentMath.StatKey.MaxStamina); Assert.Equal(0.95f, mod.Multiplier, precision: 3); } [Fact] public void GetMod_FamilyStacking_PicksHigherSpellId() { // Two spells in the same family — only the one with the higher // SpellId should contribute. var table = LoadTable( (10u, "Strength I", 1u), // Family=1 (132u, "Strength VII", 1u)); // same family var enchantments = new[] { MakeMultRecord(spellId: 10u, layer: 1, statKey: 3u, val: 1.1f), MakeMultRecord(spellId: 132u, layer: 2, statKey: 3u, val: 1.5f), }; var mod = EnchantmentMath.GetMod(enchantments, table, EnchantmentMath.StatKey.MaxStamina); // Only the higher-id buff (1.5) applies. Assert.Equal(1.5f, mod.Multiplier, precision: 3); } private static ActiveEnchantmentRecord MakeMultRecord(uint spellId, uint layer, uint statKey, float val) => new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 1u); private static ActiveEnchantmentRecord MakeAddRecord(uint spellId, uint layer, uint statKey, float val) => new(spellId, layer, 60f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 2u); private static ActiveEnchantmentRecord MakeVitaeRecord(uint spellId, uint layer, uint statKey, float val) => new(spellId, layer, -1f, 0u, StatModType: 0, StatModKey: statKey, StatModValue: val, Bucket: 4u); 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())); } }