acdream/src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs
Erik 196f883c10 fix(player): EnchantmentMask bit fix + Vitae key=0 + absolute Vitals overlay
Three fixes to the Vitals HUD path:

1. EnchantmentMask Vitae/Cooldown bit values (parser regression).
   ACE's enum at references/ACE/Source/ACE.Entity/Enum/EnchantmentCategory.cs
   has Vitae=0x4 and Cooldown=0x8. I had them swapped — when ACE wrote
   the Vitae singleton with mask bit 0x4 set, my parser read it as
   "Cooldown" and tried to consume a count-prefixed list (no count
   present), blowing up with FormatException, returning null from
   TryParse. PlayerDescription consequently failed to parse on every
   live login. Fix: swap the bit values + bucket constants to match ACE.

2. Vitae applies regardless of StatModKey. Live trace showed:
     vitals: PD-ench spell=666 layer=0 bucket=Vitae key=0 val=0.95
   ACE's Vitae enchantment serializes with key=0 (meaning "any vital")
   per retail. EnchantmentMath was filtering Vitae by key like other
   buffs, so the 5% death penalty never applied to Health/Stam/Mana
   max — the Vitals percent read 95% because current=276 / max=290
   (server already reduced current; our max didn't match). Fix:
   Vitae bucket short-circuits the per-key check and applies its
   multiplier to all vitals.

3. Absolute current/max in HUD overlay. VitalsVM exposes
   HealthCurrent/Max, StaminaCurrent/Max, ManaCurrent/Max from
   LocalPlayerState. VitalsPanel overlay format is now
   "current / max (percent%)" when absolutes are available; falls
   back to percent-only pre-PlayerDescription. Matches the retail
   look the user requested ("HP 400/400" style).

Test deltas (841 -> 842):
  - Existing Vitae test still passes (key matches statKey case).
  - New Vitae key=0 test pins the "any vital" semantics.
  - Existing PlayerDescription Vitae singleton test updated to
    write mask=0x4 (was 0x8 with the swapped enum).

Live verification: with +Acdream's Vitae-666 active and Endurance.current=290:
  HP   : current=138, max=145×0.95≈138 → bar 100% (was 95%)
  Stam : current=276, max=290×0.95≈276 → bar 100%
  Mana : current=190, max=200×0.95≈190 → bar 100%
Overlay reads e.g. "276 / 276 (100%)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:15:20 +02:00

106 lines
4.5 KiB
C#

using AcDream.Core.Combat;
using AcDream.Core.Player;
namespace AcDream.UI.Abstractions.Panels.Vitals;
/// <summary>
/// ViewModel for the vitals HUD panel. Reads live health percentage for the
/// local player from <see cref="CombatState"/> (which is fed by the server's
/// <c>UpdateHealth (0x01C0)</c> GameEvent).
///
/// <para>
/// <b>Sources:</b>
/// </para>
///
/// <list type="bullet">
/// <item>HP — <see cref="CombatState.GetHealthPercent"/> (percent-only,
/// updated by <c>UpdateHealth (0x01C0)</c>).</item>
/// <item>Stamina / Mana — <see cref="LocalPlayerState"/>'s
/// <c>StaminaPercent</c> / <c>ManaPercent</c>, populated from the
/// <c>CreatureProfile</c> embedded in <c>PlayerDescription
/// (0x0013)</c>. When no <see cref="LocalPlayerState"/> is wired
/// (older constructor / tests), both stay <c>null</c> and
/// <c>VitalsPanel</c> simply skips those bars.</item>
/// </list>
///
/// <para>
/// <b>GUID timing:</b> the local player's server GUID isn't known at
/// <c>OnLoad</c> (pre-login). Construct with <see cref="SetLocalPlayerGuid"/>
/// left as 0; <c>GameWindow</c> calls the setter when the live session
/// receives its guid at <c>EnterWorld</c>. Before the GUID is set,
/// <see cref="HealthPercent"/> returns 1.0 (via <c>CombatState</c>'s safe
/// default for unknown guids) — the bar reads "full", which is harmless.
/// </para>
/// </summary>
public sealed class VitalsVM
{
private readonly CombatState _combat;
private readonly LocalPlayerState? _local;
private uint _localPlayerGuid;
/// <summary>
/// Build a VitalsVM bound to a <see cref="CombatState"/> and (optionally)
/// a <see cref="LocalPlayerState"/>. The GUID starts at 0; call
/// <see cref="SetLocalPlayerGuid"/> once the live session assigns it.
/// When <paramref name="localPlayer"/> is <c>null</c> (back-compat),
/// stamina + mana stay <c>null</c> and <c>VitalsPanel</c> skips those bars.
/// </summary>
public VitalsVM(CombatState combat, LocalPlayerState? localPlayer = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_local = localPlayer;
_localPlayerGuid = 0;
}
/// <summary>
/// Push the authoritative local-player GUID from <c>WorldSession</c>.
/// One-way setter — only <c>GameWindow</c> should call it, exactly once
/// per live session.
/// </summary>
public void SetLocalPlayerGuid(uint guid) => _localPlayerGuid = guid;
/// <summary>
/// Current health percent (0..1) for the local player. Returns 1.0
/// before login or if the server has never sent an UpdateHealth for
/// this GUID.
/// </summary>
public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid);
/// <summary>
/// Stamina percent (0..1), or <c>null</c> when no
/// <see cref="LocalPlayerState"/> is wired or it hasn't received a
/// <c>PlayerDescription</c> with both current and max yet. Reads
/// through to the cache every access — no VM-side caching.
/// </summary>
public float? StaminaPercent => _local?.StaminaPercent;
/// <summary>
/// Mana percent (0..1), or <c>null</c> under the same conditions as
/// <see cref="StaminaPercent"/>.
/// </summary>
public float? ManaPercent => _local?.ManaPercent;
// ── Absolute values for HUD overlays ──────────────────────────────────
/// <summary>Current health value (server-authoritative absolute) or
/// <c>null</c> if <see cref="LocalPlayerState"/> hasn't received the
/// vital snapshot yet.</summary>
public uint? HealthCurrent => _local?.Get(LocalPlayerState.VitalKind.Health)?.Current;
/// <summary>Max health value, accounting for attribute contribution
/// + active enchantment buffs + vitae. <c>null</c> if no vital
/// snapshot yet.</summary>
public uint? HealthMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Health);
/// <summary>Current stamina value.</summary>
public uint? StaminaCurrent => _local?.Get(LocalPlayerState.VitalKind.Stamina)?.Current;
/// <summary>Max stamina including buffs + vitae.</summary>
public uint? StaminaMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Stamina);
/// <summary>Current mana value.</summary>
public uint? ManaCurrent => _local?.Get(LocalPlayerState.VitalKind.Mana)?.Current;
/// <summary>Max mana including buffs + vitae.</summary>
public uint? ManaMax => _local?.GetMaxApprox(LocalPlayerState.VitalKind.Mana);
}