acdream/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs
2026-07-30 18:14:48 +02:00

491 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
// ── Campaign P Slice P1 (2026-07-30): GetSkillMod (run/jump skill ─────
// ── vitae/enchantment adjustment) ──────────────────────────────────
private static ActiveEnchantmentRecord MakeSkillMultRecord(
uint spellId, uint layer, uint skillId, float val) =>
new(
spellId, layer, 60f, 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: skillId,
StatModValue: val,
Bucket: 1u);
private static ActiveEnchantmentRecord MakeSkillAddRecord(
uint spellId, uint layer, uint skillId, float val) =>
new(
spellId, layer, 60f, 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Skill,
StatModKey: skillId,
StatModValue: val,
Bucket: 2u);
[Fact]
public void EnchantmentTypeFlag_Skill_MatchesAceEnchantmentTypeFlags()
{
// ACE.Entity.Enum.EnchantmentTypeFlags.Skill = 0x0000010.
Assert.Equal(0x0000010u, (uint)EnchantmentMath.EnchantmentTypeFlag.Skill);
Assert.Equal(0x0000002u, (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt);
}
[Fact]
public void GetSkillMod_Empty_ReturnsIdentity()
{
var mod = EnchantmentMath.GetSkillMod(
Array.Empty<ActiveEnchantmentRecord>(), SpellTable.Empty, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetSkillMod_MultiplicativeSkillBuff_AppliesWhenSkillIdMatches()
{
var table = LoadTable((50u, "Run Buff", 400u));
var enchantments = new[] { MakeSkillMultRecord(50, 1, skillId: 24u, val: 1.2f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(1.2f, mod.Multiplier, precision: 4);
}
[Fact]
public void GetSkillMod_AdditiveSkillBuff_AppliesWhenSkillIdMatches()
{
var table = LoadTable((51u, "Jump Buff", 401u));
var enchantments = new[] { MakeSkillAddRecord(51, 1, skillId: 22u, val: 15f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 22u);
Assert.Equal(15.0f, mod.Additive, precision: 4);
}
[Fact]
public void GetSkillMod_SkillIdMismatch_DoesNotContribute()
{
var table = LoadTable((52u, "Melee Buff", 402u));
// Buff targets skill id 7 (some melee skill), we ask for Run (24).
var enchantments = new[] { MakeSkillMultRecord(52, 1, skillId: 7u, val: 1.5f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
}
[Fact]
public void GetSkillMod_NamespaceCollision_VitalTypedRecordDoesNotLeakIntoSkillQuery()
{
// A vital-typed (SecondAtt) buff whose numeric StatModKey happens to
// equal a skill id (24=Run) must NOT contribute to a skill query —
// the whole point of the type-flag filter (pseudocode doc §9).
var table = LoadTable((53u, "Vital Buff", 403u));
var enchantments = new[]
{
new ActiveEnchantmentRecord(
53u, 1u, 60f, 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: 24u,
StatModValue: 1.5f,
Bucket: 1u),
};
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(EnchantmentMath.VitalMod.Identity, mod);
// The SAME record DOES contribute to a vitals query with no type
// filter (GetMod's pre-P1, unmodified default behavior).
var vitalMod = EnchantmentMath.GetMod(enchantments, table, statKey: 24u);
Assert.Equal(1.5f, vitalMod.Multiplier, precision: 4);
}
[Fact]
public void GetSkillMod_Vitae_AppliesUnconditionallyLikeVitals()
{
// CEnchantmentRegistry::EnchantSkill applies _vitae in the IDENTICAL
// position as EnchantAttribute2nd (pseudocode doc §5) — reusing the
// SAME Bucket==4 handling, unconditional on the type-flag filter.
var table = LoadTable((54u, "Vitae", 0u));
var enchantments = new[] { MakeVitaeRecord(54, 0, statKey: 0u, val: 0.9f) };
var mod = EnchantmentMath.GetSkillMod(enchantments, table, skillId: 24u);
Assert.Equal(0.9f, mod.Multiplier, precision: 3);
}
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()));
}
}