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>
210 lines
7.8 KiB
C#
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));
|
|
}
|
|
}
|