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
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue