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:
parent
4ceac5cb40
commit
b153bbe5ad
6 changed files with 351 additions and 14 deletions
126
tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs
Normal file
126
tests/AcDream.Core.Tests/Spells/EnchantmentMathTests.cs
Normal 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()));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue