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>
This commit is contained in:
parent
d3165f99d7
commit
d86fd08011
6 changed files with 656 additions and 0 deletions
|
|
@ -0,0 +1,210 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue