From 83e0e4f9cab33dd325349ac851be4cf8d2c0b4f7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 18 Apr 2026 17:12:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20GameEventWiring=20=E2=80=94=20one-?= =?UTF-8?q?call=20glue=20from=20dispatcher=20to=20Core=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Central registration helper that wires every parsed GameEvent from the Phase F.1 dispatcher into the appropriate Core state class: - ChannelBroadcast / Tell / CommunicationTransientString / PopupString → ChatLog (H.1) - UpdateHealth / Victim / Defender / Attacker / EvasionAttacker / EvasionDefender / AttackDone → CombatState (E.4) - MagicUpdateSpell / MagicRemoveSpell / MagicUpdateEnchantment / MagicRemoveEnchantment / MagicDispelEnchantment / MagicPurgeEnchantments → Spellbook (E.5) - WieldObject / InventoryPutObjInContainer → ItemRepository (F.2) This is the piece that makes the dispatcher go from "thing that routes opcodes" to "thing that populates state the UI can redraw from". Before this, every handler had to be wired at each call site; now one call at startup (or per-reconnect) does the whole map. Project graph: added AcDream.Core.Net → AcDream.Core ProjectReference so the wiring can see both the dispatcher (Net) and the state classes (Core). Net's own tests already pull in Core indirectly, so test scope is unchanged. Tests (6 new, in Core.Net.Tests): verify round-trip via the actual dispatcher. Build envelope → dispatch → assert the correct Core state change. Covers ChannelBroadcast, UpdateHealth, MagicUpdateSpell, WieldObject, PopupString, MagicPurgeEnchantments. Build green, 602 tests pass (up from 596). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core.Net/AcDream.Core.Net.csproj | 3 + src/AcDream.Core.Net/GameEventWiring.cs | 155 ++++++++++++++++++ .../GameEventWiringTests.cs | 146 +++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 src/AcDream.Core.Net/GameEventWiring.cs create mode 100644 tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs diff --git a/src/AcDream.Core.Net/AcDream.Core.Net.csproj b/src/AcDream.Core.Net/AcDream.Core.Net.csproj index 68dd27c..4f8a02f 100644 --- a/src/AcDream.Core.Net/AcDream.Core.Net.csproj +++ b/src/AcDream.Core.Net/AcDream.Core.Net.csproj @@ -7,4 +7,7 @@ true AcDream.Core.Net + + + diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs new file mode 100644 index 0000000..be38076 --- /dev/null +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -0,0 +1,155 @@ +using System; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net.Messages; +using AcDream.Core.Spells; + +namespace AcDream.Core.Net; + +/// +/// Central registration point that wires every parsed GameEvent from +/// into the appropriate Core state +/// class (, , +/// , ). +/// +/// +/// Call once at startup (or on reconnect) passing the session's +/// dispatcher + the state instances you want to feed. The wiring is +/// additive — if you want to add a custom handler for a specific +/// event, register it AFTER this helper so it overrides the default. +/// +/// +/// +/// This is the piece that makes Phase F.1's dispatcher go from "a +/// thing that routes opcodes" to "a thing that actually populates +/// client state so the UI can redraw". Without this glue every +/// dispatcher handler had to be written by hand at each call site. +/// +/// +public static class GameEventWiring +{ + public static void WireAll( + GameEventDispatcher dispatcher, + ItemRepository items, + CombatState combat, + Spellbook spellbook, + ChatLog chat) + { + ArgumentNullException.ThrowIfNull(dispatcher); + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(combat); + ArgumentNullException.ThrowIfNull(spellbook); + ArgumentNullException.ThrowIfNull(chat); + + // ── Chat ────────────────────────────────────────────────── + dispatcher.Register(GameEventType.ChannelBroadcast, e => + { + var p = GameEvents.ParseChannelBroadcast(e.Payload.Span); + if (p is not null) chat.OnChannelBroadcast(p.Value.ChannelId, p.Value.SenderName, p.Value.Message); + }); + dispatcher.Register(GameEventType.Tell, e => + { + var p = GameEvents.ParseTell(e.Payload.Span); + if (p is not null) chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid); + }); + dispatcher.Register(GameEventType.CommunicationTransientString, e => + { + var p = GameEvents.ParseTransient(e.Payload.Span); + if (p is not null) chat.OnSystemMessage(p.Value.Message, p.Value.ChatType); + }); + dispatcher.Register(GameEventType.PopupString, e => + { + var s = GameEvents.ParsePopupString(e.Payload.Span); + if (s is not null) chat.OnPopup(s); + }); + + // ── Combat ──────────────────────────────────────────────── + dispatcher.Register(GameEventType.UpdateHealth, e => + { + var p = GameEvents.ParseUpdateHealth(e.Payload.Span); + if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent); + }); + dispatcher.Register(GameEventType.VictimNotification, e => + { + var p = GameEvents.ParseVictimNotification(e.Payload.Span); + if (p is not null) combat.OnVictimNotification( + p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType, + p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical, p.Value.AttackType); + }); + dispatcher.Register(GameEventType.DefenderNotification, e => + { + var p = GameEvents.ParseDefenderNotification(e.Payload.Span); + if (p is not null) combat.OnDefenderNotification( + p.Value.AttackerName, p.Value.AttackerGuid, p.Value.DamageType, + p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical); + }); + dispatcher.Register(GameEventType.AttackerNotification, e => + { + var p = GameEvents.ParseAttackerNotification(e.Payload.Span); + if (p is not null) combat.OnAttackerNotification( + p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, p.Value.DamagePercent); + }); + dispatcher.Register(GameEventType.EvasionAttackerNotification, e => + { + var name = GameEvents.ParseEvasionAttackerNotification(e.Payload.Span); + if (name is not null) combat.OnEvasionAttackerNotification(name); + }); + dispatcher.Register(GameEventType.EvasionDefenderNotification, e => + { + var name = GameEvents.ParseEvasionDefenderNotification(e.Payload.Span); + if (name is not null) combat.OnEvasionDefenderNotification(name); + }); + dispatcher.Register(GameEventType.AttackDone, e => + { + var p = GameEvents.ParseAttackDone(e.Payload.Span); + if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError); + }); + + // ── Spells ──────────────────────────────────────────────── + dispatcher.Register(GameEventType.MagicUpdateSpell, e => + { + var spellId = GameEvents.ParseMagicUpdateSpell(e.Payload.Span); + if (spellId is not null) spellbook.OnSpellLearned(spellId.Value); + }); + dispatcher.Register(GameEventType.MagicRemoveSpell, e => + { + var spellId = GameEvents.ParseMagicRemoveSpell(e.Payload.Span); + if (spellId is not null) spellbook.OnSpellForgotten(spellId.Value); + }); + dispatcher.Register(GameEventType.MagicUpdateEnchantment, e => + { + var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span); + if (p is not null) spellbook.OnEnchantmentAdded( + p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid); + }); + dispatcher.Register(GameEventType.MagicRemoveEnchantment, e => + { + var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span); + if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId); + }); + dispatcher.Register(GameEventType.MagicDispelEnchantment, e => + { + var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span); + if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId); + }); + dispatcher.Register(GameEventType.MagicPurgeEnchantments, + _ => spellbook.OnPurgeAll()); + + // ── Inventory ───────────────────────────────────────────── + dispatcher.Register(GameEventType.WieldObject, e => + { + var p = GameEvents.ParseWieldObject(e.Payload.Span); + if (p is not null) items.MoveItem( + p.Value.ItemGuid, + newContainerId: p.Value.WielderGuid, + newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); + }); + dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => + { + var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); + if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, + newSlot: (int)p.Value.Placement); + }); + } +} diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs new file mode 100644 index 0000000..31b978b --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -0,0 +1,146 @@ +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.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_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); + } +}