feat(player): #5 LocalPlayerState — Stam/Mana wired through PlayerDescription

Closes ISSUES.md #5. The Vitals devtools window now draws three bars
(HP / Stamina / Mana) once the server sends the first PlayerDescription
(0x0013), instead of HP only. Built test-first per CLAUDE.md TDD rule —
16 new tests went red before the implementation went in.

New AcDream.Core.Player.LocalPlayerState (cache):
  - {CurrentStamina, MaxStamina, CurrentMana, MaxMana} as uint? — null
    until first received.
  - StaminaPercent / ManaPercent: 0..1 fraction or null when either
    field is missing or max is zero. Clamps to 1.0 if current > max
    (server can briefly report this during buff transitions).
  - OnPlayerDescription preserves any previously known good value when
    an incoming field is null — partial profiles don't wipe state.
  - Changed event for future subscribers.

GameEventWiring.WireAll:
  - New optional 6th parameter: LocalPlayerState? localPlayer = null.
    Existing 5-arg call sites still work; without the parameter the new
    PlayerDescription handler still parses + feeds the spellbook but
    skips the cache update.
  - PlayerDescription (0x0013) shares AppraiseInfo wire format with
    IdentifyObjectResponse (0x00C9) per AppraiseInfoParser docstring,
    so the new handler reuses the existing parser and pulls
    CreatureProfile.{Stamina, StaminaMax, Mana, ManaMax}.
  - Player's full learned spellbook also lands here (previously only
    item-scoped Identify responses fed the spellbook).

VitalsVM:
  - Constructor adds optional LocalPlayerState? parameter (default null
    keeps every existing caller compiling).
  - StaminaPercent / ManaPercent now read through to LocalPlayerState
    every access — no VM-side caching, so a server-side delta to the
    cache surfaces next frame without any explicit refresh.

GameWindow:
  - Public readonly LocalPlayer field alongside Combat / Chat / Items /
    SpellBook so plugins + future panels can bind directly.
  - WireAll call updated to pass LocalPlayer.
  - VitalsVM construction passes LocalPlayer so the existing
    VitalsPanel automatically picks up the two new bars.

Test counts:
  - AcDream.Core.Tests:           550 → 561  (+11 LocalPlayerStateTests)
  - AcDream.UI.Abstractions.Tests: 23 →  26  (+3 VitalsVM through-cache)
  - AcDream.Core.Net.Tests:       192 → 194  (+2 PlayerDescription wiring)
  - Total:                        765 → 781

Build: 0 warnings, 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 11:02:00 +02:00
parent 9faf9d7e3a
commit d42bf5735d
8 changed files with 436 additions and 50 deletions

View file

@ -280,6 +280,9 @@ public sealed class GameWindow : IDisposable
public readonly AcDream.Core.Combat.CombatState Combat = new();
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
public readonly AcDream.Core.Items.ItemRepository Items = new();
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = new();
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
@ -890,8 +893,10 @@ public sealed class GameWindow : IDisposable
// VitalsVM: GUID=0 at construction; set later at EnterWorld
// (see the _playerServerGuid assignment path). Pre-login the
// HP bar just reads 1.0 (safe default) — harmless.
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat);
// HP bar just reads 1.0 (safe default) — harmless. Stam/Mana
// bars surface only after the first PlayerDescription has
// populated LocalPlayer (Issue #5).
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_panelHost.Register(
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm));
@ -1169,7 +1174,7 @@ public sealed class GameWindow : IDisposable
// notifications / spell learns / wield events all update
// the corresponding client-side state without further glue.
AcDream.Core.Net.GameEventWiring.WireAll(
_liveSession.GameEvents, Items, Combat, SpellBook, Chat);
_liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer);
// Phase H.1: feed inbound HearSpeech into the chat log.
_liveSession.SpeechHeard += speech =>

View file

@ -3,6 +3,7 @@ using AcDream.Core.Chat;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Spells;
namespace AcDream.Core.Net;
@ -34,7 +35,8 @@ public static class GameEventWiring
ItemRepository items,
CombatState combat,
Spellbook spellbook,
ChatLog chat)
ChatLog chat,
LocalPlayerState? localPlayer = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -165,5 +167,31 @@ public static class GameEventWiring
foreach (uint sid in p.Value.SpellBook)
spellbook.OnSpellLearned(sid);
});
// ── Player ────────────────────────────────────────────────
// PlayerDescription (0x0013) carries the same AppraiseInfo body as
// IdentifyObjectResponse (0x00C9), but it's targeted at the local
// player. Issue #5: feed CreatureProfile.{Stamina, Mana, *Max}
// into LocalPlayerState so the Vitals HUD can render those bars.
// Spellbook + properties get the same downstream treatment as
// IdentifyObjectResponse so the player's full learned spellbook
// also lands here.
dispatcher.Register(GameEventType.PlayerDescription, e =>
{
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
if (p is null || !p.Value.Success) return;
if (localPlayer is not null && p.Value.CreatureProfile is { } profile)
{
localPlayer.OnPlayerDescription(
currentStamina: profile.Stamina,
maxStamina: profile.StaminaMax,
currentMana: profile.Mana,
maxMana: profile.ManaMax);
}
foreach (uint sid in p.Value.SpellBook)
spellbook.OnSpellLearned(sid);
});
}
}

View file

