acdream/tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs
Erik 196f883c10 fix(player): EnchantmentMask bit fix + Vitae key=0 + absolute Vitals overlay
Three fixes to the Vitals HUD path:

1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
   ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
   has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
   the Vitae singleton with mask bit 0x4 set, my parser read it as
   "Cooldown" and tried to consume a count-prefixed list (no count
   present), blowing up with FormatException, returning null from
   TryParse. PlayerDescription consequently failed to parse on every
   live login. Fix: swap the bit values + bucket constants to match ACE.

2. Vitae applies regardless of StatModKey. Live trace showed:
     vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
   ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
   per retail. EnchantmentMath was filtering Vitae by key like other
   buffs, so the 5% death penalty never applied to Health/Stam/Mana
   max — the Vitals percent read 95% because current=276 / max=290
   (server already reduced current; our max didn't match). Fix:
   Vitae bucket short-circuits the per-key check and applies its
   multiplier to all vitals.

3. Absolute current/max in HUD overlay. VitalsVM exposes
   HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
   LocalPlayerState. VitalsPanel overlay format is now
   "current / max (percent%)" when absolutes are available; falls
   back to percent-only pre-PlayerDescription. Matches the retail
   look the user requested ("HP 400/400" style).

Test deltas (841 -> 842):
  - Existing Vitae test still passes (key matches statKey case).
  - New Vitae key=0 test pins the "any vital" semantics.
  - Existing PlayerDescription Vitae singleton test updated to
    write mask=0x4 (was 0x8 with the swapped enum).

Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
  HP   : current=138, max=145×0.95≈138 → bar 100% (was 95%)
  Stam : current=276, max=290×0.95≈276 → bar 100%
  Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:15:20 +02:00

245 lines
11 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);
}
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()));
}
}