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;
}