acdream/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs
Erik d86fd08011 feat(net): Phase F.1 GameEvent (0xF7B0) envelope dispatcher
Implements the inbound GameEvent routing layer — the single biggest
network-protocol gap per r08 (94 sub-opcodes, zero handled before).
WorldSession now detects 0xF7B0, parses the 16-byte header (guid +
gameEventSequence + eventType), and forwards to a pluggable
GameEventDispatcher.

Added:
- GameEventEnvelope record + TryParse with layout from
  ACE GameEventMessage.cs.
- GameEventType enum: all 94 S→C sub-opcodes from
  ACE.Server.Network.GameEvent.GameEventType, named per ACE conventions.
- GameEventDispatcher: handler registry + unhandled-counts bag for
  diagnostics ("which server events are firing that we don't parse?").
  Handlers invoked synchronously on the decode thread; thrown exceptions
  are swallowed + logged to stderr so one bad handler can't take down
  the packet loop.
- GameEvents parsers: ChannelBroadcast, Tell, TransientMessage,
  PopupString, WeenieError (+ WithString), UpdateHealth, PingResponse,
  MagicUpdateSpell. Each returns a typed record or null on malformed
  payload. String16L helper matches the existing CharacterList pattern
  (u16 length + ASCII bytes + 4-byte pad).
- WorldSession.GameEvents property exposing the dispatcher so
  GameWindow / UI / chat can register handlers at startup.

Wired into WorldSession.ProcessDatagram: new `else if (op ==
GameEventEnvelope.Opcode)` branch with TryParse + Dispatch.

Tests (13 new):
- Envelope: valid round-trip, wrong outer opcode, too-short body.
- Dispatcher: handler invoked, unhandled count, exception isolation,
  unregister + rollover to unhandled.
- Event parsers: ChannelBroadcast, Tell, UpdateHealth, WeenieError,
  Transient, MagicUpdateSpell.

Total: 521 tests pass (up from 508).

With this dispatcher in place, Phase F.2 (items + appraise), F.3 (combat
+ damage), F.4 (spell cast state machine), chat UI, allegiance, quest
tracker — all of which depend on GameEvent handling — are unblocked.

Ref: r08 §4 (GameEvent sub-opcode table), §2 (envelope wire shape).
Ref: ACE GameEventMessage.cs / GameEventType.cs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:52:46 +02:00

210 lines
7.8 KiB
C#

