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); } /// /// 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. /// 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())); 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 } }