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:
parent
9faf9d7e3a
commit
d42bf5735d
8 changed files with 436 additions and 50 deletions
|
|
@ -135,32 +135,15 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## #5 — VitalsPanel stamina/mana bars always null (absolute values not stored)
|
|
||||||
|
|
||||||
**Status:** OPEN
|
|
||||||
**Severity:** LOW (cosmetic — HP bar already works; stam/mana would be a nice-to-have)
|
|
||||||
**Filed:** 2026-04-25
|
|
||||||
**Component:** ui / net / player-state
|
|
||||||
|
|
||||||
**Description:** Phase D.2a shipped `VitalsVM` with `StaminaPercent` / `ManaPercent` returning `float?` null. `VitalsPanel` already renders an HP progress bar from `CombatState.GetHealthPercent(localGuid)` because per-entity health is tracked from combat notifications. Stamina and mana are absolute values and only arrive in `PlayerDescription (0x0013)` — which we currently parse then discard. Result: the Vitals window shows HP only.
|
|
||||||
|
|
||||||
**Root cause / status:** We need a `LocalPlayerState` Core class (analogous to `CombatState` but scoped to the local player) that retains parsed `PlayerDescription` fields — at minimum: `CurrentStamina` + `MaxStamina` + `CurrentMana` + `MaxMana`. `AppraiseInfoParser.CreatureProfile` already has the shape for these values; we just don't persist them.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- `src/AcDream.Core.Net/Parsers/PlayerDescriptionParser.cs` — parses then discards (verify path)
|
|
||||||
- `src/AcDream.Core.Net/Parsers/AppraiseInfoParser.cs` — has `CreatureProfile` with absolute values
|
|
||||||
- `src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs` — `StaminaPercent` / `ManaPercent` would divide `LocalPlayerState.Current*` by `Max*`
|
|
||||||
- `src/AcDream.App/Rendering/GameWindow.cs` — construct `LocalPlayerState`, hand to `VitalsVM`, wire into event dispatch
|
|
||||||
|
|
||||||
**Research:** none needed — wire-level field positions are already decoded in `PlayerDescriptionParser`.
|
|
||||||
|
|
||||||
**Acceptance:** With `ACDREAM_DEVTOOLS=1`, the Vitals window shows three progress bars (HP / Stamina / Mana) that update when the server sends `PlayerDescription` or any delta event (`UpdateHealth`, `UpdateStamina`, `UpdateMana`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Recently closed
|
# Recently closed
|
||||||
|
|
||||||
*(none yet — move DONE items here with closed-date + commit SHA)*
|
## #5 — [DONE 2026-04-25] VitalsPanel stamina/mana bars always null
|
||||||
|
|
||||||
|
**Closed:** 2026-04-25
|
||||||
|
**Commit:** `feat(player): #5 LocalPlayerState — Stam/Mana wired through PlayerDescription`
|
||||||
|
**Resolution:** Added `AcDream.Core.Player.LocalPlayerState` (caches `CurrentStamina` / `MaxStamina` / `CurrentMana` / `MaxMana` from `PlayerDescription (0x0013)`'s `CreatureProfile`). Wired into `GameEventWiring.WireAll` as a new optional 6th parameter so back-compat preserved. `VitalsVM` constructor now takes an optional `LocalPlayerState`; when wired, `StaminaPercent` / `ManaPercent` read through to the cache (no VM-side caching) and `VitalsPanel` automatically draws the two extra bars. Edge cases covered: zero `Max*` returns `null` (no /0); current > max clamps to 1.0; partial profiles preserve previous good values rather than wiping them. 16 new tests (11 `LocalPlayerStateTests` + 3 `VitalsVMTests` + 2 `GameEventWiringTests`).
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Example:
|
Example:
|
||||||
|
|
|
||||||
|
|
@ -280,6 +280,9 @@ public sealed class GameWindow : IDisposable
|
||||||
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
public readonly AcDream.Core.Combat.CombatState Combat = new();
|
||||||
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
|
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
|
||||||
public readonly AcDream.Core.Items.ItemRepository Items = 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.
|
// 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.
|
// 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
|
// VitalsVM: GUID=0 at construction; set later at EnterWorld
|
||||||
// (see the _playerServerGuid assignment path). Pre-login the
|
// (see the _playerServerGuid assignment path). Pre-login the
|
||||||
// HP bar just reads 1.0 (safe default) — harmless.
|
// HP bar just reads 1.0 (safe default) — harmless. Stam/Mana
|
||||||
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat);
|
// bars surface only after the first PlayerDescription has
|
||||||
|
// populated LocalPlayer (Issue #5).
|
||||||
|
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||||
_panelHost.Register(
|
_panelHost.Register(
|
||||||
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm));
|
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm));
|
||||||
|
|
||||||
|
|
@ -1169,7 +1174,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// notifications / spell learns / wield events all update
|
// notifications / spell learns / wield events all update
|
||||||
// the corresponding client-side state without further glue.
|
// the corresponding client-side state without further glue.
|
||||||
AcDream.Core.Net.GameEventWiring.WireAll(
|
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.
|
// Phase H.1: feed inbound HearSpeech into the chat log.
|
||||||
_liveSession.SpeechHeard += speech =>
|
_liveSession.SpeechHeard += speech =>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using AcDream.Core.Chat;
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Player;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
|
|
||||||
namespace AcDream.Core.Net;
|
namespace AcDream.Core.Net;
|
||||||
|
|
@ -34,7 +35,8 @@ public static class GameEventWiring
|
||||||
ItemRepository items,
|
ItemRepository items,
|
||||||
CombatState combat,
|
CombatState combat,
|
||||||
Spellbook spellbook,
|
Spellbook spellbook,
|
||||||
ChatLog chat)
|
ChatLog chat,
|
||||||
|
LocalPlayerState? localPlayer = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||||
ArgumentNullException.ThrowIfNull(items);
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
@ -165,5 +167,31 @@ public static class GameEventWiring
|
||||||
foreach (uint sid in p.Value.SpellBook)
|
foreach (uint sid in p.Value.SpellBook)
|
||||||
spellbook.OnSpellLearned(sid);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
src/AcDream.Core/Player/LocalPlayerState.cs
Normal file
79
src/AcDream.Core/Player/LocalPlayerState.cs
Normal 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 > 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Player;
|
||||||
|
|
||||||
namespace AcDream.UI.Abstractions.Panels.Vitals;
|
namespace AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
|
|
||||||
|
|
@ -8,17 +9,18 @@ namespace AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
/// <c>UpdateHealth (0x01C0)</c> GameEvent).
|
/// <c>UpdateHealth (0x01C0)</c> GameEvent).
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>D.2a scope limits:</b>
|
/// <b>Sources:</b>
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>HP comes from <see cref="CombatState"/> and is <b>percent-only</b>
|
/// <item>HP — <see cref="CombatState.GetHealthPercent"/> (percent-only,
|
||||||
/// (0..1). Absolute current/max HP is not wired yet.</item>
|
/// updated by <c>UpdateHealth (0x01C0)</c>).</item>
|
||||||
/// <item>Stamina / Mana are always <c>null</c> — those values live in
|
/// <item>Stamina / Mana — <see cref="LocalPlayerState"/>'s
|
||||||
/// <c>AppraiseInfoParser.CreatureProfile</c> (parsed from
|
/// <c>StaminaPercent</c> / <c>ManaPercent</c>, populated from the
|
||||||
/// <c>PlayerDescription (0x0013)</c>) but the parsed record is
|
/// <c>CreatureProfile</c> embedded in <c>PlayerDescription
|
||||||
/// currently discarded. Wiring a <c>LocalPlayerState</c> cache is
|
/// (0x0013)</c>. When no <see cref="LocalPlayerState"/> is wired
|
||||||
/// a separate follow-up; see <c>docs/ISSUES.md</c>.</item>
|
/// (older constructor / tests), both stay <c>null</c> and
|
||||||
|
/// <c>VitalsPanel</c> simply skips those bars.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|
@ -33,16 +35,20 @@ namespace AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
public sealed class VitalsVM
|
public sealed class VitalsVM
|
||||||
{
|
{
|
||||||
private readonly CombatState _combat;
|
private readonly CombatState _combat;
|
||||||
|
private readonly LocalPlayerState? _local;
|
||||||
private uint _localPlayerGuid;
|
private uint _localPlayerGuid;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build a VitalsVM bound to a <see cref="CombatState"/> instance. The
|
/// Build a VitalsVM bound to a <see cref="CombatState"/> and (optionally)
|
||||||
/// GUID starts at 0; call <see cref="SetLocalPlayerGuid"/> once the
|
/// a <see cref="LocalPlayerState"/>. The GUID starts at 0; call
|
||||||
/// live session assigns it.
|
/// <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>
|
/// </summary>
|
||||||
public VitalsVM(CombatState combat)
|
public VitalsVM(CombatState combat, LocalPlayerState? localPlayer = null)
|
||||||
{
|
{
|
||||||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||||
|
_local = localPlayer;
|
||||||
_localPlayerGuid = 0;
|
_localPlayerGuid = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,15 +67,16 @@ public sealed class VitalsVM
|
||||||
public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid);
|
public float HealthPercent => _combat.GetHealthPercent(_localPlayerGuid);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stamina percent (0..1) or <c>null</c> when absolute values aren't wired.
|
/// Stamina percent (0..1), or <c>null</c> when no
|
||||||
/// D.2a always returns <c>null</c>; to be populated by a future
|
/// <see cref="LocalPlayerState"/> is wired or it hasn't received a
|
||||||
/// <c>LocalPlayerState</c> that caches <c>PlayerDescription (0x0013)</c>.
|
/// <c>PlayerDescription</c> with both current and max yet. Reads
|
||||||
|
/// through to the cache every access — no VM-side caching.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? StaminaPercent => null;
|
public float? StaminaPercent => _local?.StaminaPercent;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Mana percent (0..1) or <c>null</c> when absolute values aren't wired.
|
/// Mana percent (0..1), or <c>null</c> under the same conditions as
|
||||||
/// Same status as <see cref="StaminaPercent"/>.
|
/// <see cref="StaminaPercent"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ManaPercent => null;
|
public float? ManaPercent => _local?.ManaPercent;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using AcDream.Core.Combat;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Player;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -46,6 +47,57 @@ public sealed class GameEventWiringTests
|
||||||
return (dispatcher, items, combat, spellbook, chat);
|
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]
|
[Fact]
|
||||||
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
||||||
{
|
{
|
||||||
|
|
@ -143,4 +195,46 @@ public sealed class GameEventWiringTests
|
||||||
|
|
||||||
Assert.Equal(0, book.ActiveCount);
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
144
tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs
Normal file
144
tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
using AcDream.Core.Player;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Player;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="LocalPlayerState"/> — the cache that retains
|
||||||
|
/// stamina / mana absolute + max values from <c>PlayerDescription
|
||||||
|
/// (0x0013)</c>'s embedded <c>CreatureProfile</c>. Health stays in
|
||||||
|
/// <see cref="AcDream.Core.Combat.CombatState"/>; this class only
|
||||||
|
/// covers the vitals that don't have a dedicated delta opcode in
|
||||||
|
/// our currently-wired event set.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalPlayerStateTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_AllVitalsNull_PercentsNull()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
|
||||||
|
Assert.Null(s.CurrentStamina);
|
||||||
|
Assert.Null(s.MaxStamina);
|
||||||
|
Assert.Null(s.CurrentMana);
|
||||||
|
Assert.Null(s.MaxMana);
|
||||||
|
Assert.Null(s.StaminaPercent);
|
||||||
|
Assert.Null(s.ManaPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnPlayerDescription_PopulatesFields_FromValidValues()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
|
||||||
|
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
|
||||||
|
currentMana: 150, maxMana: 200);
|
||||||
|
|
||||||
|
Assert.Equal(50u, s.CurrentStamina);
|
||||||
|
Assert.Equal(100u, s.MaxStamina);
|
||||||
|
Assert.Equal(150u, s.CurrentMana);
|
||||||
|
Assert.Equal(200u, s.MaxMana);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StaminaPercent_IsCurrentOverMax_InZeroToOneRange()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
|
||||||
|
currentMana: null, maxMana: null);
|
||||||
|
|
||||||
|
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManaPercent_IsCurrentOverMax_InZeroToOneRange()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
s.OnPlayerDescription(currentStamina: null, maxStamina: null,
|
||||||
|
currentMana: 75, maxMana: 100);
|
||||||
|
|
||||||
|
Assert.Equal(0.75f, s.ManaPercent!.Value, precision: 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StaminaPercent_NullWhenMaxIsZero_AvoidsDivByZero()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
s.OnPlayerDescription(currentStamina: 0, maxStamina: 0,
|
||||||
|
currentMana: null, maxMana: null);
|
||||||
|
|
||||||
|
Assert.Null(s.StaminaPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManaPercent_NullWhenMaxIsZero_AvoidsDivByZero()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
s.OnPlayerDescription(currentStamina: null, maxStamina: null,
|
||||||
|
currentMana: 0, maxMana: 0);
|
||||||
|
|
||||||
|
Assert.Null(s.ManaPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StaminaPercent_Null_WhenOnlyCurrentKnown()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
s.OnPlayerDescription(currentStamina: 50, maxStamina: null,
|
||||||
|
currentMana: null, maxMana: null);
|
||||||
|
// Max never received → percent indeterminate.
|
||||||
|
Assert.Null(s.StaminaPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StaminaPercent_Null_WhenOnlyMaxKnown()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
s.OnPlayerDescription(currentStamina: null, maxStamina: 100,
|
||||||
|
currentMana: null, maxMana: null);
|
||||||
|
// Current never received → percent indeterminate.
|
||||||
|
Assert.Null(s.StaminaPercent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StaminaPercent_ClampsToOne_WhenCurrentExceedsMax()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
// Server can momentarily report current > max during buff transitions.
|
||||||
|
s.OnPlayerDescription(currentStamina: 150, maxStamina: 100,
|
||||||
|
currentMana: null, maxMana: null);
|
||||||
|
|
||||||
|
Assert.Equal(1f, s.StaminaPercent!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Changed_EventFires_WhenAnyVitalUpdates()
|
||||||
|
{
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
int fires = 0;
|
||||||
|
s.Changed += _ => fires++;
|
||||||
|
|
||||||
|
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
|
||||||
|
currentMana: 75, maxMana: 200);
|
||||||
|
|
||||||
|
Assert.Equal(1, fires);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnPlayerDescription_PreservesPreviousField_WhenIncomingValueIsNull()
|
||||||
|
{
|
||||||
|
// CreatureProfile occasionally has nullable fields if the server
|
||||||
|
// sends a partial profile — the cache should preserve known-good
|
||||||
|
// values rather than wipe them. Stamina set first, then a Mana-only
|
||||||
|
// update should not clear Stamina.
|
||||||
|
var s = new LocalPlayerState();
|
||||||
|
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
|
||||||
|
currentMana: null, maxMana: null);
|
||||||
|
s.OnPlayerDescription(currentStamina: null, maxStamina: null,
|
||||||
|
currentMana: 75, maxMana: 200);
|
||||||
|
|
||||||
|
Assert.Equal(50u, s.CurrentStamina);
|
||||||
|
Assert.Equal(100u, s.MaxStamina);
|
||||||
|
Assert.Equal(75u, s.CurrentMana);
|
||||||
|
Assert.Equal(200u, s.MaxMana);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.Core.Combat;
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Player;
|
||||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
|
|
||||||
namespace AcDream.UI.Abstractions.Tests;
|
namespace AcDream.UI.Abstractions.Tests;
|
||||||
|
|
@ -39,22 +40,67 @@ public sealed class VitalsVMTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void StaminaPercent_IsNull_ForD2aScope()
|
public void StaminaPercent_IsNull_WhenNoLocalPlayerStateProvided()
|
||||||
{
|
{
|
||||||
// D.2a explicitly defers Stamina until LocalPlayerState + PlayerDescription
|
// Back-compat with the original D.2a constructor — when no
|
||||||
// wiring. When that arrives VitalsVM.StaminaPercent becomes non-null and
|
// LocalPlayerState is wired, Stam/Mana remain null and
|
||||||
// VitalsPanel starts drawing the Stam bar automatically.
|
// VitalsPanel skips drawing those bars.
|
||||||
var vm = new VitalsVM(new CombatState());
|
var vm = new VitalsVM(new CombatState());
|
||||||
Assert.Null(vm.StaminaPercent);
|
Assert.Null(vm.StaminaPercent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ManaPercent_IsNull_ForD2aScope()
|
public void ManaPercent_IsNull_WhenNoLocalPlayerStateProvided()
|
||||||
{
|
{
|
||||||
var vm = new VitalsVM(new CombatState());
|
var vm = new VitalsVM(new CombatState());
|
||||||
Assert.Null(vm.ManaPercent);
|
Assert.Null(vm.ManaPercent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StaminaPercent_FromLocalPlayerState_AfterPlayerDescription()
|
||||||
|
{
|
||||||
|
// Issue #5 — once a LocalPlayerState is wired and the server has
|
||||||
|
// sent a PlayerDescription with CreatureProfile, the Stam bar
|
||||||
|
// surfaces the correct percent without any VM-level caching.
|
||||||
|
var local = new LocalPlayerState();
|
||||||
|
local.OnPlayerDescription(currentStamina: 80, maxStamina: 100,
|
||||||
|
currentMana: null, maxMana: null);
|
||||||
|
|
||||||
|
var vm = new VitalsVM(new CombatState(), local);
|
||||||
|
|
||||||
|
Assert.Equal(0.8f, vm.StaminaPercent!.Value, precision: 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ManaPercent_FromLocalPlayerState_AfterPlayerDescription()
|
||||||
|
{
|
||||||
|
var local = new LocalPlayerState();
|
||||||
|
local.OnPlayerDescription(currentStamina: null, maxStamina: null,
|
||||||
|
currentMana: 25, maxMana: 100);
|
||||||
|
|
||||||
|
var vm = new VitalsVM(new CombatState(), local);
|
||||||
|
|
||||||
|
Assert.Equal(0.25f, vm.ManaPercent!.Value, precision: 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Vm_ReadsThroughToLocalPlayerState_NoStaleCache()
|
||||||
|
{
|
||||||
|
// Verify the VM doesn't snapshot — it should read live from the
|
||||||
|
// LocalPlayerState every property access so a server delta picks
|
||||||
|
// up next frame without any explicit refresh call.
|
||||||
|
var local = new LocalPlayerState();
|
||||||
|
var vm = new VitalsVM(new CombatState(), local);
|
||||||
|
|
||||||
|
Assert.Null(vm.StaminaPercent); // no data yet
|
||||||
|
|
||||||
|
local.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
|
||||||
|
currentMana: 50, maxMana: 100);
|
||||||
|
|
||||||
|
Assert.Equal(0.5f, vm.StaminaPercent!.Value, precision: 3);
|
||||||
|
Assert.Equal(0.5f, vm.ManaPercent!.Value, precision: 3);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetLocalPlayerGuid_ReroutesHealthLookup_WithoutStaleCache()
|
public void SetLocalPlayerGuid_ReroutesHealthLookup_WithoutStaleCache()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue