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

@ -6,6 +6,7 @@ using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
using AcDream.Core.Spells;
using Xunit;
@ -46,6 +47,57 @@ public sealed class GameEventWiringTests
return (dispatcher, items, combat, spellbook, chat);
}
private static (GameEventDispatcher, ItemRepository, CombatState, Spellbook, ChatLog, LocalPlayerState) MakeAllWithLocal()
{
var dispatcher = new GameEventDispatcher();
var items = new ItemRepository();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
var local = new LocalPlayerState();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, local);
return (dispatcher, items, combat, spellbook, chat, local);
}
/// <summary>
/// Build a minimal AppraiseInfo body containing only a CreatureProfile
/// blob with ShowAttributes (flag 0x08) so stamina + mana fields are
/// present. Mirrors the wire shape that PlayerDescription (0x0013)
/// carries for the local player.
/// </summary>
private static byte[] MakePlayerDescriptionPayload(
uint guid, uint health, uint healthMax,
uint stamina, uint mana, uint staminaMax, uint manaMax)
{
// Outer header: u32 guid, u32 outerFlags, u32 success.
// Outer flags: just CreatureProfile (0x2000).
// Profile blob: u32 innerFlags, u32 health, u32 healthMax, then 10 u32s
// (str/end/quic/coord/focus/self/sta/mana/staMax/manaMax) when 0x08 set.
const uint outerFlags = 0x0000_2000u; // CreatureProfile
const uint innerFlags = 0x08u; // ShowAttributes
byte[] body = new byte[12 + 12 + 10 * 4];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), guid); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), outerFlags); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // success
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), innerFlags); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), health); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), healthMax); p += 4;
// Stub attributes — VM doesn't read these, parser still has to skip them.
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // str
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // end
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // quic
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // coord
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // focus
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u); p += 4; // self
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), stamina); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), mana); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), staminaMax); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), manaMax); p += 4;
return body;
}
[Fact]
public void WireAll_ChannelBroadcast_RoutesToChatLog()
{
@ -143,4 +195,46 @@ public sealed class GameEventWiringTests
Assert.Equal(0, book.ActiveCount);
}
[Fact]
public void WireAll_PlayerDescription_PopulatesLocalPlayerState()
{
// Issue #5 — the PlayerDescription (0x0013) opcode shares the
// AppraiseInfo payload with IdentifyObjectResponse (0x00C9). Now
// also funnels CreatureProfile.{Stamina, Mana, StaminaMax, ManaMax}
// into LocalPlayerState so the Vitals HUD can render those bars.
var (d, _, _, _, _, local) = MakeAllWithLocal();
byte[] payload = MakePlayerDescriptionPayload(
guid: 0x5000_000Au,
health: 100, healthMax: 200,
stamina: 75, mana: 150, staminaMax: 100, manaMax: 200);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, payload));
d.Dispatch(env!.Value);
Assert.Equal(75u, local.CurrentStamina);
Assert.Equal(100u, local.MaxStamina);
Assert.Equal(150u, local.CurrentMana);
Assert.Equal(200u, local.MaxMana);
Assert.Equal(0.75f, local.StaminaPercent!.Value, precision: 3);
Assert.Equal(0.75f, local.ManaPercent!.Value, precision: 3);
}
[Fact]
public void WireAll_PlayerDescription_NoOp_WhenLocalPlayerStateNotProvided()
{
// Back-compat: the original 5-arg overload still works; without a
// LocalPlayerState reference there's no place to push the parsed
// CreatureProfile, but the dispatch must not throw.
var (d, _, _, _, _) = MakeAll();
byte[] payload = MakePlayerDescriptionPayload(
guid: 0x5000_000Au,
health: 100, healthMax: 200,
stamina: 75, mana: 150, staminaMax: 100, manaMax: 200);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, payload));
d.Dispatch(env!.Value); // must not throw
}
}