feat(player): #5 PlayerDescription parser — Stam/Mana via attribute block

Visual-verified — Vitals window now shows three bars (HP/Stam/Mana)
with live values. Closes ISSUES.md #5; ~95% reading on Stam/Mana
traced to active buff multipliers, filed as #6.

Why the rewrite

The first attempt (commit d42bf57) routed PlayerDescription (0x0013)
through AppraiseInfoParser, trusting a misleading xmldoc claim.
Live diagnostics proved the format is wrong — ACE source
(GameEventPlayerDescription.WriteEventBody) hand-writes a body
distinct from IdentifyObjectResponse's AppraiseInfo: property
hashtables gated on DescriptionPropertyFlag, vector-flag-gated
attribute / skill / spell blocks, then a long options + inventory
trailer. Vitals only arrive via the attribute block at login.
Holtburger's events.rs:220-625 has the canonical client-side
unpacker; this commit ports the early-section walker through spells.

What landed

  PlayerDescriptionParser.cs (new — 350 LOC):
    Walks propertyFlags + weenieType, then property hashtables
    (Int32/Int64/Bool/Double/String/Did/Iid) + Position table —
    each gated on a property flag bit, header is `u16 count, u16
    buckets`. Then vectorFlags + has_health + the attribute block
    (primary attrs 1..6 = 12 B each, vitals 7..9 = 16 B with
    `current`), then optional Skill + Spell tables. Stops cleanly
    before the options/shortcuts/hotbars/inventory trailer (filed
    as #7 — heuristic alignment search needed for gameplay_options).

  PrivateUpdateVital.cs (new — 95 LOC):
    Wire parsers for the GameMessage opcodes 0x02E7 (full snapshot)
    and 0x02E9 (current-only delta), per holtburger UpdateVital +
    UpdateVitalCurrent. WorldSession dispatches each to a session-
    level event the GameWindow forwards into LocalPlayerState.

  LocalPlayerState (full redesign):
    VitalKind (Health/Stamina/Mana) + AttributeKind (six primary).
    VitalSnapshot stores ranks/start/xp/current; AttributeSnapshot
    stores ranks/start/xp with `Current = ranks+start` per
    holtburger. GetMaxApprox computes the retail formula
        vital.(ranks+start) + attribute_contribution
    where the contribution is hardcoded from retail's
    SecondaryAttributeTable: Endurance/2 for Health, Endurance for
    Stamina, Self for Mana. Enchantment buffs not yet folded in
    (filed as #6). VitalIdToKind now accepts both ID systems
    (1..6 wire, 7..9 PD attribute block); AttributeIdToKind covers
    primary attrs 1..6.

  GameEventWiring:
    PlayerDescription handler. Walks parsed.Attributes, routes
    primary attrs (id 1..6) to OnAttributeUpdate and vitals
    (id 7..9) to OnVitalUpdate. Player's full learned spellbook
    also lands here. ACDREAM_DUMP_VITALS=1 traces every PD attribute
    + every PrivateUpdateVital(Current) opcode for diagnostics.

  WorldSession:
    Dispatch chain re-ordered — the diagnostic else-if for
    ACDREAM_DUMP_OPCODES=1 was originally placed before
    GameEventEnvelope.Opcode, which silently intercepted 0xF7B0 and
    broke UpdateHealth dispatch when the env var was set. Moved to
    the very end of the chain so it only fires for genuinely
    unhandled opcodes. (Diagnostic-only regression; production
    launches without the env var were unaffected.)

Test deltas

  Added:
    - PlayerDescriptionParserTests (6 — empty header, full attribute
      block, partial flags, post-property-table walk, spell table)
    - PrivateUpdateVitalTests (7 — fixture round-trip, vital ID
      coverage, opcode rejection, truncation)
    - LocalPlayerStateTests rewritten (20 — VitalIdToKind +
      AttributeIdToKind theories, Endurance/Self formula coverage,
      delta semantics, change events)
    - GameEventWiringTests for PlayerDescription dispatch (2 —
      end-to-end populate + spellbook feed)
  Updated:
    - VitalsVMTests rephrased onto the new OnVitalUpdate API.
  Total: 765 → 817 tests passing.

Diagnostics

  ACDREAM_DUMP_VITALS=1 — log every PD attribute extracted,
    every 0x02E7/0x02E9 dispatch.
  ACDREAM_DUMP_OPCODES=1 — log first occurrence of any unhandled
    GameMessage opcode (now correctly placed at end of chain).

Visual verify

  $env:ACDREAM_DEVTOOLS = "1"
  dotnet run --project src\AcDream.App\AcDream.App.csproj -c Debug

  Vitals window shows three bars; HP at 100%, Stam/Mana at ~95%
  (the gap is buff enchantments — filed as #6 with the holtburger
  multiplier+additive aggregator pattern as the reference for the
  fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 16:42:24 +02:00
parent d42bf5735d
commit 7da2a027d4
14 changed files with 1660 additions and 272 deletions

View file

@ -47,56 +47,6 @@ 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()
@ -182,6 +132,114 @@ public sealed class GameEventWiringTests
Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind);
}
[Fact]
public void WireAll_PlayerDescription_PopulatesLocalPlayerStateVitals()
{
// Issue #5 — the full pipeline: synthetic 0xF7B0 envelope wrapping
// a PlayerDescription body with Health/Stam/Mana entries, dispatched
// through WireAll, lands in LocalPlayerState with the right
// ranks/start/current values.
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);
// Body: empty property tables, ATTRIBUTE vector flag, all 9 attrs
// present. Primary attrs:
// Endurance (id=2): ranks=50 + start=150 → current=200
// Self (id=6): ranks=50 + start=50 → current=100
// Vitals (ranks+start = 0 — typical retail values):
// Health (id=7) cur=90 → MaxApprox = 0 + 200/2 = 100 → percent 0.9
// Stamina (id=8) cur=140 → MaxApprox = 0 + 200 = 200 → percent 0.7
// Mana (id=9) cur=50 → MaxApprox = 0 + 100 = 100 → percent 0.5
byte[] body = new byte[140];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // propertyFlags
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x52u); p += 4; // weenieType
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4; // vectorFlags = ATTRIBUTE
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // has_health
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1FFu); p += 4; // attribute_flags = Full
// Primary attrs in order 1..6.
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 1 Strength
WritePrimaryAttr(body, ref p, ranks: 50, start: 150, xp: 0); // 2 Endurance — current=200
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 3 Quickness
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 4 Coordination
WritePrimaryAttr(body, ref p, ranks: 0, start: 50, xp: 0); // 5 Focus
WritePrimaryAttr(body, ref p, ranks: 50, start: 50, xp: 0); // 6 Self — current=100
// Vitals 7/8/9.
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 90); // Health
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 140); // Stamina
WriteVitalBlock(body, ref p, ranks: 0, start: 0, xp: 0, current: 50); // Mana
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, body));
dispatcher.Dispatch(env!.Value);
var health = local.Get(LocalPlayerState.VitalKind.Health);
var stam = local.Get(LocalPlayerState.VitalKind.Stamina);
var mana = local.Get(LocalPlayerState.VitalKind.Mana);
Assert.NotNull(health);
Assert.NotNull(stam);
Assert.NotNull(mana);
Assert.Equal(90u, health!.Value.Current);
Assert.Equal(140u, stam!.Value.Current);
Assert.Equal(50u, mana!.Value.Current);
// Primary attrs landed too — formula contributions feed the max.
Assert.Equal(200u, local.GetAttribute(LocalPlayerState.AttributeKind.Endurance)!.Value.Current);
Assert.Equal(100u, local.GetAttribute(LocalPlayerState.AttributeKind.Self)!.Value.Current);
Assert.Equal(0.9f, local.HealthPercent!.Value, precision: 3);
Assert.Equal(0.7f, local.StaminaPercent!.Value, precision: 3);
Assert.Equal(0.5f, local.ManaPercent!.Value, precision: 3);
}
[Fact]
public void WireAll_PlayerDescription_FeedsSpellbook()
{
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);
// Body: SPELL vector flag + a spell table with 2 entries.
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags
w.Write(0x52u); // weenieType
w.Write(0x100u); // vectorFlags = SPELL only
w.Write(0u); // has_health = false
w.Write((ushort)2); // spell count
w.Write((ushort)64); // buckets
w.Write(0x3E1u); w.Write(2.0f);
w.Write(0x3E2u); w.Write(2.0f);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
Assert.True(spellbook.Knows(0x3E1u));
Assert.True(spellbook.Knows(0x3E2u));
}
private static void WriteVitalBlock(byte[] body, ref int p, uint ranks, uint start, uint xp, uint current)
{
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), current); p += 4;
}
private static void WritePrimaryAttr(byte[] body, ref int p, uint ranks, uint start, uint xp)
{
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
}
[Fact]
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
{
@ -196,45 +254,4 @@ 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
}
}

View file

@ -0,0 +1,235 @@
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Wire-format tests for <see cref="PlayerDescriptionParser"/>.
/// Builds synthetic payloads matching ACE
/// <c>GameEventPlayerDescription.WriteEventBody</c> and confirms the
/// walker extracts the attribute block + early sections correctly.
/// </summary>
public sealed class PlayerDescriptionParserTests
{
/// <summary>
/// Build a minimal PlayerDescription payload with empty property
/// flags + no-attribute vector flags. Just header bytes — useful
/// for testing the most basic walk.
/// </summary>
private static byte[] BuildEmpty(uint weenieType = 1u)
{
// u32 propertyFlags + u32 weenieType + u32 vectorFlags + u32 has_health
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0u); // no property flags
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), weenieType);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u); // no vector flags
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), 0u); // has_health=false
return body;
}
/// <summary>
/// Build a payload with an Attribute-block-only body
/// (vector_flags = ATTRIBUTE) populating all 9 entries with known
/// ranks/start/xp values — primary attrs 1..6 and vitals 7..9.
/// </summary>
private static byte[] BuildWithFullAttributeBlock(
uint healthCurrent, uint stamCurrent, uint manaCurrent)
{
// No property tables, no positions.
// Header (8) + vectorFlags+has_health (8) + attribFlags (4)
// + 6 primary entries × 12 + 3 vital entries × 16 = 8+8+4+72+48 = 140.
byte[] body = new byte[140];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // propertyFlags = 0
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // weenieType
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4; // vectorFlags = ATTRIBUTE
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4; // has_health = true
// attributeFlags = AttributeCache.Full = 0x1FF (bits 0..8)
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1FFu); p += 4;
// Primary attrs 1..6 — ranks/start/xp triplets.
for (uint i = 1; i <= 6; i++)
{
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 10u * i); p += 4; // ranks
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 50u + i); p += 4; // start
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1000u); p += 4; // xp
}
// Vitals 7..9 — Health (id=7), Stamina (id=8), Mana (id=9).
// ranks=20, start=80 → MaxApprox = 100. Currents = test args.
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: healthCurrent);
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: stamCurrent);
WriteVital(body, ref p, ranks: 20u, start: 80u, xp: 5000u, current: manaCurrent);
return body;
}
private static void WriteVital(byte[] body, ref int p, uint ranks, uint start, uint xp, uint current)
{
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), ranks); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), start); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), xp); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), current); p += 4;
}
[Fact]
public void TryParse_ReturnsNull_OnTruncatedHeader()
{
byte[] tooShort = new byte[4];
Assert.Null(PlayerDescriptionParser.TryParse(tooShort));
}
[Fact]
public void TryParse_EmptyBody_ParsesHeaderOnly()
{
var p = PlayerDescriptionParser.TryParse(BuildEmpty(weenieType: 0x52u));
Assert.NotNull(p);
Assert.Equal(0x52u, p!.Value.WeenieType);
Assert.Equal(PlayerDescriptionParser.DescriptionPropertyFlag.None, p.Value.PropertyFlags);
Assert.Equal(PlayerDescriptionParser.DescriptionVectorFlag.None, p.Value.VectorFlags);
Assert.False(p.Value.HasHealth);
Assert.Empty(p.Value.Attributes);
}
[Fact]
public void TryParse_AttributeBlock_PopulatesAllNineEntries()
{
var p = PlayerDescriptionParser.TryParse(
BuildWithFullAttributeBlock(healthCurrent: 90, stamCurrent: 75, manaCurrent: 60));
Assert.NotNull(p);
Assert.True(p!.Value.HasHealth);
Assert.Equal(9, p.Value.Attributes.Count);
// Primary attrs 1..6 have null Current.
for (uint i = 1; i <= 6; i++)
{
var attr = p.Value.Attributes.First(a => a.AtType == i);
Assert.Equal(10u * i, attr.Ranks);
Assert.Equal(50u + i, attr.Start);
Assert.Equal(1000u, attr.Xp);
Assert.Null(attr.Current);
}
// Vital 7 = Health (current = 90).
var health = p.Value.Attributes.First(a => a.AtType == 7);
Assert.Equal(20u, health.Ranks);
Assert.Equal(80u, health.Start);
Assert.Equal(90u, health.Current);
// Vital 8 = Stamina.
var stam = p.Value.Attributes.First(a => a.AtType == 8);
Assert.Equal(75u, stam.Current);
// Vital 9 = Mana.
var mana = p.Value.Attributes.First(a => a.AtType == 9);
Assert.Equal(60u, mana.Current);
}
[Fact]
public void TryParse_SkipsPrimaryAttribute_WhenItsAttributeFlagBitIsClear()
{
// attribute_flags only sets bits for Strength (id=1) and Health (id=7).
// Body shape: 8 header + 8 vector header + 4 attr_flags + 12 (str) + 16 (health) = 48
byte[] body = new byte[48];
int p = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x01u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 1u); p += 4;
// bit 0 (Strength = id 1) + bit 6 (Health = id 7) = 0x41
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x41u); p += 4;
// Strength entry (12 B):
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 100u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 60u); p += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4;
// Health entry (16 B):
WriteVital(body, ref p, ranks: 25u, start: 75u, xp: 0u, current: 99u);
var parsed = PlayerDescriptionParser.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Attributes.Count);
Assert.Equal(1u, parsed.Value.Attributes[0].AtType); // Strength
Assert.Equal(7u, parsed.Value.Attributes[1].AtType); // Health
Assert.Equal(99u, parsed.Value.Attributes[1].Current);
}
[Fact]
public void TryParse_PropertyTablesWalked_OffsetReachesAttributeBlock()
{
// PROPERTY_INT32 + PROPERTY_STRING tables present, then ATTRIBUTE
// block. If the walker can't skip past the property tables it'll
// read garbage from the table bytes as if it were the attribute
// block — this test fails on any walking error.
// PROPERTY_INT32 = 0x0001, PROPERTY_STRING = 0x0010 → flags = 0x0011
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0x0011u); // propertyFlags = INT32 | STRING
writer.Write(0x52u); // weenieType
// INT32 table: 2 entries.
writer.Write((ushort)2);
writer.Write((ushort)32); // buckets (ignored)
writer.Write(101u); writer.Write(42);
writer.Write(102u); writer.Write(-7);
// STRING table: 1 entry "Acdream" (7 chars + 2 length + 3 padding = 12).
writer.Write((ushort)1);
writer.Write((ushort)32); // buckets
writer.Write(1u); // PropertyString.Name = 1
byte[] name = Encoding.ASCII.GetBytes("Acdream");
writer.Write((ushort)name.Length);
writer.Write(name);
writer.Write(new byte[(4 - ((2 + name.Length) & 3)) & 3]); // pad to 4
// vectorFlags = ATTRIBUTE, has_health = 1.
writer.Write(0x01u);
writer.Write(1u);
// Attribute block: only Health (bit 6 = 0x40), current=88.
writer.Write(0x40u);
WriteVitalToWriter(writer, ranks: 30u, start: 70u, xp: 0u, current: 88u);
byte[] body = sb.ToArray();
var parsed = PlayerDescriptionParser.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Properties.Ints.Count);
Assert.Equal(42, parsed.Value.Properties.Ints[101]);
Assert.Equal(-7, parsed.Value.Properties.Ints[102]);
Assert.Equal("Acdream", parsed.Value.Properties.Strings[1]);
Assert.Single(parsed.Value.Attributes);
Assert.Equal(7u, parsed.Value.Attributes[0].AtType);
Assert.Equal(88u, parsed.Value.Attributes[0].Current);
}
private static void WriteVitalToWriter(BinaryWriter w, uint ranks, uint start, uint xp, uint current)
{
w.Write(ranks); w.Write(start); w.Write(xp); w.Write(current);
}
[Fact]
public void TryParse_SpellTable_PopulatesSpellsDictionary()
{
// ATTRIBUTE | SPELL = 0x101. Empty attribute block (flags=0). Spell
// table with two entries.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x101u); // vectorFlags = ATTRIBUTE | SPELL
writer.Write(1u); // has_health
writer.Write(0u); // attribute_flags = 0 → no entries
writer.Write((ushort)2); // spell count
writer.Write((ushort)64); // spell buckets
writer.Write(1234u); writer.Write(2.0f);
writer.Write(5678u); writer.Write(2.0f);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Spells.Count);
Assert.Equal(2.0f, parsed.Value.Spells[1234u]);
Assert.Equal(2.0f, parsed.Value.Spells[5678u]);
}
}

