acdream/src/AcDream.Core/Player/LocalPlayerState.cs
Erik 9ea579bdd0 feat(chat): port retail client command families
Expand the typed client-command boundary across travel, character queries, local UI and layout controls, AFK and consent, emotes, friends, squelch and filters, and fill-components. Preserve retail packet layouts and queue ownership, import the confirmation dialog, and keep authoritative social state in Core.

Co-Authored-By: Codex <codex@openai.com>
2026-07-13 14:50:15 +02:00

484 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Physics;
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);
/// <summary>Per-skill snapshot from PlayerDescription's CreatureSkill table.</summary>
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<AttributeKind, AttributeSnapshot> _attrs = new();
private readonly Dictionary<uint, SkillSnapshot> _skills = new();
private readonly Dictionary<uint, Position> _positions = new();
private PropertyBundle _properties = 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>Fires after player properties or skills from PlayerDescription change.</summary>
public event System.Action? CharacterChanged;
/// <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>Snapshot of the local player's current property bundle.</summary>
public PropertyBundle Properties => _properties;
/// <summary>All known skill snapshots, keyed by SkillId.</summary>
public IReadOnlyDictionary<uint, SkillSnapshot> Skills => _skills;
/// <summary>Player Position qualities keyed by retail PositionType.</summary>
public IReadOnlyDictionary<uint, Position> Positions => _positions;
public Position? GetPosition(uint positionType) =>
_positions.TryGetValue(positionType, out var value) ? value : null;
public void OnPositions(IReadOnlyDictionary<uint, Position> positions)
{
_positions.Clear();
foreach (var pair in positions)
_positions[pair.Key] = pair.Value;
CharacterChanged?.Invoke();
}
/// <summary>Snapshot for one skill, or <c>null</c> if it has not arrived yet.</summary>
public SkillSnapshot? GetSkill(uint skillId) =>
_skills.TryGetValue(skillId, out var s) ? s : 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(&amp;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);
}
/// <summary>Replace the local player's top-level property snapshot from PlayerDescription.</summary>
public void OnProperties(PropertyBundle properties)
{
_properties = properties.Clone();
CharacterChanged?.Invoke();
}
/// <summary>Apply or replace one PlayerDescription skill entry.</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Optimistically apply a successful local max-vital raise action.</summary>
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;
}
/// <summary>Optimistically promote an untrained skill after a successful TrainSkill action.</summary>
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;
}
/// <summary>Optimistically apply a successful local skill-raise action.</summary>
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;
}
/// <summary>
/// Optimistically debit an int property in the player's property bundle
/// (clamped at 0), firing <see cref="CharacterChanged"/> 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 <see cref="Properties"/> dictionaries directly skips the
/// change event and is a single-owner-state violation.
/// </summary>
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;
}
/// <summary>
/// Optimistically debit an int64 property (clamped at 0), firing
/// <see cref="CharacterChanged"/>. False if the property is absent or
/// already ≤ 0. The next server snapshot remains authoritative.
/// </summary>
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;
}
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)
{
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;
}