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

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