acdream/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs
Erik 6c485c2f06 feat(D.5.1): persist PlayerDescription shortcuts (were parsed then discarded)
Add optional `onShortcuts` callback to `GameEventWiring.WireAll`; invoke
it with `parsed.Shortcuts` after the inventory/equipped loops in the
PlayerDescription handler.  `GameWindow` holds the list in a new
`Shortcuts` property (initialized to empty) so the toolbar (D.5.1 Task 5)
can read hotbar slots without keeping a parser reference.  Existing callers
compile unchanged — the parameter defaults to null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:01:40 +02:00

447 lines
19 KiB
C#

using System;
using System.Buffers.Binary;
using System.IO;
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);
}
[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_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_KillerNotification_AppendsCombatLine()
{
var (d, _, _, _, chat) = MakeAll();
byte[] payload = MakeString16L("You killed the drudge!");
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.KillerNotification, payload));
d.Dispatch(env!.Value);
Assert.Equal(1, chat.Count);
var entry = chat.Snapshot()[0];
Assert.Equal(ChatKind.Combat, entry.Kind);
Assert.Equal(CombatLineKind.Info, entry.CombatKind);
Assert.Equal("You killed the drudge!", entry.Text);
}
[Fact]
public void WireAll_CombatCommenceAttack_FiresCombatStateEvent()
{
var (d, _, combat, _, _) = MakeAll();
bool commenced = false;
combat.AttackCommenced += () => commenced = true;
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.CombatCommenceAttack, Array.Empty<byte>()));
d.Dispatch(env!.Value);
Assert.True(commenced);
}
[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_WeenieError_RoutesToChatLog()
{
// Phase I.5: 0x028A previously had a parser
// (GameEvents.ParseWeenieError) but no dispatcher registration. The
// server fires this for plain game-logic failures (e.g. "you can't
// pick that up"). Now wired → ChatLog.OnWeenieError.
var (d, _, _, _, chat) = MakeAll();
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x9C); // arbitrary error code
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieError, payload));
d.Dispatch(env!.Value);
Assert.Equal(1, chat.Count);
var e = chat.Snapshot()[0];
Assert.Equal(ChatKind.System, e.Kind);
Assert.Equal(0x9Cu, e.ChannelId);
Assert.Contains("0x009C", e.Text);
}
[Fact]
public void WireAll_WeenieErrorWithString_RoutesToChatLogWithInterpolation()
{
// Phase I.5: 0x028B carries an interpolated substring (e.g. the
// target's name in "you can't pick up the {Mana Stone}"). Now
// wired → ChatLog.OnWeenieError with the param.
var (d, _, _, _, chat) = MakeAll();
byte[] interpBytes = MakeString16L("Mana Stone");
byte[] payload = new byte[4 + interpBytes.Length];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x42u);
Array.Copy(interpBytes, 0, payload, 4, interpBytes.Length);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WeenieErrorWithString, payload));
d.Dispatch(env!.Value);
Assert.Equal(1, chat.Count);
var e = chat.Snapshot()[0];
Assert.Equal(ChatKind.System, e.Kind);
Assert.Equal(0x42u, e.ChannelId);
Assert.Contains("Mana Stone", e.Text);
}
[Fact]
public void PlayerDescription_RegistersInventoryEntries_InItemRepository()
{
// Issue #13 acceptance test: after a PlayerDescription with non-empty
// Inventory is dispatched through WireAll, ItemRepository.ItemCount > 0.
// Wire format: strict path (no GAMEPLAY_OPTIONS bit) so inventory +
// equipped follow directly after spellbook_filters.
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);
Assert.Equal(0, items.ItemCount); // pre-condition
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags = 0
w.Write(0x52u); // weenieType
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
w.Write(1u); // has_health
w.Write(0u); // attribute_flags = 0 (no attrs)
w.Write(0u); // enchantment_mask = 0
w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path)
w.Write(0u); // options1
w.Write(0u); // legacy hotbar list count = 0
w.Write(0u); // spellbook_filters
// Inventory: 2 entries
w.Write(2u);
w.Write(0x50000A01u); w.Write(0u); // guid, ContainerType=NonContainer
w.Write(0x50000A02u); w.Write(1u); // guid, ContainerType=Container
// Equipped: 0 entries
w.Write(0u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
Assert.Equal(2, items.ItemCount);
Assert.NotNull(items.GetItem(0x50000A01u));
Assert.NotNull(items.GetItem(0x50000A02u));
}
[Fact]
public void WireAll_PlayerDescription_invokesOnShortcuts()
{
// D.5.1 Task 4: WireAll must forward parsed.Shortcuts to the onShortcuts
// callback so the toolbar can read them without keeping a parser reference.
// Mirrors PlayerDescription_RegistersInventoryEntries_InItemRepository
// for the harness pattern; adds the Shortcut flag (0x1) + one 12-byte
// entry, followed by the legacy-hotbar count (0) + spellbook_filters (0)
// then empty inventory and equipped.
IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>? got = null;
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,
onShortcuts: list => got = list);
// PlayerDescription body — minimal: no property flags, ATTRIBUTE|ENCHANTMENT
// vectorFlags (so the parser sees has_health=1, attribute_flags=0,
// enchantment_mask=0 and advances past both vector blocks), then the trailer
// with option_flags=Shortcut (0x1).
//
// Trailer layout when option_flags=0x1 (Shortcut only, no SpellLists8):
// u32 option_flags = 0x1
// u32 options1 = 0
// u32 count = 1 ← shortcut block (Shortcut flag set)
// u32 idx = 0
// u32 guid = 0x5001
// u16 spellId = 0
// u16 layer = 0
// u32 legacyHotbar count = 0 ← SpellLists8 NOT set → legacy fallback
// u32 spellbook_filters = 0
// u32 inventory count = 0
// u32 equipped count = 0
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags = 0
w.Write(0x52u); // weenieType
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
w.Write(1u); // has_health
w.Write(0u); // attribute_flags = 0 (no attrs)
w.Write(0u); // enchantment_mask = 0
// Trailer
w.Write(0x00000001u); // option_flags = Shortcut
w.Write(0u); // options1
// Shortcut block (option_flags & 0x1 set):
w.Write(1u); // count = 1
w.Write(0u); // idx = 0
w.Write(0x5001u); // guid = 0x5001
w.Write((ushort)0); // spellId = 0
w.Write((ushort)0); // layer = 0
// SpellLists8 NOT set → legacy single-list fallback:
w.Write(0u); // legacy hotbar list count = 0
// No DesiredComps, no CharacterOptions2, no GameplayOptions → strict path:
w.Write(0u); // spellbook_filters = 0
w.Write(0u); // inventory count = 0
w.Write(0u); // equipped count = 0
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
Assert.NotNull(got);
Assert.Single(got!);
Assert.Equal(0x5001u, got![0].ObjectGuid);
}
}