View file

@ -0,0 +1,121 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Wire-format tests for <see cref="PrivateUpdateVital.TryParseFull"/>
/// + <see cref="PrivateUpdateVital.TryParseCurrent"/>. Cross-checks
/// holtburger's published test fixture (<c>UPDATE_VITAL_CURRENT_PRIVATE</c>).
/// </summary>
public sealed class PrivateUpdateVitalTests
{
private static byte[] BuildFull(byte seq, uint vital, uint ranks, uint start, uint xp, uint current)
{
// u32 opcode (0x02E7) + u8 seq + 5 * u32 = 25 bytes
byte[] body = new byte[25];
BinaryPrimitives.WriteUInt32LittleEndian(body, PrivateUpdateVital.FullOpcode);
body[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), vital);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(9), ranks);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(13), start);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(17), xp);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(21), current);
return body;
}
private static byte[] BuildCurrent(byte seq, uint vital, uint current)
{
// u32 opcode (0x02E9) + u8 seq + 2 * u32 = 13 bytes
byte[] body = new byte[13];
BinaryPrimitives.WriteUInt32LittleEndian(body, PrivateUpdateVital.CurrentOpcode);
body[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), vital);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(9), current);
return body;
}
[Fact]
public void TryParseFull_RoundTrip()
{
// Mirrors holtburger's test_private_update_vital_fixture values:
// sequence: 12, vital: 2 (Health), ranks: 100, start: 12345, xp: 67890, current: 100
var bytes = BuildFull(seq: 12, vital: 2, ranks: 100, start: 12345, xp: 67890, current: 100);
var p = PrivateUpdateVital.TryParseFull(bytes);
Assert.NotNull(p);
Assert.Equal((byte)12, p!.Value.Sequence);
Assert.Equal(2u, p.Value.VitalId);
Assert.Equal(100u, p.Value.Ranks);
Assert.Equal(12345u, p.Value.Start);
Assert.Equal(67890u, p.Value.Xp);
Assert.Equal(100u, p.Value.Current);
}
[Fact]
public void TryParseCurrent_RoundTrip()
{
// Mirrors holtburger's test_private_update_vital_current_fixture:
// sequence: 12, vital: 2 (Health), current: 100
var bytes = BuildCurrent(seq: 12, vital: 2, current: 100);
var p = PrivateUpdateVital.TryParseCurrent(bytes);
Assert.NotNull(p);
Assert.Equal((byte)12, p!.Value.Sequence);
Assert.Equal(2u, p.Value.VitalId);
Assert.Equal(100u, p.Value.Current);
}
[Fact]
public void TryParseFull_RejectsWrongOpcode()
{
var bytes = BuildFull(seq: 12, vital: 2, ranks: 100, start: 12345, xp: 67890, current: 100);
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEAD_BEEFu);
Assert.Null(PrivateUpdateVital.TryParseFull(bytes));
}
[Fact]
public void TryParseCurrent_RejectsWrongOpcode()
{
var bytes = BuildCurrent(seq: 12, vital: 2, current: 100);
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0xDEAD_BEEFu);
Assert.Null(PrivateUpdateVital.TryParseCurrent(bytes));
}
[Fact]
public void TryParseFull_RejectsTruncatedBody()
{
var full = BuildFull(seq: 12, vital: 2, ranks: 100, start: 0, xp: 0, current: 100);
// 24 bytes — one short of 25.
Assert.Null(PrivateUpdateVital.TryParseFull(full[..24]));
}
[Fact]
public void TryParseCurrent_RejectsTruncatedBody()
{
var current = BuildCurrent(seq: 1, vital: 4, current: 50);
// 12 bytes — one short of 13.
Assert.Null(PrivateUpdateVital.TryParseCurrent(current[..12]));
}
[Fact]
public void TryParseFull_ParsesStaminaAndManaIds()
{
// Spot-check Stamina (vital=4) and Mana (vital=6) — same byte layout.
var stam = PrivateUpdateVital.TryParseFull(
BuildFull(seq: 7, vital: 4, ranks: 50, start: 100, xp: 0, current: 75));
Assert.NotNull(stam);
Assert.Equal(4u, stam!.Value.VitalId);
Assert.Equal(75u, stam.Value.Current);
var mana = PrivateUpdateVital.TryParseFull(
BuildFull(seq: 8, vital: 6, ranks: 30, start: 80, xp: 0, current: 110));
Assert.NotNull(mana);
Assert.Equal(6u, mana!.Value.VitalId);
Assert.Equal(110u, mana.Value.Current);
}
}

