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>
240 lines
10 KiB
C#
240 lines
10 KiB
C#
using System;
|
|
using System.Buffers.Binary;
|
|
using System.Text;
|
|
using AcDream.Core.Chat;
|
|
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;
|
|
|
|
namespace AcDream.Core.Net.Tests;
|
|
|
|
public sealed class GameEventWiringTests
|
|
{
|
|
private static byte[] MakeString16L(string s)
|
|
{
|
|
byte[] data = Encoding.ASCII.GetBytes(s);
|
|
int recordSize = 2 + data.Length;
|
|
int padding = (4 - (recordSize & 3)) & 3;
|
|
byte[] result = new byte[recordSize + padding];
|
|
BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
|
|
Array.Copy(data, 0, result, 2, data.Length);
|
|
return result;
|
|
}
|
|
|
|
private static byte[] WrapEnvelope(GameEventType type, byte[] payload)
|
|
{
|
|
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)type);
|
|
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
|
return body;
|
|
}
|
|
|
|
private static (GameEventDispatcher, ItemRepository, CombatState, Spellbook, ChatLog) MakeAll()
|
|
{
|
|
var dispatcher = new GameEventDispatcher();
|
|
var items = new ItemRepository();
|
|
var combat = new CombatState();
|
|
var spellbook = new Spellbook();
|
|
var chat = new ChatLog();
|
|
GameEventWiring.WireAll(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]
|
|
public void WireAll_ChannelBroadcast_RoutesToChatLog()
|
|
{
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
|
|
byte[] payload = new byte[4 + MakeString16L("Alice").Length + MakeString16L("hi").Length];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42);
|
|
int p = 4;
|
|
var senderBytes = MakeString16L("Alice");
|
|
Array.Copy(senderBytes, 0, payload, p, senderBytes.Length); p += senderBytes.Length;
|
|
var msgBytes = MakeString16L("hi");
|
|
Array.Copy(msgBytes, 0, payload, p, msgBytes.Length);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ChannelBroadcast, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
var entry = chat.Snapshot()[0];
|
|
Assert.Equal(ChatKind.Channel, entry.Kind);
|
|
Assert.Equal("Alice", entry.Sender);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_UpdateHealth_RoutesToCombatState()
|
|
{
|
|
var (d, _, combat, _, _) = MakeAll();
|
|
|
|
byte[] payload = new byte[8];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xCAFE);
|
|
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.UpdateHealth, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(0.42f, combat.GetHealthPercent(0xCAFE), 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_MagicUpdateSpell_RoutesToSpellbook()
|
|
{
|
|
var (d, _, _, book, _) = MakeAll();
|
|
|
|
byte[] payload = new byte[4];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicUpdateSpell, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.True(book.Knows(0x3E1));
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_WieldObject_RoutesToItemRepository()
|
|
{
|
|
var (d, items, _, _, _) = MakeAll();
|
|
items.AddOrUpdate(new ItemInstance { ObjectId = 0x1000, WeenieClassId = 1 });
|
|
|
|
byte[] payload = new byte[12];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
|
|
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
var item = items.GetItem(0x1000);
|
|
Assert.NotNull(item);
|
|
Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation);
|
|
Assert.Equal(0x2000u, item.ContainerId);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_PopupString_RoutesToChatLog()
|
|
{
|
|
var (d, _, _, _, chat) = MakeAll();
|
|
|
|
byte[] payload = MakeString16L("A modal message");
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PopupString, payload));
|
|
d.Dispatch(env!.Value);
|
|
|
|
Assert.Equal(1, chat.Count);
|
|
Assert.Equal(ChatKind.Popup, chat.Snapshot()[0].Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void WireAll_MagicPurgeEnchantments_CallsOnPurgeAll()
|
|
{
|
|
var (d, _, _, book, _) = MakeAll();
|
|
book.OnEnchantmentAdded(1, 1, 100f, 0);
|
|
book.OnEnchantmentAdded(2, 2, 100f, 0);
|
|
Assert.Equal(2, book.ActiveCount);
|
|
|
|
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.MagicPurgeEnchantments, Array.Empty<byte>()));
|
|
d.Dispatch(env!.Value);
|
|
|
|
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
|
|
}
|
|
}
|