acdream/src/AcDream.Core.Net/Messages/GameEventDispatcher.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

90 lines
3.3 KiB
C#

using System;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Central router for inbound <c>0xF7B0</c> GameEvent envelopes.
///
/// <para>
/// Each handled <see cref="GameEventType"/> gets a registered delegate.
/// Unhandled types are counted for diagnostics so it's easy to see which
/// events the server is actually emitting vs which ones we theoretically
/// support — a useful number when iterating on chat / inventory / combat.
/// </para>
///
/// <para>
/// Handlers are invoked synchronously on the thread that called
/// <see cref="Dispatch"/> — normally the render thread since
/// <see cref="WorldSession"/>'s decode path runs there in the current
/// architecture. Handlers must not block.
/// </para>
/// </summary>
public sealed class GameEventDispatcher
{
public delegate void EventHandler(GameEventEnvelope envelope);
private readonly Dictionary<GameEventType, EventHandler> _handlers = new();
private readonly Dictionary<GameEventType, int> _unhandledCounts = new();
/// <summary>
/// Register a handler for a GameEvent sub-opcode. Replaces any
/// existing handler for that opcode.
/// </summary>
public void Register(GameEventType type, EventHandler handler)
{
ArgumentNullException.ThrowIfNull(handler);
_handlers[type] = handler;
}
/// <summary>
/// Remove the registered handler for a sub-opcode.
/// </summary>
public void Unregister(GameEventType type) => _handlers.Remove(type);
/// <summary>
/// Route an envelope to its handler, or log as unhandled. Exceptions
/// inside handlers are swallowed to keep the decode loop alive — a
/// malformed event from the server should not crash the client.
/// </summary>
public void Dispatch(GameEventEnvelope envelope)
{
if (_handlers.TryGetValue(envelope.EventType, out var handler))
{
try
{
handler(envelope);
}
catch (Exception ex)
{
// The decode thread must survive handler failures. Log via
// Console so it surfaces in live-play logs without needing
// a Serilog sink here.
Console.Error.WriteLine(
$"[GameEvent] handler for 0x{(uint)envelope.EventType:X4} threw: {ex.Message}");
}
}
else
{
_unhandledCounts.TryGetValue(envelope.EventType, out int n);
_unhandledCounts[envelope.EventType] = n + 1;
}
}
/// <summary>Number of events of the given type we've seen with no handler.</summary>
public int GetUnhandledCount(GameEventType type) =>
_unhandledCounts.TryGetValue(type, out var n) ? n : 0;
/// <summary>
/// Snapshot of every event type we've seen without a handler, keyed
/// by type → count. Useful for "which server events are firing that
/// we don't parse?" diagnostic overlays.
/// </summary>
public IReadOnlyDictionary<GameEventType, int> UnhandledCounts => _unhandledCounts;
/// <summary>Reset the unhandled-counts bag (e.g. after a log-off).</summary>
public void ResetUnhandledCounts() => _unhandledCounts.Clear();
/// <summary>How many distinct sub-opcodes have a handler registered.</summary>
public int RegisteredHandlerCount => _handlers.Count;
}