View file

@ -3,12 +3,17 @@ 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.
/// Tests for <see cref="LocalPlayerState"/> — per-vital + per-attribute
/// cache populated from <c>PlayerDescription</c>'s attribute block (ids
/// 1..=6 primary attrs, 7..=9 vitals) and from
/// <c>PrivateUpdateVital(Current)</c> deltas (ids 1..=6 in their
/// alternate role as wire-opcode <c>Vital</c> ids).
///
/// <para>
/// Max formula tested here: <c>vital.(ranks+start) + attribute_contribution</c>
/// with retail coefficients (Endurance/2 for Health, Endurance for Stamina,
/// Self for Mana). See class doc.
/// </para>
/// </summary>
public sealed class LocalPlayerStateTests
{
@ -17,128 +22,226 @@ public sealed class LocalPlayerStateTests
{
var s = new LocalPlayerState();
Assert.Null(s.CurrentStamina);
Assert.Null(s.MaxStamina);
Assert.Null(s.CurrentMana);
Assert.Null(s.MaxMana);
Assert.Null(s.Get(LocalPlayerState.VitalKind.Health));
Assert.Null(s.Get(LocalPlayerState.VitalKind.Stamina));
Assert.Null(s.Get(LocalPlayerState.VitalKind.Mana));
Assert.Null(s.HealthPercent);
Assert.Null(s.StaminaPercent);
Assert.Null(s.ManaPercent);
}
[Fact]
public void OnPlayerDescription_PopulatesFields_FromValidValues()
[Theory]
// Wire opcode IDs (PrivateUpdateVital + PrivateUpdateVitalCurrent),
// ACE Vital enum: Undef=0, MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6.
[InlineData(1u, LocalPlayerState.VitalKind.Health)]
[InlineData(2u, LocalPlayerState.VitalKind.Health)]
[InlineData(3u, LocalPlayerState.VitalKind.Stamina)]
[InlineData(4u, LocalPlayerState.VitalKind.Stamina)]
[InlineData(5u, LocalPlayerState.VitalKind.Mana)]
[InlineData(6u, LocalPlayerState.VitalKind.Mana)]
// PlayerDescription attribute-block IDs.
[InlineData(7u, LocalPlayerState.VitalKind.Health)]
[InlineData(8u, LocalPlayerState.VitalKind.Stamina)]
[InlineData(9u, LocalPlayerState.VitalKind.Mana)]
public void VitalIdToKind_MapsBothIdSystems_ToSameKind(uint vitalId, LocalPlayerState.VitalKind expected)
{
var s = new LocalPlayerState();
Assert.Equal(expected, LocalPlayerState.VitalIdToKind(vitalId));
}
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
currentMana: 150, maxMana: 200);
[Theory]
[InlineData(0u)]
[InlineData(10u)]
[InlineData(99u)]
public void VitalIdToKind_ReturnsNull_ForUnknownId(uint vitalId)
{
Assert.Null(LocalPlayerState.VitalIdToKind(vitalId));
}
Assert.Equal(50u, s.CurrentStamina);
Assert.Equal(100u, s.MaxStamina);
Assert.Equal(150u, s.CurrentMana);
Assert.Equal(200u, s.MaxMana);
[Theory]
[InlineData(1u, LocalPlayerState.AttributeKind.Strength)]
[InlineData(2u, LocalPlayerState.AttributeKind.Endurance)]
[InlineData(3u, LocalPlayerState.AttributeKind.Quickness)]
[InlineData(4u, LocalPlayerState.AttributeKind.Coordination)]
[InlineData(5u, LocalPlayerState.AttributeKind.Focus)]
[InlineData(6u, LocalPlayerState.AttributeKind.Self)]
public void AttributeIdToKind_MapsPrimaryAttrIds(uint atType, LocalPlayerState.AttributeKind expected)
{
Assert.Equal(expected, LocalPlayerState.AttributeIdToKind(atType));
}
[Theory]
[InlineData(0u)]
[InlineData(7u)] // Vitals are not primary attrs in this lookup.
[InlineData(99u)]
public void AttributeIdToKind_ReturnsNull_ForNonPrimaryIds(uint atType)
{
Assert.Null(LocalPlayerState.AttributeIdToKind(atType));
}
[Fact]
public void StaminaPercent_IsCurrentOverMax_InZeroToOneRange()
public void OnVitalUpdate_PopulatesSnapshot_FromFullMessage()
{
var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
currentMana: null, maxMana: null);
s.OnVitalUpdate(vitalId: 4u, ranks: 100u, start: 120u, xp: 50000u, current: 180u);
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
var stam = s.Get(LocalPlayerState.VitalKind.Stamina);
Assert.NotNull(stam);
Assert.Equal(100u, stam!.Value.Ranks);
Assert.Equal(120u, stam.Value.Start);
Assert.Equal(50000u, stam.Value.Xp);
Assert.Equal(180u, stam.Value.Current);
}
[Fact]
public void ManaPercent_IsCurrentOverMax_InZeroToOneRange()
public void OnAttributeUpdate_PopulatesSnapshot()
{
var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: null, maxStamina: null,
currentMana: 75, maxMana: 100);
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 100u, xp: 12345u);
Assert.Equal(0.75f, s.ManaPercent!.Value, precision: 3);
var endurance = s.GetAttribute(LocalPlayerState.AttributeKind.Endurance);
Assert.NotNull(endurance);
Assert.Equal(50u, endurance!.Value.Ranks);
Assert.Equal(100u, endurance.Value.Start);
Assert.Equal(12345u, endurance.Value.Xp);
// Current = ranks + start.
Assert.Equal(150u, endurance.Value.Current);
}
[Fact]
public void StaminaPercent_NullWhenMaxIsZero_AvoidsDivByZero()
public void HealthPercent_UsesEnduranceContribution_DividedByTwo()
{
// Endurance.current = ranks(50) + start(150) = 200. Contribution = 100.
// Health vital: ranks=0 start=0 cur=80. MaxApprox = 0 + 100 = 100. Percent = 0.8.
var s = new LocalPlayerState();
s.OnPlayerDescription(currentStamina: 0, maxStamina: 0,
currentMana: null, maxMana: null);
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u); // Endurance
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 0u, xp: 0u, current: 80u); // Health (PD id)
Assert.Equal(100u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(0.8f, s.HealthPercent!.Value, precision: 3);
}
[Fact]
public void StaminaPercent_UsesEnduranceContribution_FullValue()
{
// Endurance.current = 200, Health/2=100. Stamina takes full Endurance=200.
// Stamina vital: ranks=0 start=0 cur=150. MaxApprox = 0 + 200 = 200. Percent = 0.75.
var s = new LocalPlayerState();
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u);
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 150u);
Assert.Equal(200u, s.GetMaxApprox(LocalPlayerState.VitalKind.Stamina));
Assert.Equal(0.75f, s.StaminaPercent!.Value, precision: 3);
}
[Fact]
public void ManaPercent_UsesSelfContribution()
{
// Self.current = 100. Mana vital: ranks=20 start=80 cur=100. MaxApprox = 100 + 100 = 200.
var s = new LocalPlayerState();
s.OnAttributeUpdate(atType: 6u, ranks: 50u, start: 50u, xp: 0u); // Self
s.OnVitalUpdate(vitalId: 9u, ranks: 20u, start: 80u, xp: 0u, current: 100u); // Mana
Assert.Equal(200u, s.GetMaxApprox(LocalPlayerState.VitalKind.Mana));
Assert.Equal(0.5f, s.ManaPercent!.Value, precision: 3);
}
[Fact]
public void Percent_ZeroWhenAttributeAndVitalBothZero()
{
// Without any attribute or vital ranks, MaxApprox=0 → percent null
// (no /0). Vital received but no useful information.
var s = new LocalPlayerState();
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 0u);
Assert.Null(s.StaminaPercent);
}
[Fact]
public void ManaPercent_NullWhenMaxIsZero_AvoidsDivByZero()
public void Percent_ClampsToOne_WhenCurrentExceedsMax()
{
// Endurance contribution = 100/1 = 100. Stamina ranks+start = 0.
// MaxApprox = 100. Current = 150 → ratio 1.5 → clamps to 1.0.
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);
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 50u, xp: 0u);
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 150u);
Assert.Equal(1f, s.StaminaPercent!.Value);
}
[Fact]
public void Changed_EventFires_WhenAnyVitalUpdates()
public void OnVitalCurrent_UpdatesOnlyCurrent_LeavesRanksStartXpAlone()
{
var s = new LocalPlayerState();
int fires = 0;
s.Changed += _ => fires++;
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u);
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 50000u, current: 180u);
s.OnVitalCurrent(vitalId: 8u, current: 90u);
s.OnPlayerDescription(currentStamina: 50, maxStamina: 100,
currentMana: 75, maxMana: 200);
Assert.Equal(1, fires);
var stam = s.Get(LocalPlayerState.VitalKind.Stamina)!.Value;
Assert.Equal(0u, stam.Ranks);
Assert.Equal(0u, stam.Start);
Assert.Equal(50000u, stam.Xp);
Assert.Equal(90u, stam.Current);
// Endurance.current = 200; MaxApprox = 200; percent = 90/200 = 0.45.
Assert.Equal(0.45f, s.StaminaPercent!.Value, precision: 3);
}
[Fact]
public void OnPlayerDescription_PreservesPreviousField_WhenIncomingValueIsNull()
public void OnVitalCurrent_NoOp_WhenNoFullUpdateYet()
{
// 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);
s.OnVitalCurrent(vitalId: 4u, current: 90u);
Assert.Equal(50u, s.CurrentStamina);
Assert.Equal(100u, s.MaxStamina);
Assert.Equal(75u, s.CurrentMana);
Assert.Equal(200u, s.MaxMana);
Assert.Null(s.Get(LocalPlayerState.VitalKind.Stamina));
Assert.Null(s.StaminaPercent);
}
[Fact]
public void Changed_FiresOnFullVitalUpdate_WithCorrectKind()
{
var s = new LocalPlayerState();
var seen = new List<LocalPlayerState.VitalKind>();
s.Changed += k => seen.Add(k);
s.OnVitalUpdate(vitalId: 2u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
s.OnVitalUpdate(vitalId: 4u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
s.OnVitalUpdate(vitalId: 6u, ranks: 1u, start: 1u, xp: 0u, current: 1u);
Assert.Equal(new[]
{
LocalPlayerState.VitalKind.Health,
LocalPlayerState.VitalKind.Stamina,
LocalPlayerState.VitalKind.Mana,
}, seen);
}
[Fact]
public void AttributeChanged_FiresOnPrimaryAttrUpdate()
{
var s = new LocalPlayerState();
var seen = new List<LocalPlayerState.AttributeKind>();
s.AttributeChanged += k => seen.Add(k);
s.OnAttributeUpdate(atType: 2u, ranks: 1u, start: 1u, xp: 0u); // Endurance
s.OnAttributeUpdate(atType: 6u, ranks: 1u, start: 1u, xp: 0u); // Self
Assert.Equal(new[]
{
LocalPlayerState.AttributeKind.Endurance,
LocalPlayerState.AttributeKind.Self,
}, seen);
}
[Fact]
public void OnAttributeUpdate_DoesNotAffectVitals_DirectlyButRefreshesPercent()
{
var s = new LocalPlayerState();
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 0u, xp: 0u, current: 100u);
// Pre-attribute: percent null because MaxApprox = 0.
Assert.Null(s.StaminaPercent);
s.OnAttributeUpdate(atType: 2u, ranks: 50u, start: 150u, xp: 0u); // Endurance.current = 200
// Now MaxApprox = 0 + 200 = 200; percent = 100/200 = 0.5.
Assert.Equal(0.5f, s.StaminaPercent!.Value, precision: 3);
}
}

