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>
144 lines
4.7 KiB
C#
144 lines
4.7 KiB
C#
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);
|
|
}
|
|
}
|