refactor(net): own session wiring subscriptions

This commit is contained in:
Erik 2026-07-21 10:45:15 +02:00
parent 88fe1db37b
commit 7d452aa6a2
8 changed files with 577 additions and 61 deletions

View file

@ -18,9 +18,10 @@ namespace AcDream.Core.Net;
///
/// <para>
/// Call once at startup (or on reconnect) passing the session's
/// dispatcher + the state instances you want to feed. The wiring is
/// additive — if you want to add a custom handler for a specific
/// event, register it AFTER this helper so it overrides the default.
/// dispatcher + the state instances you want to feed. The returned
/// subscription owns exactly these registrations and must be disposed
/// before the session is replaced. A later legacy registration still
/// overrides the corresponding default without becoming owned here.
/// </para>
///
/// <para>
@ -32,7 +33,7 @@ namespace AcDream.Core.Net;
/// </summary>
public static class GameEventWiring
{
public static void WireAll(
public static IDisposable WireAll(
GameEventDispatcher dispatcher,
ClientObjectTable items,
CombatState combat,
@ -88,29 +89,31 @@ public static class GameEventWiring
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(chat);
clientTime ??= static () => 0d;
var registrar = new OwnedGameEventRegistrar(dispatcher);
using var construction = new RegistrationBuildScope(registrar);
// ── Chat ──────────────────────────────────────────────────
dispatcher.Register(GameEventType.ChannelBroadcast, e =>
registrar.Register(GameEventType.ChannelBroadcast, e =>
{
var p = GameEvents.ParseChannelBroadcast(e.Payload.Span);
if (p is not null) chat.OnChannelBroadcast(p.Value.ChannelId, p.Value.SenderName, p.Value.Message);
});
dispatcher.Register(GameEventType.Tell, e =>
registrar.Register(GameEventType.Tell, e =>
{
var p = GameEvents.ParseTell(e.Payload.Span);
if (p is not null) chat.OnTellReceived(p.Value.SenderName, p.Value.Message, p.Value.SenderGuid);
});
dispatcher.Register(GameEventType.CommunicationTransientString, e =>
registrar.Register(GameEventType.CommunicationTransientString, e =>
{
var p = GameEvents.ParseTransient(e.Payload.Span);
if (p is not null) chat.OnSystemMessage(p.Value.Message, p.Value.ChatType);
});
dispatcher.Register(GameEventType.PopupString, e =>
registrar.Register(GameEventType.PopupString, e =>
{
var s = GameEvents.ParsePopupString(e.Payload.Span);
if (s is not null) chat.OnPopup(s);
});
dispatcher.Register(GameEventType.QueryAgeResponse, e =>
registrar.Register(GameEventType.QueryAgeResponse, e =>
{
var p = GameEvents.ParseQueryAgeResponse(e.Payload.Span);
if (p is null) return;
@ -121,7 +124,7 @@ public static class GameEventWiring
});
if (onConfirmationRequest is not null)
{
dispatcher.Register(GameEventType.CharacterConfirmationRequest, e =>
registrar.Register(GameEventType.CharacterConfirmationRequest, e =>
{
var request = GameEvents.ParseCharacterConfirmationRequest(e.Payload.Span);
if (request is not null)
@ -131,7 +134,7 @@ public static class GameEventWiring
if (onConfirmationDone is not null)
{
dispatcher.Register(GameEventType.CharacterConfirmationDone, e =>
registrar.Register(GameEventType.CharacterConfirmationDone, e =>
{
var done = GameEvents.ParseCharacterConfirmationDone(e.Payload.Span);
if (done is not null)
@ -141,7 +144,7 @@ public static class GameEventWiring
if (friends is not null)
{
dispatcher.Register(GameEventType.FriendsListUpdate, e =>
registrar.Register(GameEventType.FriendsListUpdate, e =>
{
FriendsUpdate? update = SocialStateMessages.ParseFriendsUpdate(e.Payload.Span);
if (update is not null) friends.Apply(update);
@ -150,7 +153,7 @@ public static class GameEventWiring
if (squelch is not null)
{
dispatcher.Register(GameEventType.SetSquelchDB, e =>
registrar.Register(GameEventType.SetSquelchDB, e =>
{
SquelchDatabase? database = SocialStateMessages.ParseSquelchDatabase(e.Payload.Span);
if (database is not null) squelch.Replace(database);
@ -167,7 +170,7 @@ public static class GameEventWiring
// server-message handling pattern.
if (turbineChat is not null)
{
dispatcher.Register(GameEventType.SetTurbineChatChannels, e =>
registrar.Register(GameEventType.SetTurbineChatChannels, e =>
{
var p = SetTurbineChatChannels.TryParse(e.Payload.Span);
if (p is null) return;
@ -202,26 +205,26 @@ public static class GameEventWiring
// (GameEvents.ParseWeenieError(WithString)) but were never registered.
// The server fires these for game-logic failures: "not enough mana",
// "can't pick that up", "your spell fizzled". Routed to chat.
dispatcher.Register(GameEventType.WeenieError, e =>
registrar.Register(GameEventType.WeenieError, e =>
{
var code = GameEvents.ParseWeenieError(e.Payload.Span);
if (code is not null) chat.OnWeenieError(code.Value, param: null);
});
dispatcher.Register(GameEventType.WeenieErrorWithString, e =>
registrar.Register(GameEventType.WeenieErrorWithString, e =>
{
var p = GameEvents.ParseWeenieErrorWithString(e.Payload.Span);
if (p is not null) chat.OnWeenieError(p.Value.ErrorCode, p.Value.Interpolation);
});
// ── Combat ────────────────────────────────────────────────
dispatcher.Register(GameEventType.UpdateHealth, e =>
registrar.Register(GameEventType.UpdateHealth, e =>
{
var p = GameEvents.ParseUpdateHealth(e.Payload.Span);
if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent);
});
if (itemMana is not null)
{
dispatcher.Register(GameEventType.QueryItemManaResponse, e =>
registrar.Register(GameEventType.QueryItemManaResponse, e =>
{
var p = GameEvents.ParseQueryItemManaResponse(e.Payload.Span);
if (p is not null)
@ -229,67 +232,67 @@ public static class GameEventWiring
p.Value.ItemGuid, p.Value.ManaPercent, p.Value.Valid);
});
}
dispatcher.Register(GameEventType.VictimNotification, e =>
registrar.Register(GameEventType.VictimNotification, e =>
{
var p = GameEvents.ParseVictimNotification(e.Payload.Span);
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Error);
});
dispatcher.Register(GameEventType.DefenderNotification, e =>
registrar.Register(GameEventType.DefenderNotification, e =>
{
var p = GameEvents.ParseDefenderNotification(e.Payload.Span);
if (p is not null) combat.OnDefenderNotification(
p.Value.AttackerName, 0u, p.Value.DamageType,
p.Value.Damage, p.Value.HitQuadrant, p.Value.Critical);
});
dispatcher.Register(GameEventType.AttackerNotification, e =>
registrar.Register(GameEventType.AttackerNotification, e =>
{
var p = GameEvents.ParseAttackerNotification(e.Payload.Span);
if (p is not null) combat.OnAttackerNotification(
p.Value.DefenderName, p.Value.DamageType, p.Value.Damage, (float)p.Value.HealthPercent);
});
dispatcher.Register(GameEventType.EvasionAttackerNotification, e =>
registrar.Register(GameEventType.EvasionAttackerNotification, e =>
{
var name = GameEvents.ParseEvasionAttackerNotification(e.Payload.Span);
if (name is not null) combat.OnEvasionAttackerNotification(name);
});
dispatcher.Register(GameEventType.EvasionDefenderNotification, e =>
registrar.Register(GameEventType.EvasionDefenderNotification, e =>
{
var name = GameEvents.ParseEvasionDefenderNotification(e.Payload.Span);
if (name is not null) combat.OnEvasionDefenderNotification(name);
});
dispatcher.Register(GameEventType.AttackDone, e =>
registrar.Register(GameEventType.AttackDone, e =>
{
var p = GameEvents.ParseAttackDone(e.Payload.Span);
if (p is not null) combat.OnAttackDone(p.Value.AttackSequence, p.Value.WeenieError);
});
dispatcher.Register(GameEventType.CombatCommenceAttack, e =>
registrar.Register(GameEventType.CombatCommenceAttack, e =>
{
if (GameEvents.ParseCombatCommenceAttack(e.Payload.Span))
combat.OnCombatCommenceAttack();
});
dispatcher.Register(GameEventType.KillerNotification, e =>
registrar.Register(GameEventType.KillerNotification, e =>
{
var p = GameEvents.ParseKillerNotification(e.Payload.Span);
if (p is not null) chat.OnCombatLine(p.Value.DeathMessage, CombatLineKind.Info);
});
// ── Spells ────────────────────────────────────────────────
dispatcher.Register(GameEventType.MagicUpdateSpell, e =>
registrar.Register(GameEventType.MagicUpdateSpell, e =>
{
var spellId = GameEvents.ParseMagicUpdateSpell(e.Payload.Span);
if (spellId is not null) spellbook.OnSpellLearned(spellId.Value);
});
dispatcher.Register(GameEventType.MagicRemoveSpell, e =>
registrar.Register(GameEventType.MagicRemoveSpell, e =>
{
var spellId = GameEvents.ParseMagicRemoveSpell(e.Payload.Span);
if (spellId is not null) spellbook.OnSpellForgotten(spellId.Value);
});
dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
registrar.Register(GameEventType.MagicUpdateEnchantment, e =>
{
var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime()));
});
dispatcher.Register(GameEventType.MagicUpdateMultipleEnchantments, e =>
registrar.Register(GameEventType.MagicUpdateMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span);
if (entries is not null)
@ -299,35 +302,35 @@ public static class GameEventWiring
ToActiveEnchantment(entry, receivedAt)));
}
});
dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
registrar.Register(GameEventType.MagicRemoveEnchantment, e =>
{
var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicRemoveMultipleEnchantments, e =>
registrar.Register(GameEventType.MagicRemoveMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
registrar.Register(GameEventType.MagicDispelEnchantment, e =>
{
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
});
dispatcher.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
registrar.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
{
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
if (entries is not null)
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
});
dispatcher.Register(GameEventType.MagicPurgeEnchantments,
registrar.Register(GameEventType.MagicPurgeEnchantments,
_ => spellbook.OnPurgeAll());
dispatcher.Register(GameEventType.MagicPurgeBadEnchantments,
registrar.Register(GameEventType.MagicPurgeBadEnchantments,
_ => spellbook.OnPurgeBadEnchantments());
// ── Inventory ─────────────────────────────────────────────
dispatcher.Register(GameEventType.WieldObject, e =>
registrar.Register(GameEventType.WieldObject, e =>
{
var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is null) return;
@ -338,7 +341,7 @@ public static class GameEventWiring
wielderGuid,
(AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
});
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
registrar.Register(GameEventType.InventoryPutObjInContainer, e =>
{
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is null) return;
@ -355,7 +358,7 @@ public static class GameEventWiring
// opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
// inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so
// every UI consumer repaints from the same snapshot. Retail: ClientUISystem::OnViewContents.
dispatcher.Register(GameEventType.ViewContents, e =>
registrar.Register(GameEventType.ViewContents, e =>
{
var p = GameEvents.ParseViewContents(e.Payload.Span);
if (p is null) return;
@ -371,7 +374,7 @@ public static class GameEventWiring
// B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped
// to the world. Unparent it from its container (it's now a ground object) so
// the inventory grid drops the cell; the object itself survives.
dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e =>
registrar.Register(GameEventType.InventoryPutObjectIn3D, e =>
{
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
if (guid is not null)
@ -386,7 +389,7 @@ public static class GameEventWiring
// B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move.
// Snap the item back to its pre-move slot. Log only when there was no pending move
// (a server-initiated failure on a non-optimistic path).
dispatcher.Register(GameEventType.InventoryServerSaveFailed, e =>
registrar.Register(GameEventType.InventoryServerSaveFailed, e =>
{
var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span);
if (p is null) return;
@ -404,7 +407,7 @@ public static class GameEventWiring
// retail surfaces the string-table text as a chat line. Interim text map:
// WeenieErrorText (#202 / register AP-74). Without this line a refused
// kit-heal looks like "nothing happened" — the 2026-07-03 session bug.
dispatcher.Register(GameEventType.UseDone, e =>
registrar.Register(GameEventType.UseDone, e =>
{
uint? err = GameEvents.ParseUseDone(e.Payload.Span);
if (err is null) return;
@ -417,7 +420,7 @@ public static class GameEventWiring
// CloseGroundContainer (0x0052): clear ClientUISystem::groundObject and
// retire the root plus nested temporary ViewContents projections. Child
// objects remain until authoritative move/delete wire says otherwise.
dispatcher.Register(GameEventType.CloseGroundContainer, e =>
registrar.Register(GameEventType.CloseGroundContainer, e =>
{
var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span);
if (guid is null) return;
@ -425,7 +428,7 @@ public static class GameEventWiring
items.StopViewingContentsTree(guid.Value);
});
dispatcher.Register(GameEventType.IdentifyObjectResponse, e =>
registrar.Register(GameEventType.IdentifyObjectResponse, e =>
{
var p = AppraiseInfoParser.TryParse(e.Payload.Span);
if (p is null || !p.Value.Success) return;
@ -461,7 +464,7 @@ public static class GameEventWiring
// SpellBook flag in IdentifyObjectResponse is for caster
// items / scrolls only).
bool dumpPd = Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1";
dispatcher.Register(GameEventType.PlayerDescription, e =>
registrar.Register(GameEventType.PlayerDescription, e =>
{
var p = PlayerDescriptionParser.TryParse(e.Payload.Span);
if (dumpPd)
@ -652,6 +655,38 @@ public static class GameEventWiring
// toolbar can read them without holding a parser reference.
onShortcuts?.Invoke(p.Value.Shortcuts);
});
return construction.Complete();
}
private sealed class OwnedGameEventRegistrar(
GameEventDispatcher dispatcher) : IDisposable
{
private readonly SubscriptionSet _subscriptions = new();
public void Register(
GameEventType type,
GameEventDispatcher.EventHandler handler) =>
_subscriptions.Add(dispatcher.RegisterOwned(type, handler));
public void Dispose() => _subscriptions.Dispose();
}
private sealed class RegistrationBuildScope(
OwnedGameEventRegistrar registration) : IDisposable
{
private bool _complete;
public IDisposable Complete()
{
_complete = true;
return registration;
}
public void Dispose()
{
if (!_complete)
registration.Dispose();
}
}
private static ActiveEnchantmentRecord ToActiveEnchantment(