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>
309 lines
12 KiB
C#
309 lines
12 KiB
C#
using System.Collections.Generic;
|
||
using AcDream.Core.Spells;
|
||
|
||
namespace AcDream.Core.Player;
|
||
|
||
/// <summary>
|
||
/// Local player's attribute + vital snapshot, populated from the
|
||
/// <c>PlayerDescription (0x0013)</c> attribute block at login and
|
||
/// kept fresh by <c>PrivateUpdateVital (0x02E7)</c> +
|
||
/// <c>PrivateUpdateVitalCurrent (0x02E9)</c> deltas.
|
||
///
|
||
/// <para>
|
||
/// Wire format references:
|
||
/// </para>
|
||
/// <list type="bullet">
|
||
/// <item>holtburger
|
||
/// <c>crates/holtburger-protocol/src/messages/player/events.rs</c>
|
||
/// for PlayerDescription body layout (vitals at attribute-block
|
||
/// ids 7/8/9 with <c>ranks/start/xp/current</c>).</item>
|
||
/// <item>holtburger
|
||
/// <c>crates/holtburger-world/src/player/stats_calc.rs</c>
|
||
/// <c>calculate_vital_current</c> for the max formula —
|
||
/// <c>(ranks + start + attribute_contribution) × multiplier + additive</c>.
|
||
/// We implement the unenchanted base case with retail-faithful
|
||
/// hardcoded attribute coefficients (no portal.dat
|
||
/// <c>SecondaryAttributeTable</c> port yet — see remarks).</item>
|
||
/// </list>
|
||
///
|
||
/// <para>
|
||
/// <b>Max derivation</b> (retail base case, no enchantments):
|
||
/// </para>
|
||
/// <code>
|
||
/// MaxHealth = vital.ranks + vital.start + Endurance.current / 2
|
||
/// MaxStamina = vital.ranks + vital.start + Endurance.current
|
||
/// MaxMana = vital.ranks + vital.start + Self.current
|
||
/// </code>
|
||
///
|
||
/// <para>
|
||
/// Primary attribute <c>current = ranks + start</c> per holtburger
|
||
/// <c>mutations.rs</c>. Attribute coefficients come from retail's
|
||
/// <c>SecondaryAttributeTable</c> (portal.dat 0x0E0..0x0E2). The
|
||
/// values are hardcoded here as well-known constants; a future port
|
||
/// of the dat object can replace the hardcodes if those coefficients
|
||
/// ever turn out to vary.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Enchantment buffs</b> (multiplicative + additive) and the
|
||
/// 5-min-vital clamp are <b>not yet applied</b> — adding those
|
||
/// requires the <see cref="AcDream.Core.Spells.Spellbook"/>'s active
|
||
/// enchantment list. The unenchanted max is correct for clean
|
||
/// characters; buffed players will read percent slightly higher than
|
||
/// retail until enchantment integration lands.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class LocalPlayerState
|
||
{
|
||
/// <summary>Three vital types — mirrors holtburger <c>VitalType</c>.</summary>
|
||
public enum VitalKind
|
||
{
|
||
Health,
|
||
Stamina,
|
||
Mana,
|
||
}
|
||
|
||
/// <summary>Six primary attributes — ACE <c>PropertyAttribute</c>.</summary>
|
||
public enum AttributeKind
|
||
{
|
||
Strength,
|
||
Endurance,
|
||
Quickness,
|
||
Coordination,
|
||
Focus,
|
||
Self,
|
||
}
|
||
|
||
/// <summary>Primary-attribute snapshot. <c>Current = Ranks + Start</c>
|
||
/// per retail; we don't track an independent "current attribute"
|
||
/// because PlayerDescription doesn't expose one.</summary>
|
||
public readonly record struct AttributeSnapshot(uint Ranks, uint Start, uint Xp)
|
||
{
|
||
public uint Current => Ranks + Start;
|
||
}
|
||
|
||
/// <summary>Per-vital snapshot. Max comes from
|
||
/// <see cref="LocalPlayerState.GetMaxApprox"/> because it depends
|
||
/// on primary-attribute state held elsewhere on the cache.</summary>
|
||
public readonly record struct VitalSnapshot(uint Ranks, uint Start, uint Xp, uint Current);
|
||
|
||
private VitalSnapshot? _health;
|
||
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;
|
||
|
||
/// <summary>Fires after any primary-attribute field changes (rare —
|
||
/// only at PlayerDescription / future <c>PrivateUpdateAttribute</c>).</summary>
|
||
public event System.Action<AttributeKind>? AttributeChanged;
|
||
|
||
/// <summary>
|
||
/// Map a vital-id (across both ID systems) to a <see cref="VitalKind"/>.
|
||
/// <list type="bullet">
|
||
/// <item><c>1..=6</c> — wire-opcode <c>Vital</c> enum
|
||
/// (MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6).</item>
|
||
/// <item><c>7..=9</c> — PlayerDescription attribute-block ids
|
||
/// (Health=7, Stamina=8, Mana=9).</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public static VitalKind? VitalIdToKind(uint vitalId) => vitalId switch
|
||
{
|
||
1u or 2u or 7u => VitalKind.Health,
|
||
3u or 4u or 8u => VitalKind.Stamina,
|
||
5u or 6u or 9u => VitalKind.Mana,
|
||
_ => null,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Map a primary-attribute id (1..=6) to <see cref="AttributeKind"/>.
|
||
/// Returns <c>null</c> for ids outside that range — vital ids 7-9
|
||
/// don't map.
|
||
/// </summary>
|
||
public static AttributeKind? AttributeIdToKind(uint atType) => atType switch
|
||
{
|
||
1u => AttributeKind.Strength,
|
||
2u => AttributeKind.Endurance,
|
||
3u => AttributeKind.Quickness,
|
||
4u => AttributeKind.Coordination,
|
||
5u => AttributeKind.Focus,
|
||
6u => AttributeKind.Self,
|
||
_ => null,
|
||
};
|
||
|
||
/// <summary>Snapshot for a vital, or <c>null</c> if never received.</summary>
|
||
public VitalSnapshot? Get(VitalKind kind) => kind switch
|
||
{
|
||
VitalKind.Health => _health,
|
||
VitalKind.Stamina => _stamina,
|
||
VitalKind.Mana => _mana,
|
||
_ => null,
|
||
};
|
||
|
||
/// <summary>Snapshot for a primary attribute, or <c>null</c> if never received.</summary>
|
||
public AttributeSnapshot? GetAttribute(AttributeKind kind) =>
|
||
_attrs.TryGetValue(kind, out var a) ? a : null;
|
||
|
||
/// <summary>
|
||
/// 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)
|
||
{
|
||
var v = Get(kind);
|
||
if (v is null) return null;
|
||
uint baseMax = v.Value.Ranks + v.Value.Start;
|
||
uint contrib = AttributeContribution(kind);
|
||
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);
|
||
|
||
/// <summary>Mana percent (0..1) or null when not yet received.</summary>
|
||
public float? ManaPercent => Percent(VitalKind.Mana);
|
||
|
||
/// <summary>Health percent (0..1) or null when not yet received.</summary>
|
||
public float? HealthPercent => Percent(VitalKind.Health);
|
||
|
||
private float? Percent(VitalKind kind)
|
||
{
|
||
var v = Get(kind);
|
||
if (v is null) return null;
|
||
uint? max = GetMaxApprox(kind);
|
||
if (max is not uint m || m == 0) return null;
|
||
float r = (float)v.Value.Current / m;
|
||
if (r < 0f) r = 0f;
|
||
else if (r > 1f) r = 1f;
|
||
return r;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply a full vital update — replaces ranks / start / xp / current
|
||
/// for the matching <see cref="VitalKind"/>. Accepts both wire-opcode
|
||
/// ids (1..=6) and PlayerDescription attribute-block ids (7..=9).
|
||
/// </summary>
|
||
public void OnVitalUpdate(uint vitalId, uint ranks, uint start, uint xp, uint current)
|
||
{
|
||
if (VitalIdToKind(vitalId) is not VitalKind kind) return;
|
||
var snap = new VitalSnapshot(ranks, start, xp, current);
|
||
switch (kind)
|
||
{
|
||
case VitalKind.Health: _health = snap; break;
|
||
case VitalKind.Stamina: _stamina = snap; break;
|
||
case VitalKind.Mana: _mana = snap; break;
|
||
}
|
||
Changed?.Invoke(kind);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply a current-only delta. Silently ignored if no full update
|
||
/// has been received for this vital yet (matches holtburger's
|
||
/// <c>get_mut(&kind)</c> miss-as-noop semantics).
|
||
/// </summary>
|
||
public void OnVitalCurrent(uint vitalId, uint current)
|
||
{
|
||
if (VitalIdToKind(vitalId) is not VitalKind kind) return;
|
||
VitalSnapshot? existing = Get(kind);
|
||
if (existing is not VitalSnapshot prev) return;
|
||
var snap = prev with { Current = current };
|
||
switch (kind)
|
||
{
|
||
case VitalKind.Health: _health = snap; break;
|
||
case VitalKind.Stamina: _stamina = snap; break;
|
||
case VitalKind.Mana: _mana = snap; break;
|
||
}
|
||
Changed?.Invoke(kind);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Apply a primary-attribute update from PlayerDescription's
|
||
/// attribute block (ids 1..=6). Vital ids (7..=9) here are silently
|
||
/// dropped — feed them through <see cref="OnVitalUpdate"/> instead.
|
||
/// </summary>
|
||
public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp)
|
||
{
|
||
if (AttributeIdToKind(atType) is not AttributeKind kind) return;
|
||
_attrs[kind] = new AttributeSnapshot(ranks, start, xp);
|
||
AttributeChanged?.Invoke(kind);
|
||
}
|
||
|
||
// ── Retail attribute contribution ──────────────────────────────────────
|
||
//
|
||
// Source: ACE Source/ACE.Server/Entity/AttributeFormula.cs +
|
||
// SecondaryAttributeTable (portal.dat 0x0E0..0x0E2). Coefficients are
|
||
// hardwired in retail and re-confirmed by holtburger's
|
||
// calculate_vital_attribute_contribution.
|
||
//
|
||
// MaxHealth formula = Endurance × 0.5
|
||
// MaxStamina formula = Endurance × 1.0
|
||
// MaxMana formula = Self × 1.0
|
||
//
|
||
// Unknown attribute → contribution 0 → max underestimated. Once the
|
||
// SecondaryAttributeTable port lands these can shift to dat-driven
|
||
// coefficients, but the values themselves don't change between dat
|
||
// versions in retail.
|
||
|
||
private uint AttributeContribution(VitalKind kind)
|
||
{
|
||
switch (kind)
|
||
{
|
||
case VitalKind.Health:
|
||
return GetAttrCurrent(AttributeKind.Endurance) / 2u;
|
||
case VitalKind.Stamina:
|
||
return GetAttrCurrent(AttributeKind.Endurance);
|
||
case VitalKind.Mana:
|
||
return GetAttrCurrent(AttributeKind.Self);
|
||
default:
|
||
return 0u;
|
||
}
|
||
}
|
||
|
||
private uint GetAttrCurrent(AttributeKind kind) =>
|
||
_attrs.TryGetValue(kind, out var a) ? a.Current : 0u;
|
||
}
|