@ -0,0 +1,79 @@
namespace AcDream.Core.Player;
/// <summary>
/// Cache of the local player's stamina + mana absolute values, populated
/// from the <c>CreatureProfile</c> blob inside <c>PlayerDescription
/// (0x0013)</c>. Health stays in <see cref="AcDream.Core.Combat.CombatState"/>
/// because it has its own delta opcode (<c>UpdateHealth 0x01C0</c>); stam
/// and mana don't, so without this cache <c>VitalsVM</c> can't surface
/// percent-of-max bars even though the parser already decodes the
/// absolute values.
///
/// <para>
/// Filed by issue <c>#5</c> in <c>docs/ISSUES.md</c>. Once future delta
/// opcodes for stam/mana arrive (or are recognised in the existing event
/// stream) they update through the same <see cref="OnPlayerDescription"/>
/// surface — keep the cache the single point of truth so VM consumers
/// don't have to subscribe to multiple sources.
/// </para>
/// </summary>
public sealed class LocalPlayerState
{
/// <summary>Current stamina (absolute), or <c>null</c> if never received.</summary>
public uint? CurrentStamina { get; private set; }
/// <summary>Max stamina (absolute), or <c>null</c> if never received.</summary>
public uint? MaxStamina { get; private set; }
/// <summary>Current mana (absolute), or <c>null</c> if never received.</summary>
public uint? CurrentMana { get; private set; }
/// <summary>Max mana (absolute), or <c>null</c> if never received.</summary>
public uint? MaxMana { get; private set; }
/// <summary>Fires after any field update via <see cref="OnPlayerDescription"/>.</summary>
public event Action<LocalPlayerState>? Changed;
/// <summary>
/// Stamina as a 0..1 fraction, or <c>null</c> when either current or max
/// is missing or max is zero. Clamps to 1.0 if current &gt; max (which
/// the server can briefly report during buff transitions).
/// </summary>
public float? StaminaPercent => Percent(CurrentStamina, MaxStamina);
/// <summary>
/// Mana as a 0..1 fraction, or <c>null</c> when either current or max
/// is missing or max is zero. Same clamp rules as
/// <see cref="StaminaPercent"/>.
/// </summary>
public float? ManaPercent => Percent(CurrentMana, MaxMana);
/// <summary>
/// Apply a slice of the latest <c>CreatureProfile</c>. Each field is
/// nullable: <c>null</c> means "no information" — preserve any previously
/// known good value rather than wipe it. Fires <see cref="Changed"/> once
/// after the update.
/// </summary>
public void OnPlayerDescription(
uint? currentStamina,
uint? maxStamina,
uint? currentMana,
uint? maxMana)
{
if (currentStamina.HasValue) CurrentStamina = currentStamina.Value;
if (maxStamina.HasValue) MaxStamina = maxStamina.Value;
if (currentMana.HasValue) CurrentMana = currentMana.Value;
if (maxMana.HasValue) MaxMana = maxMana.Value;
Changed?.Invoke(this);
}
private static float? Percent(uint? current, uint? max)
{
if (current is not uint c) return null;
if (max is not uint m || m == 0) return null;
float r = (float)c / m;
if (r < 0f) r = 0f;
else if (r > 1f) r = 1f;
return r;
}
}

View file

@ -1,4 +1,5 @@
using AcDream.Core.Combat;
using AcDream.Core.Player;
namespace AcDream.UI.Abstractions.Panels.Vitals;
@ -8,17 +9,18 @@ namespace AcDream.UI.Abstractions.Panels.Vitals;
/// <c>UpdateHealth (0x01C0)</c> GameEvent).
///
/// <para>
/// <b>D.2a scope limits:</b>
/// <b>Sources:</b>
/// </para>
///
/// <list type="bullet">
/// <item>HP comes from <see cref="CombatState"/> and is <b>percent-only</b>
/// (0..1). Absolute current/max HP is not wired yet.</item>
/// <item>Stamina / Mana are always <c>null</c> — those values live in
/// <c>AppraiseInfoParser.CreatureProfile</c> (parsed from
/// <c>PlayerDescription (0x0013)</c>) but the parsed record is
/// currently discarded. Wiring a <c>LocalPlayerState</c> cache is
/// a separate follow-up; see <c>docs/ISSUES.md</c>.</item>
/// <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>
@ -33,16 +35,20 @@ namespace AcDream.UI.Abstractions.Panels.Vitals;
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"/> instance. The
/// GUID starts at 0; call <see cref="SetLocalPlayerGuid"/> once the
/// live session assigns it.
/// 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)
public VitalsVM(CombatState combat, LocalPlayerState? localPlayer = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_local = localPlayer;
_localPlayerGuid = 0;
}
@ -61,15 +67,16 @@ public sealed class VitalsVM
public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid);
/// <summary>
/// Stamina percent (0..1) or <c>null</c> when absolute values aren't wired.
/// D.2a always returns <c>null</c>; to be populated by a future
/// <c>LocalPlayerState</c> that caches <c>PlayerDescription (0x0013)</c>.
/// 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 => null;
public float? StaminaPercent => _local?.StaminaPercent;
/// <summary>
/// Mana percent (0..1) or <c>null</c> when absolute values aren't wired.
/// Same status as <see cref="StaminaPercent"/>.
/// Mana percent (0..1), or <c>null</c> under the same conditions as
/// <see cref="StaminaPercent"/>.
/// </summary>
public float? ManaPercent => null;
public float? ManaPercent => _local?.ManaPercent;
}