View file

@ -57,14 +57,14 @@ public sealed class VitalsVMTests
}
[Fact]
public void StaminaPercent_FromLocalPlayerState_AfterPlayerDescription()
public void StaminaPercent_FromLocalPlayerState_AfterVitalUpdate()
{
// Issue #5 — once a LocalPlayerState is wired and the server has
// sent a PlayerDescription with CreatureProfile, the Stam bar
// sent a PrivateUpdateVital for Stamina (vital=4), the Stam bar
// surfaces the correct percent without any VM-level caching.
// ranks=50, start=50, current=80 → MaxApprox=100 → percent=0.8.
var local = new LocalPlayerState();
local.OnPlayerDescription(currentStamina: 80, maxStamina: 100,
currentMana: null, maxMana: null);
local.OnVitalUpdate(vitalId: 4u, ranks: 50u, start: 50u, xp: 0u, current: 80u);
var vm = new VitalsVM(new CombatState(), local);
@ -72,11 +72,11 @@ public sealed class VitalsVMTests
}
[Fact]
public void ManaPercent_FromLocalPlayerState_AfterPlayerDescription()
public void ManaPercent_FromLocalPlayerState_AfterVitalUpdate()
{
// ranks=20, start=80, current=25 → MaxApprox=100 → percent=0.25.
var local = new LocalPlayerState();
local.OnPlayerDescription(currentStamina: null, maxStamina: null,
currentMana: 25, maxMana: 100);
local.OnVitalUpdate(vitalId: 6u, ranks: 20u, start: 80u, xp: 0u, current: 25u);
var vm = new VitalsVM(new CombatState(), local);
@ -86,16 +86,16 @@ public sealed class VitalsVMTests
[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.
// Verify the VM doesn't snapshot — every property access reads
// live 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);
local.OnVitalUpdate(vitalId: 4u, ranks: 50u, start: 50u, xp: 0u, current: 50u);
local.OnVitalUpdate(vitalId: 6u, ranks: 50u, start: 50u, xp: 0u, current: 50u);
Assert.Equal(0.5f, vm.StaminaPercent!.Value, precision: 3);
Assert.Equal(0.5f, vm.ManaPercent!.Value, precision: 3);