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
|
|
@ -114,24 +114,27 @@ Copy this block when adding a new issue:
|
|||
|
||||
---
|
||||
|
||||
## #6 — Vital max ignores enchantment buffs + vitae
|
||||
## #12 — Capture full Enchantment wire payload (StatMod) on ActiveEnchantmentRecord
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** LOW (3-5% accuracy gap on a HUD bar)
|
||||
**Severity:** LOW (architecture for buff aggregation is in place via #6 / Phase G; this is the wire-data extension that lights it up)
|
||||
**Filed:** 2026-04-25
|
||||
**Component:** ui / player-state
|
||||
**Component:** net / spells
|
||||
|
||||
**Description:** `LocalPlayerState.GetMaxApprox` computes the unenchanted base max for HP/Stam/Mana — `vital.(ranks+start) + attribute_contribution` with retail's hardcoded coefficients (Endurance/2, Endurance, Self). Live test shows bars at ~95% when buffs are presumably active (server character is `+Acdream`, GM-marker char with likely buff stack). Holtburger's `calculate_vital_current` adds `× multiplier + additive` from the active enchantment list — that's the missing 5%.
|
||||
**Description:** `ParseMagicUpdateEnchantment` currently reads only the first 16 bytes of the 0x02C2 payload (`SpellId, LayerId, Duration, CasterGuid`) and ignores the trailing fields including `_smod` (the StatMod triad). `EnchantmentMath.GetMod` consequently returns `(1.0, 0.0)` for every stat key — the architecture is wired (see `Spellbook.GetVitalMod`, `LocalPlayerState.GetMaxApprox`) but no actual buff modifier flows through. Until this lands, the `+Acdream` Stam/Mana percent will continue to read ~95% (vs. retail's 100%) when buffs are active.
|
||||
|
||||
**Root cause / status:** Need to fold `Spellbook.ActiveEnchantments` into the max calc. Holtburger's `magic.rs` aggregates by `EnchantmentTypeFlags::SECOND_ATT` masked with the vital id. The same data already arrives via `MagicUpdateEnchantment` events that we wire into `Spellbook`.
|
||||
**Root cause / status:** Holtburger `crates/holtburger-protocol/src/messages/magic/types.rs` documents the full 60-64 byte Enchantment wire format: `u16 spell_id, u16 layer, u16 spell_category, u16 has_spell_set_id, u32 power_level, f64 start_time, f64 duration, u32 caster_guid, f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded, u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, [u32 spell_set_id]?`. Note the wire format starts with `u16` not `u32` for spell_id / layer / category — our existing parser may be reading garbage. Verify against actual ACE traffic via `ACDREAM_DUMP_VITALS=1`.
|
||||
|
||||
**Files:**
|
||||
- `src/AcDream.Core/Player/LocalPlayerState.cs` — `GetMaxApprox` returns the base; extend to also call `Spellbook` for vital-typed enchantment aggregation.
|
||||
- `src/AcDream.Core/Spells/Spellbook.cs` — needs an aggregator helper similar to holtburger's `get_vital_multiplier` / `get_vital_additive`.
|
||||
- `src/AcDream.Core.Net/Messages/GameEvents.cs` — extend `ParseMagicUpdateEnchantment` + `EnchantmentSummary`.
|
||||
- `src/AcDream.Core/Spells/Spellbook.cs` — extend `ActiveEnchantmentRecord` with `StatModType`, `StatModKey`, `StatModValue`; extend `OnEnchantmentAdded` arity.
|
||||
- `src/AcDream.Core/Spells/EnchantmentMath.cs` — uncomment the StatMod-aware aggregator inside `GetMod`.
|
||||
|
||||
**Research:** holtburger `crates/holtburger-world/src/player/stats_calc.rs:91-111`, `magic.rs` get_enchantment_multiplier / additive.
|
||||
**Research:** holtburger `messages/magic/types.rs:40` (`Enchantment::unpack`); `docs/research/named-retail/acclient.h` line 37406+ (`Enchantment` + `StatMod`).
|
||||
|
||||
**Acceptance:** A `+Acdream` login shows Stam/Mana percent within 1% of retail's reading once any active buff multipliers are applied. (HP already at 100% indicates the unbuffed Health formula is already correct on its own.)
|
||||
**Acceptance:** With `+Acdream` logged in and standard self-buffs active, `LocalPlayer.StaminaPercent` rises from ~95% to a percent that matches retail's reading. New tests cover: parse round-trip with synthetic Enchantment payload; StatMod aggregation in `EnchantmentMath` (multiplicative + additive + family-stacking).
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -181,6 +184,18 @@ Copy this block when adding a new issue:
|
|||
|
||||
# Recently closed
|
||||
|
||||
## #6 — [DONE 2026-04-25 architecture; data follow-up #12] Vital max ignores enchantment buffs + vitae
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
**Commit:** `feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath`
|
||||
**Resolution:** Ported `CEnchantmentRegistry::EnchantAttribute` (PDB `0x00594570`) as `EnchantmentMath.GetMod(IEnumerable<ActiveEnchantmentRecord>, SpellTable, statKey)` returning `(Multiplier, Additive)`. Family-stacking dedup via `SpellTable.Family` (only one buff per family bucket wins, by highest spell-id as a generation proxy). `Spellbook.GetVitalMod(statKey)` delegates. `LocalPlayerState.GetMaxApprox` reworked to apply `(unbuffed × mult) + add` with retail's min-vital clamp (`>= 5` if base ≥ 5 else `>= 1`, matches `CreatureVital::GetMaxValue` at PDB `0x0058F2DD`). Stat-key constants (`MaxHealth=1`, `MaxStamina=3`, `MaxMana=5`) verified against `docs/research/named-retail/acclient.h` line 37287-37301.
|
||||
|
||||
**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 — `EnchantmentMath.GetMod` returns `Identity (1.0, 0.0)` for every stat key. Once #12 wires the data, the existing aggregator + formula light up automatically. Live `+Acdream` Stam/Mana percent will continue to read ~95% until #12 lands.
|
||||
|
||||
6 new EnchantmentMathTests cover: empty list returns Identity, no-table-entries returns Identity, stat-key constants match ACE enum, Identity is `(1, 0)`, family-stacking dedup, family=0 (no-bucket) treated as separate.
|
||||
|
||||
---
|
||||
|
||||
## #11 — [DONE 2026-04-25] Spell metadata loader (spells.csv → SpellTable)
|
||||
|
||||
**Closed:** 2026-04-25
|
||||
|
|
|
|||
|
|
@ -288,7 +288,9 @@ public sealed class GameWindow : IDisposable
|
|||
public readonly AcDream.Core.Items.ItemRepository Items = new();
|
||||
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
|
||||
// PlayerDescription so the Vitals HUD can render those bars.
|
||||
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = new();
|
||||
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
|
||||
// buffs into the max formula via Spellbook.GetVitalMod.
|
||||
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!;
|
||||
|
||||
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
|
||||
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
|
||||
|
|
@ -397,6 +399,7 @@ public sealed class GameWindow : IDisposable
|
|||
_worldGameState = worldGameState;
|
||||
_worldEvents = worldEvents;
|
||||
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||||
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Player;
|
||||
|
||||
|
|
@ -90,6 +91,19 @@ public sealed class LocalPlayerState
|
|||
private VitalSnapshot? _stamina;
|
||||
private VitalSnapshot? _mana;
|
||||
private readonly Dictionary<AttributeKind, AttributeSnapshot> _attrs = new();
|
||||
private readonly Spellbook? _spellbook;
|
||||
|
||||
/// <summary>
|
||||
/// Build a LocalPlayerState. Optional <see cref="Spellbook"/>
|
||||
/// reference unlocks issue #6 — vital-max calc folds in active
|
||||
/// enchantment buffs via <see cref="Spellbook.GetVitalMod"/>. When
|
||||
/// absent (back-compat for tests / older callers), buff modifiers
|
||||
/// are skipped (identity).
|
||||
/// </summary>
|
||||
public LocalPlayerState(Spellbook? spellbook = null)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
}
|
||||
|
||||
/// <summary>Fires after any vital field changes.</summary>
|
||||
public event System.Action<VitalKind>? Changed;
|
||||
|
|
@ -145,9 +159,17 @@ public sealed class LocalPlayerState
|
|||
_attrs.TryGetValue(kind, out var a) ? a : null;
|
||||
|
||||
/// <summary>
|
||||
/// Compute the unenchanted max for a vital, using the retail formula:
|
||||
/// <c>vital.(ranks+start) + attribute_contribution</c>. Returns
|
||||
/// <c>null</c> if the vital snapshot doesn't exist yet.
|
||||
/// Compute the buffed max for a vital, using the full retail formula:
|
||||
/// <c>(vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff</c>
|
||||
/// with a <c>>= 5 if base >= 5 else >= 1</c> minimum-vital clamp
|
||||
/// (matches <c>CreatureVital::GetMaxValue</c> at PDB
|
||||
/// <c>0x0058F2DD</c>). Buffs are pulled from the optional
|
||||
/// <see cref="Spellbook"/> via <see cref="EnchantmentMath.GetMod"/>;
|
||||
/// when absent, returns the unenchanted max.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns <c>null</c> if the vital snapshot doesn't exist yet.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public uint? GetMaxApprox(VitalKind kind)
|
||||
{
|
||||
|
|
@ -155,9 +177,31 @@ public sealed class LocalPlayerState
|
|||
if (v is null) return null;
|
||||
uint baseMax = v.Value.Ranks + v.Value.Start;
|
||||
uint contrib = AttributeContribution(kind);
|
||||
return baseMax + contrib;
|
||||
uint unbuffed = baseMax + contrib;
|
||||
// Preserve the "no data" sentinel — when the unbuffed max is 0
|
||||
// we lack the inputs to compute anything reasonable. The retail
|
||||
// min-vital floor only kicks in once we know the base.
|
||||
if (unbuffed == 0) return 0;
|
||||
|
||||
var mod = _spellbook?.GetVitalMod(StatKeyForKind(kind))
|
||||
?? EnchantmentMath.VitalMod.Identity;
|
||||
// Apply: (unbuffed * mult) + additive, then clamp to retail's
|
||||
// min-vital floor (5 if base >= 5 else 1) — matches
|
||||
// CreatureVital::GetMaxValue at PDB 0x0058F2DD.
|
||||
float buffed = (unbuffed * mod.Multiplier) + mod.Additive;
|
||||
uint minFloor = unbuffed >= 5 ? 5u : 1u;
|
||||
if (buffed < minFloor) buffed = minFloor;
|
||||
return (uint)System.Math.Round(buffed);
|
||||
}
|
||||
|
||||
private static uint StatKeyForKind(VitalKind kind) => kind switch
|
||||
{
|
||||
VitalKind.Health => EnchantmentMath.StatKey.MaxHealth,
|
||||
VitalKind.Stamina => EnchantmentMath.StatKey.MaxStamina,
|
||||
VitalKind.Mana => EnchantmentMath.StatKey.MaxMana,
|
||||
_ => 0u,
|
||||
};
|
||||
|
||||
/// <summary>Stamina percent (0..1) or null when not yet received.</summary>
|
||||
public float? StaminaPercent => Percent(VitalKind.Stamina);
|
||||
|
||||
|
|
|
|||
138
src/AcDream.Core/Spells/EnchantmentMath.cs
Normal file
138
src/AcDream.Core/Spells/EnchantmentMath.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates active-enchantment buffs into per-stat <c>(multiplier,
|
||||
/// additive)</c> modifier pairs, mirroring retail
|
||||
/// <c>CEnchantmentRegistry::EnchantAttribute</c> at PDB
|
||||
/// <c>0x00594570</c> (see
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>
|
||||
/// line 416110).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Retail formula:</b>
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// for each enchantment in _mult_list (after CullEnchantmentsFromList):
|
||||
/// if statMod.key == requested_key && mod-type is multiplicative:
|
||||
/// multiplier *= statMod.val
|
||||
/// for each enchantment in _add_list:
|
||||
/// if statMod.key == requested_key && mod-type is additive:
|
||||
/// additive += statMod.val
|
||||
/// apply family-stacking: only one enchantment per Family wins
|
||||
/// (highest Generation; tie-broken by latest cast).
|
||||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Vitae</b> (death penalty) is a singleton on
|
||||
/// <c>CEnchantmentRegistry._vitae</c>, applied multiplicatively after
|
||||
/// the buff lists. We don't yet wire it through.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Current implementation status:</b> the aggregator iterates
|
||||
/// <see cref="Spellbook.ActiveEnchantments"/> and applies
|
||||
/// <see cref="SpellTable"/> family-stacking deduplication, but
|
||||
/// **returns identity (1.0, 0.0) for stat modifiers** because our
|
||||
/// <see cref="ActiveEnchantmentRecord"/> doesn't yet carry the
|
||||
/// <c>StatMod (type/key/val)</c> triad — that requires extending
|
||||
/// <c>ParseMagicUpdateEnchantment</c> to read the full Enchantment
|
||||
/// payload (60-64 bytes per holtburger
|
||||
/// <c>messages/magic/types.rs</c>) and storing it on the record.
|
||||
/// Filed as ISSUES.md #12. Once that lands, the aggregator's
|
||||
/// `effectiveMult * mod.Val` and `additive + mod.Val` paths fire and
|
||||
/// the Vitals HUD percent gap closes.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Stat keys</b> (ACE <c>PropertyAttribute2nd</c>):
|
||||
/// <c>MaxHealth=1</c>, <c>MaxStamina=3</c>, <c>MaxMana=5</c>.
|
||||
/// Verified against
|
||||
/// <c>docs/research/named-retail/acclient.h</c> line 37287-37301
|
||||
/// (<c>SecondaryAttribute</c> family).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class EnchantmentMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Combined multiplicative + additive modifier for a stat key.
|
||||
/// </summary>
|
||||
public readonly record struct VitalMod(float Multiplier, float Additive)
|
||||
{
|
||||
/// <summary>Identity modifier — <c>(1.0, 0.0)</c>. No active
|
||||
/// buffs apply.</summary>
|
||||
public static readonly VitalMod Identity = new(1.0f, 0.0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute the combined buff modifier for a given stat key from
|
||||
/// the player's active enchantments. Returns <see cref="VitalMod.Identity"/>
|
||||
/// when no relevant buffs are active.
|
||||
/// </summary>
|
||||
/// <param name="enchantments">All active enchantment layers
|
||||
/// (typically <see cref="Spellbook.ActiveEnchantments"/>).</param>
|
||||
/// <param name="table">Spell metadata table for family-stacking
|
||||
/// (only one buff per <see cref="SpellMetadata.Family"/> wins).</param>
|
||||
/// <param name="statKey">Target stat key (ACE
|
||||
/// <c>PropertyAttribute2nd</c> enum value: 1=MaxHealth,
|
||||
/// 3=MaxStamina, 5=MaxMana).</param>
|
||||
public static VitalMod GetMod(
|
||||
IEnumerable<ActiveEnchantmentRecord> enchantments,
|
||||
SpellTable table,
|
||||
uint statKey)
|
||||
{
|
||||
// Family-stacking: bucket the active enchantments by Family and
|
||||
// keep the strongest one per bucket (the one with the largest
|
||||
// SpellId, which in retail correlates with generation level —
|
||||
// higher level = higher id within a family. Without the
|
||||
// Generation field, this is a faithful approximation.)
|
||||
var stronger = new Dictionary<uint, ActiveEnchantmentRecord>();
|
||||
foreach (var ench in enchantments)
|
||||
{
|
||||
if (!table.TryGet(ench.SpellId, out var meta))
|
||||
continue;
|
||||
// Family 0 means "no stacking bucket" — these don't dedup;
|
||||
// pass them through with a synthetic key per layer.
|
||||
uint bucket = meta.Family == 0 ? ench.LayerId | 0x80000000u : meta.Family;
|
||||
if (!stronger.TryGetValue(bucket, out var current) ||
|
||||
ench.SpellId > current.SpellId)
|
||||
{
|
||||
stronger[bucket] = ench;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate StatMod values from the deduplicated set.
|
||||
// Note: ActiveEnchantmentRecord doesn't currently carry
|
||||
// StatMod (type/key/val). Until ISSUES.md #12 lands the wire
|
||||
// extension, this loop produces no aggregation effect — we
|
||||
// keep the architecture in place so the swap-in is local.
|
||||
float multiplier = 1.0f;
|
||||
float additive = 0.0f;
|
||||
foreach (var ench in stronger.Values)
|
||||
{
|
||||
// TODO ISSUES.md #12: filter by ench.StatModKey == statKey
|
||||
// and apply ench.StatModType (multiplicative vs additive)
|
||||
// with ench.StatModValue. For now, stat-key filtering is
|
||||
// a no-op since the data isn't on the record yet.
|
||||
_ = ench;
|
||||
_ = statKey;
|
||||
}
|
||||
return multiplier == 1.0f && additive == 0.0f
|
||||
? VitalMod.Identity
|
||||
: new VitalMod(multiplier, additive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stat-key constants matching ACE <c>PropertyAttribute2nd</c>
|
||||
/// (verified against <c>docs/research/named-retail/acclient.h</c>
|
||||
/// line 37287-37301). Used by <see cref="LocalPlayerState"/> to
|
||||
/// look up the right buff bucket per vital kind.
|
||||
/// </summary>
|
||||
public static class StatKey
|
||||
{
|
||||
public const uint MaxHealth = 1;
|
||||
public const uint MaxStamina = 3;
|
||||
public const uint MaxMana = 5;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,17 @@ public sealed class Spellbook
|
|||
/// Returns <see cref="SpellTable.Empty"/> if none was provided.</summary>
|
||||
public SpellTable Metadata => _table;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #6 — combined buff modifier for a vital stat. Aggregates
|
||||
/// over <see cref="ActiveEnchantments"/> through
|
||||
/// <see cref="EnchantmentMath.GetMod"/> with family-stacking dedup
|
||||
/// from the spell metadata table. Returns
|
||||
/// <see cref="EnchantmentMath.VitalMod.Identity"/> when no buffs
|
||||
/// apply (or when no SpellTable was wired).
|
||||
/// </summary>
|
||||
public EnchantmentMath.VitalMod GetVitalMod(uint statKey) =>
|
||||
EnchantmentMath.GetMod(ActiveEnchantments, _table, statKey);
|
||||
|
||||
/// <summary>Fires when a spell is added to the player's spellbook.</summary>
|
||||
public event Action<uint>? SpellLearned;
|
||||
|
||||
|
|
|
|||
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