using System.Collections.Generic; using AcDream.Core.Spells; namespace AcDream.Core.Player; /// /// Local player's attribute + vital snapshot, populated from the /// PlayerDescription (0x0013) attribute block at login and /// kept fresh by PrivateUpdateVital (0x02E7) + /// PrivateUpdateVitalCurrent (0x02E9) deltas. /// /// /// Wire format references: /// /// /// holtburger /// crates/holtburger-protocol/src/messages/player/events.rs /// for PlayerDescription body layout (vitals at attribute-block /// ids 7/8/9 with ranks/start/xp/current). /// holtburger /// crates/holtburger-world/src/player/stats_calc.rs /// calculate_vital_current for the max formula — /// (ranks + start + attribute_contribution) × multiplier + additive. /// We implement the unenchanted base case with retail-faithful /// hardcoded attribute coefficients (no portal.dat /// SecondaryAttributeTable port yet — see remarks). /// /// /// /// Max derivation (retail base case, no enchantments): /// /// /// MaxHealth = vital.ranks + vital.start + Endurance.current / 2 /// MaxStamina = vital.ranks + vital.start + Endurance.current /// MaxMana = vital.ranks + vital.start + Self.current /// /// /// /// Primary attribute current = ranks + start per holtburger /// mutations.rs. Attribute coefficients come from retail's /// SecondaryAttributeTable (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. /// /// /// /// Enchantment buffs (multiplicative + additive) and the /// 5-min-vital clamp are not yet applied — adding those /// requires the '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. /// /// public sealed class LocalPlayerState { /// Three vital types — mirrors holtburger VitalType. public enum VitalKind { Health, Stamina, Mana, } /// Six primary attributes — ACE PropertyAttribute. public enum AttributeKind { Strength, Endurance, Quickness, Coordination, Focus, Self, } /// Primary-attribute snapshot. Current = Ranks + Start /// per retail; we don't track an independent "current attribute" /// because PlayerDescription doesn't expose one. public readonly record struct AttributeSnapshot(uint Ranks, uint Start, uint Xp) { public uint Current => Ranks + Start; } /// Per-vital snapshot. Max comes from /// because it depends /// on primary-attribute state held elsewhere on the cache. 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 _attrs = new(); private readonly Spellbook? _spellbook; /// /// Build a LocalPlayerState. Optional /// reference unlocks issue #6 — vital-max calc folds in active /// enchantment buffs via . When /// absent (back-compat for tests / older callers), buff modifiers /// are skipped (identity). /// public LocalPlayerState(Spellbook? spellbook = null) { _spellbook = spellbook; } /// Fires after any vital field changes. public event System.Action? Changed; /// Fires after any primary-attribute field changes (rare — /// only at PlayerDescription / future PrivateUpdateAttribute). public event System.Action? AttributeChanged; /// /// Map a vital-id (across both ID systems) to a . /// /// 1..=6 — wire-opcode Vital enum /// (MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6). /// 7..=9 — PlayerDescription attribute-block ids /// (Health=7, Stamina=8, Mana=9). /// /// 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, }; /// /// Map a primary-attribute id (1..=6) to . /// Returns null for ids outside that range — vital ids 7-9 /// don't map. /// 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, }; /// Snapshot for a vital, or null if never received. public VitalSnapshot? Get(VitalKind kind) => kind switch { VitalKind.Health => _health, VitalKind.Stamina => _stamina, VitalKind.Mana => _mana, _ => null, }; /// Snapshot for a primary attribute, or null if never received. public AttributeSnapshot? GetAttribute(AttributeKind kind) => _attrs.TryGetValue(kind, out var a) ? a : null; /// /// Compute the buffed max for a vital, using the full retail formula: /// (vital.(ranks+start) + attribute_contribution) × multiplier_buff + additive_buff /// with a >= 5 if base >= 5 else >= 1 minimum-vital clamp /// (matches CreatureVital::GetMaxValue at PDB /// 0x0058F2DD). Buffs are pulled from the optional /// via ; /// when absent, returns the unenchanted max. /// /// /// Returns null if the vital snapshot doesn't exist yet. /// /// 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, }; /// Stamina percent (0..1) or null when not yet received. public float? StaminaPercent => Percent(VitalKind.Stamina); /// Mana percent (0..1) or null when not yet received. public float? ManaPercent => Percent(VitalKind.Mana); /// Health percent (0..1) or null when not yet received. 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; } /// /// Apply a full vital update — replaces ranks / start / xp / current /// for the matching . Accepts both wire-opcode /// ids (1..=6) and PlayerDescription attribute-block ids (7..=9). /// 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); } /// /// Apply a current-only delta. Silently ignored if no full update /// has been received for this vital yet (matches holtburger's /// get_mut(&kind) miss-as-noop semantics). /// 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); } /// /// Apply a primary-attribute update from PlayerDescription's /// attribute block (ids 1..=6). Vital ids (7..=9) here are silently /// dropped — feed them through instead. /// 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; }