using System;
using System.Buffers.Binary;
using System.Text;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class GameEventDispatcherTests
{
// Helper: build a 0xF7B0 envelope with given sub-opcode + payload bytes.
private static byte[] MakeEnvelope(GameEventType type, ReadOnlySpan<byte> payload,
uint playerGuid = 0x12345678u, uint sequence = 0)
{
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
var span = body.AsSpan();
BinaryPrimitives.WriteUInt32LittleEndian(span, GameEventEnvelope.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(4), playerGuid);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(8), sequence);
BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(12), (uint)type);
payload.CopyTo(span.Slice(GameEventEnvelope.HeaderSize));
return body;
}
[Fact]
public void TryParse_ValidEnvelope_ReturnsFields()
{
byte[] body = MakeEnvelope(GameEventType.PlayerDescription,
payload: new byte[8],
playerGuid: 0xAABBCCDD, sequence: 42);
var env = GameEventEnvelope.TryParse(body);
Assert.NotNull(env);
Assert.Equal(0xAABBCCDDu, env!.Value.PlayerGuid);
Assert.Equal(42u, env.Value.Sequence);
Assert.Equal(GameEventType.PlayerDescription, env.Value.EventType);
Assert.Equal(8, env.Value.Payload.Length);
}
[Fact]
public void TryParse_WrongOuterOpcode_ReturnsNull()
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, 0xDEADBEEFu);
Assert.Null(GameEventEnvelope.TryParse(body));
}
[Fact]
public void TryParse_TooShort_ReturnsNull()
{
Assert.Null(GameEventEnvelope.TryParse(new byte[10]));
}
[Fact]
public void Dispatch_CallsHandler_ForRegisteredType()
{
var dispatcher = new GameEventDispatcher();
GameEventEnvelope received = default;
int calls = 0;
dispatcher.Register(GameEventType.Tell, env => { received = env; calls++; });
var body = MakeEnvelope(GameEventType.Tell, new byte[4]);
var env = GameEventEnvelope.TryParse(body);
dispatcher.Dispatch(env!.Value);
Assert.Equal(1, calls);
Assert.Equal(GameEventType.Tell, received.EventType);
}
[Fact]
public void Dispatch_Unhandled_CountedButNoThrow()
{
var dispatcher = new GameEventDispatcher();
var body = MakeEnvelope(GameEventType.HouseProfile, Array.Empty<byte>());
var env = GameEventEnvelope.TryParse(body);
dispatcher.Dispatch(env!.Value);
dispatcher.Dispatch(env.Value);
Assert.Equal(2, dispatcher.GetUnhandledCount(GameEventType.HouseProfile));
}
[Fact]
public void Dispatch_HandlerThrows_DoesNotPropagate()
{
var dispatcher = new GameEventDispatcher();
dispatcher.Register(GameEventType.Tell, _ => throw new InvalidOperationException("bad handler"));
var body = MakeEnvelope(GameEventType.Tell, Array.Empty<byte>());
// Should not throw; error is logged instead.
dispatcher.Dispatch(GameEventEnvelope.TryParse(body)!.Value);
}
[Fact]
public void Unregister_RemovesHandler_AndMovesToUnhandled()
{
var dispatcher = new GameEventDispatcher();
int calls = 0;
dispatcher.Register(GameEventType.Tell, _ => calls++);
dispatcher.Unregister(GameEventType.Tell);
var body = MakeEnvelope(GameEventType.Tell, Array.Empty<byte>());
dispatcher.Dispatch(GameEventEnvelope.TryParse(body)!.Value);
Assert.Equal(0, calls);
Assert.Equal(1, dispatcher.GetUnhandledCount(GameEventType.Tell));
}
// ── Per-event parser tests ───────────────────────────────────────────────
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;
}
[Fact]
public void ParseChannelBroadcast_RoundTrip()
{
// u32 channelId + string16L sender + string16L message
byte[] chan = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(chan, 42);
byte[] sender = MakeString16L("Hero");
byte[] msg = MakeString16L("hello allegiance");
byte[] payload = new byte[chan.Length + sender.Length + msg.Length];
Buffer.BlockCopy(chan, 0, payload, 0, chan.Length);
Buffer.BlockCopy(sender, 0, payload, chan.Length, sender.Length);
Buffer.BlockCopy(msg, 0, payload, chan.Length + sender.Length, msg.Length);
var parsed = GameEvents.ParseChannelBroadcast(payload);
Assert.NotNull(parsed);
Assert.Equal(42u, parsed!.Value.ChannelId);
Assert.Equal("Hero", parsed.Value.SenderName);
Assert.Equal("hello allegiance", parsed.Value.Message);
}
[Fact]
public void ParseTell_RoundTrip()
{
byte[] msg = MakeString16L("hi");
byte[] sender = MakeString16L("Alice");
byte[] tail = new byte[12]; // u32 senderGuid + u32 targetGuid + u32 chatType
BinaryPrimitives.WriteUInt32LittleEndian(tail, 0xAAu);
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(4), 0xBBu);
BinaryPrimitives.WriteUInt32LittleEndian(tail.AsSpan(8), 1u);
byte[] payload = new byte[msg.Length + sender.Length + tail.Length];
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
Buffer.BlockCopy(sender, 0, payload, msg.Length, sender.Length);
Buffer.BlockCopy(tail, 0, payload, msg.Length + sender.Length, tail.Length);
var parsed = GameEvents.ParseTell(payload);
Assert.NotNull(parsed);
Assert.Equal("hi", parsed!.Value.Message);
Assert.Equal("Alice", parsed.Value.SenderName);
Assert.Equal(0xAAu, parsed.Value.SenderGuid);
Assert.Equal(0xBBu, parsed.Value.TargetGuid);
Assert.Equal(1u, parsed.Value.ChatType);
}
[Fact]
public void ParseUpdateHealth_RoundTrip()
{
byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xC0DEu);
BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.42f);
var parsed = GameEvents.ParseUpdateHealth(payload);
Assert.NotNull(parsed);
Assert.Equal(0xC0DEu, parsed!.Value.TargetGuid);
Assert.Equal(0.42f, parsed.Value.HealthPercent, 4);
}
[Fact]
public void ParseWeenieError_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 42u);
Assert.Equal(42u, GameEvents.ParseWeenieError(payload));
}
[Fact]
public void ParseTransient_RoundTrip()
{
byte[] msg = MakeString16L("Your spell fizzled!");
byte[] chatType = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(chatType, 5u);
byte[] payload = new byte[msg.Length + 4];
Buffer.BlockCopy(msg, 0, payload, 0, msg.Length);
Buffer.BlockCopy(chatType, 0, payload, msg.Length, 4);
var parsed = GameEvents.ParseTransient(payload);
Assert.NotNull(parsed);
Assert.Equal("Your spell fizzled!", parsed!.Value.Message);
Assert.Equal(5u, parsed.Value.ChatType);
}
[Fact]
public void ParseMagicUpdateSpell_RoundTrip()
{
byte[] payload = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x3E1u); // Flame Bolt I
Assert.Equal(0x3E1u, GameEvents.ParseMagicUpdateSpell(payload));
}
}