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); + } +}