using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Properties;
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), vitae, and the
/// retail five-point minimum are applied through the attached
/// . Base-value accessors retain
/// the unenchanted values needed by the character-panel comparison logic.
///
///
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);
/// Per-skill snapshot from PlayerDescription's CreatureSkill table.
public readonly record struct SkillSnapshot(
uint SkillId,
uint Ranks,
uint Status,
uint Xp,
uint Init,
uint Resistance,
double LastUsed,
uint FormulaBonus)
{
public uint BaseLevel => FormulaBonus + Init + Ranks;
public uint CurrentLevel => BaseLevel;
}
private VitalSnapshot? _health;
private VitalSnapshot? _stamina;
private VitalSnapshot? _mana;
private readonly Dictionary _attrs = new();
private readonly Dictionary _skills = new();
private readonly Dictionary _positions = new();
private PropertyBundle _properties = 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;
/// Fires after player properties or skills from PlayerDescription change.
public event System.Action? CharacterChanged;
///
/// 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;
/// The optional wired spellbook — read-only exposure of the same
/// reference passed to the constructor. Issue #267: lets presentation
/// code (e.g. CharacterSheetProvider) subscribe to
/// for live character-sheet
/// refresh without duplicating the reference in its own constructor.
public Spellbook? Spellbook => _spellbook;
///
/// Issue #267 — effective (post-buff) primary-attribute value. Retail
/// CACQualities::EnchantAttribute (0x00594570): primary attributes
/// do NOT receive vitae (only 's secondary
/// attributes and 's skills do). Returns
/// the unenchanted base when no spellbook is wired (back-compat) or the
/// attribute hasn't arrived yet.
///
public int? GetEffectiveAttribute(AttributeKind kind)
{
AttributeSnapshot? attr = GetAttribute(kind);
if (attr is null) return null;
uint baseValue = attr.Value.Current;
if (_spellbook is null) return (int)baseValue;
EnchantmentMath.VitalMod mod = _spellbook.GetAttributeMod(AttributeKindToId(kind));
return EnchantmentMath.EnchantAttribute(mod, baseValue);
}
/// Reverse of — the wire
/// PropertyAttribute id (1..6) for a given .
public static uint AttributeKindToId(AttributeKind kind) => kind switch
{
AttributeKind.Strength => 1u,
AttributeKind.Endurance => 2u,
AttributeKind.Quickness => 3u,
AttributeKind.Coordination => 4u,
AttributeKind.Focus => 5u,
AttributeKind.Self => 6u,
_ => 0u,
};
///
/// Issue #267 — effective (post-vitae, post-buff) skill level. Retail
/// CACQualities::EnchantSkill (0x005947b0). Returns the
/// unenchanted base when no spellbook is wired (back-compat) or the
/// skill hasn't arrived yet.
///
public int? GetEffectiveSkill(uint skillId)
=> GetSkillValue(skillId)?.EffectiveLevel;
///
/// Full retail CACQualities::InqSkill projection, including the
/// augmentation terms on both sides of EnchantSkill. Callers with a
/// fresher player-object property bundle may supply it; otherwise the
/// PlayerDescription snapshot is used.
///
public PlayerSkillMath.Value? GetSkillValue(
uint skillId,
PropertyBundle? properties = null)
{
SkillSnapshot? skill = GetSkill(skillId);
if (skill is null) return null;
PlayerSkillMath.AugmentationBonuses augmentations =
PlayerSkillMath.AugmentationBonuses.FromProperties(
properties ?? _properties);
EnchantmentMath.VitalMod mod = _spellbook?.GetSkillMod(skillId)
?? EnchantmentMath.VitalMod.Identity;
float vitae = _spellbook is null
? 1f
: EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments);
return PlayerSkillMath.Calculate(
checked((int)Math.Min(int.MaxValue, skill.Value.CurrentLevel)),
skillId,
skill.Value.Status,
augmentations,
mod,
vitae);
}
///
/// Issue #267 — vitae's isolated contribution to a skill's effective
/// level (retail SkillInfoRegion::GetVitaeModifier 0x004f0fa0),
/// separate from any buff delta. Always ≤ 0. Returns 0 when no spellbook
/// is wired, no vitae is active, or the skill hasn't arrived yet.
///
public int GetSkillVitaeModifier(uint skillId)
=> GetSkillValue(skillId)?.VitaeModifier ?? 0;
/// Snapshot of the local player's current property bundle.
public PropertyBundle Properties => _properties;
/// All known skill snapshots, keyed by SkillId.
public IReadOnlyDictionary Skills => _skills;
/// Player Position qualities keyed by retail PositionType.
public IReadOnlyDictionary Positions => _positions;
public Position? GetPosition(uint positionType) =>
_positions.TryGetValue(positionType, out var value) ? value : null;
public void OnPositions(IReadOnlyDictionary positions)
{
_positions.Clear();
foreach (var pair in positions)
_positions[pair.Key] = pair.Value;
CharacterChanged?.Invoke();
}
/// Snapshot for one skill, or null if it has not arrived yet.
public SkillSnapshot? GetSkill(uint skillId) =>
_skills.TryGetValue(skillId, out var s) ? s : 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)
{
uint? baseValue = GetMaxBeforeSecondaryEnchantments(kind);
if (baseValue is not uint unbuffed) return null;
// 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). The final cast is
// retail CEnchantmentRegistry::EnchantAttribute2nd's _ftol2
// conversion at 0x00594787: truncate toward zero, do not round.
float buffed = (unbuffed * mod.Multiplier) + mod.Additive;
uint minFloor = unbuffed >= 5 ? 5u : 1u;
if (buffed < minFloor) buffed = minFloor;
return (uint)buffed;
}
///
/// Unenchanted secondary-attribute maximum used by retail's
/// Attribute2ndInfoRegion::Update @ 0x004F19E0 comparison.
///
public uint? GetBaseMaxApprox(VitalKind kind)
{
VitalSnapshot? vital = Get(kind);
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind, effective: false)
+ GearHealthBonus(kind);
}
///
/// Retail CACQualities::InqAttribute2nd @ 0x00592020 computes a
/// maximum vital's formula contribution through InqAttribute with
/// enchantments enabled, adds GearMaxHealth (property 379) for
/// health, and only then calls EnchantAttribute2nd. Keeping this
/// stage separate prevents primary-attribute records from leaking into
/// the secondary-attribute modifier while still allowing buffed
/// Endurance/Self to feed the vital formula.
///
private uint? GetMaxBeforeSecondaryEnchantments(VitalKind kind)
{
VitalSnapshot? vital = Get(kind);
if (vital is null) return null;
return vital.Value.Ranks
+ vital.Value.Start
+ AttributeContribution(kind, effective: true)
+ GearHealthBonus(kind);
}
///
/// Isolated vitae contribution to a secondary attribute, matching
/// Attribute2ndInfoRegion::GetVitaeModifier @ 0x004F1130.
///
public int GetVitalVitaeModifier(VitalKind kind)
{
if (_spellbook is null
|| GetBaseMaxApprox(kind) is not uint baseValue)
{
return 0;
}
return EnchantmentMath.SkillVitaeModifier(
EnchantmentMath.GetVitaeMultiplier(
_spellbook.ActiveEnchantments),
baseValue);
}
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);
}
/// Replace the local player's top-level property snapshot from PlayerDescription.
public void OnProperties(PropertyBundle properties)
{
_properties = properties.Clone();
CharacterChanged?.Invoke();
}
///
/// Apply one authoritative signed 64-bit player-quality update and notify
/// character-sheet consumers. Retail routes 0x02CF through
/// Handle_Qualities__PrivateUpdateInt64 @ 0x00559000; Total XP (1)
/// and Available XP (2) are the character-window values carried here.
///
public void OnInt64PropertyUpdate(uint propertyId, long value)
{
_properties.Int64s[propertyId] = value;
CharacterChanged?.Invoke();
}
/// Apply or replace one PlayerDescription skill entry.
public void OnSkillUpdate(
uint skillId,
uint ranks,
uint status,
uint xp,
uint init,
uint resistance,
double lastUsed,
uint formulaBonus)
{
_skills[skillId] = new SkillSnapshot(
skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus);
CharacterChanged?.Invoke();
}
///
/// Optimistically apply a successful local attribute-raise action.
/// The next server snapshot remains authoritative; this keeps UI state current
/// during the round trip after sending the retail raise action.
///
public bool ApplyAttributeRaise(uint atType, uint amount, ulong xpSpent)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return false;
if (!_attrs.TryGetValue(kind, out var prev)) return false;
_attrs[kind] = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
AttributeChanged?.Invoke(kind);
return true;
}
/// Optimistically apply a successful local max-vital raise action.
public bool ApplyVitalRaise(uint vitalId, uint amount, ulong xpSpent)
{
if (VitalIdToKind(vitalId) is not VitalKind kind) return false;
VitalSnapshot? existing = Get(kind);
if (existing is not VitalSnapshot prev) return false;
var snap = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
switch (kind)
{
case VitalKind.Health: _health = snap; break;
case VitalKind.Stamina: _stamina = snap; break;
case VitalKind.Mana: _mana = snap; break;
}
Changed?.Invoke(kind);
return true;
}
/// Optimistically promote an untrained skill after a successful TrainSkill action.
public bool ApplySkillTraining(uint skillId)
{
if (!_skills.TryGetValue(skillId, out var prev)) return false;
if (prev.Status >= 2u) return false;
_skills[skillId] = prev with { Status = 2u };
CharacterChanged?.Invoke();
return true;
}
/// Optimistically apply a successful local skill-raise action.
public bool ApplySkillRaise(uint skillId, uint amount, ulong xpSpent)
{
if (!_skills.TryGetValue(skillId, out var prev)) return false;
if (prev.Status < 2u) return false;
_skills[skillId] = prev with
{
Ranks = SaturatingAdd(prev.Ranks, amount),
Xp = SaturatingAdd(prev.Xp, xpSpent),
};
CharacterChanged?.Invoke();
return true;
}
///
/// Optimistically debit an int property in the player's property bundle
/// (clamped at 0), firing so bound UI
/// refreshes. False if the property is absent — callers walk their
/// fallback id chain. The next server snapshot remains authoritative.
/// All local-player property writes go through eventful APIs like this
/// one; writing dictionaries directly skips the
/// change event and is a single-owner-state violation.
///
public bool DebitIntProperty(uint propertyId, int amount)
{
if (!_properties.Ints.TryGetValue(propertyId, out int current))
return false;
_properties.Ints[propertyId] = current > amount ? current - amount : 0;
CharacterChanged?.Invoke();
return true;
}
///
/// Optimistically debit an int64 property (clamped at 0), firing
/// . False if the property is absent or
/// already ≤ 0. The next server snapshot remains authoritative.
///
public bool DebitInt64Property(uint propertyId, long amount)
{
if (!_properties.Int64s.TryGetValue(propertyId, out long current) || current <= 0)
return false;
_properties.Int64s[propertyId] = current > amount ? current - amount : 0L;
CharacterChanged?.Invoke();
return true;
}
///
/// Return the character snapshot to its pre-login state. The object itself
/// is process-lived because UI view models subscribe to it once; session
/// replacement clears its contents instead of replacing the owner.
///
///
/// Retail: CPlayerSystem::OnEndCharacterSession @ 0x00562870
/// calls End @ 0x005606C0, which invokes
/// PlayerModule::Clear @ 0x005D48A0, then immediately calls
/// CPlayerSystem::Begin @ 0x0055D410. Re-publishing invalidation
/// events is acdream's process-lived-view adaptation.
///
public void Clear()
{
_health = null;
_stamina = null;
_mana = null;
_attrs.Clear();
_skills.Clear();
_positions.Clear();
_properties = new PropertyBundle();
// Process-lived views pull through this object and use these events as
// their invalidation edge. Publish every category even when Clear is
// repeated so a failed reset attempt can safely converge on retry.
Changed?.Invoke(VitalKind.Health);
Changed?.Invoke(VitalKind.Stamina);
Changed?.Invoke(VitalKind.Mana);
foreach (AttributeKind kind in Enum.GetValues())
AttributeChanged?.Invoke(kind);
CharacterChanged?.Invoke();
}
private static uint SaturatingAdd(uint value, uint delta)
=> uint.MaxValue - value < delta ? uint.MaxValue : value + delta;
private static uint SaturatingAdd(uint value, ulong delta)
=> delta > uint.MaxValue - value ? uint.MaxValue : value + (uint)delta;
// ── 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, bool effective)
{
uint endurance = GetAttrCurrent(AttributeKind.Endurance, effective);
uint self = GetAttrCurrent(AttributeKind.Self, effective);
switch (kind)
{
case VitalKind.Health:
// SecondaryAttributeTable's SkillFormula is
// floor((Endurance / 2) + 0.5), not integer truncation.
return (endurance / 2u) + (endurance & 1u);
case VitalKind.Stamina:
return endurance;
case VitalKind.Mana:
return self;
default:
return 0u;
}
}
private uint GetAttrCurrent(AttributeKind kind, bool effective)
{
if (!_attrs.TryGetValue(kind, out var attribute)) return 0u;
if (!effective || _spellbook is null) return attribute.Current;
int value = GetEffectiveAttribute(kind) ?? 0;
return value > 0 ? (uint)value : 0u;
}
private uint GearHealthBonus(VitalKind kind)
{
if (kind != VitalKind.Health) return 0u;
int value = _properties.GetInt((uint)PropertyInt.GearMaxHealth);
return value > 0 ? (uint)value : 0u;
}
}