Retail CACQualities::EnchantAttribute (0x00594570), EnchantAttribute2nd (0x00594670, already ported for #6), and EnchantSkill (0x005947b0) are the three enchantment-composition functions the Character window's Attributes and Skills tabs depend on. Primary attributes never reference the vitae singleton in retail (only Attribute2nd/Skill do) — confirmed directly from the decompiled function bodies, not assumed. EnchantmentMath.GetMod gains requiredType/includeVitae parameters (default to the prior behavior) so a numeric StatMod key collision across domains (e.g. key=1 is both Strength and MaxHealth) can't leak a buff into the wrong computation. Spellbook.GetAttributeMod/GetSkillMod and LocalPlayerState.GetEffectiveAttribute/GetEffectiveSkill/ GetSkillVitaeModifier wire the retail chain through to the panel. CharacterSheetProvider now reports the effective value as the main number and CharacterSkill.CurrentLevel is no longer an alias of BaseLevel (this also activates the previously-dead SkillValueColor buffed/debuffed row coloring). CharacterStatController's footer-title parenthetical is cited from gmAttributeUI::DisplaySelectionFooter_Attribute (0x0049d280) and gmSkillUI::DisplaySelectionFooter_Trained (0x0049b860) + SkillInfoRegion::GetVitaeModifier (0x004f0fa0): skills show up to two segments (vitae's own contribution, then the buff-only residual), while vitae-immune attributes show at most one; no parenthetical when the delta is zero. The panel now refreshes on Spellbook.EnchantmentsChanged, not only raw property/attribute updates. Core goldens cover the user-reported 33% vitae example (303->203, "(-100)" exactly), buff+vitae composition, and the attribute vitae-immunity finding. Provider/controller tests cover the full row-click -> footer-title path and live refresh. Full solution suite passes with zero failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
389 lines
16 KiB
C#
389 lines
16 KiB
C#
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);
|
||
}
|
||
|
||
[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);
|
||
}
|
||
|
||
// ── Issue #267 — Attribute/Skill domain filter + EnchantAttribute/EnchantSkill goldens ──
|
||
|
||
[Fact]
|
||
public void GetMod_RequiredType_ExcludesCrossDomainKeyCollision()
|
||
{
|
||
// A Strength buff (Attribute, key=1) must NOT leak into a MaxHealth
|
||
// (SecondAtt, key=1) computation just because the numeric key collides.
|
||
var table = LoadTable((50u, "Strength Buff", 0u));
|
||
var enchantments = new[]
|
||
{
|
||
MakeTypedMultRecord(spellId: 50, layer: 1, statKey: 1u,
|
||
statModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute, val: 1.5f),
|
||
};
|
||
|
||
var secondAttMod = EnchantmentMath.GetMod(enchantments, table, statKey: 1u,
|
||
EnchantmentMath.EnchantmentTypeFlag.SecondAtt);
|
||
Assert.Equal(EnchantmentMath.VitalMod.Identity, secondAttMod);
|
||
|
||
var attributeMod = EnchantmentMath.GetMod(enchantments, table, statKey: 1u,
|
||
EnchantmentMath.EnchantmentTypeFlag.Attribute);
|
||
Assert.Equal(1.5f, attributeMod.Multiplier, precision: 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void GetMod_IncludeVitaeFalse_ExcludesVitaeEvenWhenActive()
|
||
{
|
||
// Primary attributes (EnchantAttribute) never reference the vitae
|
||
// singleton in retail — includeVitae:false must fully exclude it.
|
||
var table = LoadTable((60u, "Vitae", 0u));
|
||
var enchantments = new[]
|
||
{
|
||
MakeVitaeRecord(spellId: 60, layer: 1, statKey: 0u, val: 0.67f),
|
||
};
|
||
|
||
var mod = EnchantmentMath.GetMod(enchantments, table, statKey: 1u,
|
||
EnchantmentMath.EnchantmentTypeFlag.Attribute, includeVitae: false);
|
||
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
|
||
}
|
||
|
||
[Fact]
|
||
public void EnchantAttribute_NoMods_ReturnsBaseTruncated()
|
||
{
|
||
Assert.Equal(200, EnchantmentMath.EnchantAttribute(EnchantmentMath.VitalMod.Identity, 200u));
|
||
}
|
||
|
||
[Fact]
|
||
public void EnchantAttribute_Buff_AppliesMultiplierAndTruncates()
|
||
{
|
||
// 200 base with a +10% buff (unrelated to vitae — attributes are
|
||
// vitae-immune) → 220.
|
||
var mod = new EnchantmentMath.VitalMod(1.1f, 0f);
|
||
Assert.Equal(220, EnchantmentMath.EnchantAttribute(mod, 200u));
|
||
}
|
||
|
||
[Fact]
|
||
public void EnchantAttribute_FloorsAtOne_WhenBaseBelowTenAndDebuffed()
|
||
{
|
||
// Base 5 (< 10) crushed by a 0.1 multiplier would compute to 0.5,
|
||
// but retail floors small attributes at 1 rather than letting them
|
||
// hit zero (0x00594570: `< 0xa` branch floors at 1f).
|
||
var mod = new EnchantmentMath.VitalMod(0.1f, 0f);
|
||
Assert.Equal(1, EnchantmentMath.EnchantAttribute(mod, 5u));
|
||
}
|
||
|
||
[Fact]
|
||
public void EnchantAttribute_FloorsAtTen_WhenBaseAtOrAboveTenAndDebuffed()
|
||
{
|
||
// Base 50 (>= 10) crushed by a 0.1 multiplier would compute to 5,
|
||
// but retail floors at 10 for this base range.
|
||
var mod = new EnchantmentMath.VitalMod(0.1f, 0f);
|
||
Assert.Equal(10, EnchantmentMath.EnchantAttribute(mod, 50u));
|
||
}
|
||
|
||
[Fact]
|
||
public void EnchantSkill_ThirtyThreePercentVitae_MatchesGoldenValue()
|
||
{
|
||
// 33% vitae penalty on a base-303 skill: 303 * 0.67 = 203.01 -> 203.
|
||
var mod = new EnchantmentMath.VitalMod(0.67f, 0f);
|
||
Assert.Equal(203, EnchantmentMath.EnchantSkill(mod, 303u));
|
||
}
|
||
|
||
[Fact]
|
||
public void EnchantSkill_BuffPlusVitaeComposition_MatchesGoldenValue()
|
||
{
|
||
// Base 300, vitae 0.67 (33%) composed with a +50 additive buff:
|
||
// (300 * 0.67) + 50 = 201 + 50 = 251.
|
||
var mod = new EnchantmentMath.VitalMod(0.67f, 50f);
|
||
Assert.Equal(251, EnchantmentMath.EnchantSkill(mod, 300u));
|
||
}
|
||
|
||
[Fact]
|
||
public void EnchantSkill_ZeroFloorsBelowHalf()
|
||
{
|
||
// A crushing vitae/debuff combination that drops the result under
|
||
// 0.5 floors to 0 rather than truncating to a stray small value.
|
||
var mod = new EnchantmentMath.VitalMod(0.001f, 0f);
|
||
Assert.Equal(0, EnchantmentMath.EnchantSkill(mod, 10u));
|
||
}
|
||
|
||
[Fact]
|
||
public void GetVitaeMultiplier_NoVitae_ReturnsOne()
|
||
{
|
||
var enchantments = new[]
|
||
{
|
||
MakeMultRecord(spellId: 1, layer: 1, statKey: 1u, val: 1.5f),
|
||
};
|
||
Assert.Equal(1.0f, EnchantmentMath.GetVitaeMultiplier(enchantments));
|
||
}
|
||
|
||
[Fact]
|
||
public void GetVitaeMultiplier_WithVitae_ReturnsItsValue()
|
||
{
|
||
var enchantments = new[]
|
||
{
|
||
MakeVitaeRecord(spellId: 1, layer: 1, statKey: 0u, val: 0.67f),
|
||
MakeMultRecord(spellId: 2, layer: 2, statKey: 1u, val: 1.5f), // must not affect vitae isolation
|
||
};
|
||
Assert.Equal(0.67f, EnchantmentMath.GetVitaeMultiplier(enchantments), precision: 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillVitaeModifier_ThirtyThreePercent_MatchesGoldenValue()
|
||
{
|
||
// Base 303 skill, 33% vitae: truncate(303 * 0.67) - 303 = 203 - 303 = -100.
|
||
// This is the user-reported oracle example: "(-100)".
|
||
var enchantments = new[]
|
||
{
|
||
MakeVitaeRecord(spellId: 1, layer: 1, statKey: 0u, val: 0.67f),
|
||
};
|
||
Assert.Equal(-100, EnchantmentMath.SkillVitaeModifier(enchantments, baseValue: 303u));
|
||
}
|
||
|
||
[Fact]
|
||
public void SkillVitaeModifier_NoVitae_ReturnsZero()
|
||
{
|
||
Assert.Equal(0, EnchantmentMath.SkillVitaeModifier(
|
||
System.Array.Empty<ActiveEnchantmentRecord>(), baseValue: 303u));
|
||
}
|
||
|
||
private static ActiveEnchantmentRecord MakeTypedMultRecord(
|
||
uint spellId, uint layer, uint statKey, uint statModType, float val) =>
|
||
new(spellId, layer, 60f, 0u, StatModType: statModType, StatModKey: statKey,
|
||
StatModValue: val, Bucket: 1u);
|
||
|
||
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()));
|
||
}
|
||
}
|