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