From 961bdd07b7865bed1fb8a29639b2bbb8190d05ce Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 11:17:09 +0200 Subject: [PATCH] refactor(net): own live session routing --- .../Net/LiveSessionCommandRouter.cs | 326 +++++++ src/AcDream.App/Net/LiveSessionEventRouter.cs | 341 ++++++++ src/AcDream.App/Rendering/GameWindow.cs | 799 ++++++------------ src/AcDream.Core.Net/CombatStateWiring.cs | 8 +- src/AcDream.Core.Net/GameEventWiring.cs | 20 +- src/AcDream.Core.Net/ObjectTableWiring.cs | 21 +- src/AcDream.Core/Chat/CombatChatTranslator.cs | 30 +- src/AcDream.UI.Abstractions/LiveCommandBus.cs | 7 + .../Net/LiveSessionCommandRouterTests.cs | 301 +++++++ .../Net/LiveSessionEventRouterTests.cs | 291 +++++++ .../WorldSessionWiringOwnershipTests.cs | 30 + .../LiveCommandBusTests.cs | 14 + 12 files changed, 1645 insertions(+), 543 deletions(-) create mode 100644 src/AcDream.App/Net/LiveSessionCommandRouter.cs create mode 100644 src/AcDream.App/Net/LiveSessionEventRouter.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs new file mode 100644 index 00000000..3390b7b2 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -0,0 +1,326 @@ +using AcDream.App.UI; +using AcDream.Core.Chat; +using AcDream.Core.Net.Messages; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Net; + +internal sealed record LiveSessionCommandBindings( + ClientCommandController.Bindings ClientCommands, + ChatLog Chat, + TurbineChatState TurbineChat, + Func PlayerGuid, + Action SendTalk, + Action SendTell, + Action SendChannel, + Action SendTurbineChat, + Action? Log = null); + +/// +/// Owns the command surface for one exact live-session generation. The router +/// itself is the published bus, so a retained reference becomes inert before +/// the displaced transport is disposed. +/// +internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable +{ + private readonly object _gate = new(); + private LiveCommandBus? _commands; + private ClientCommandController.Bindings? _clientCommands; + private int _state; // 0 = constructed, 1 = active, 2 = disposed + + public LiveSessionCommandRouter(LiveSessionCommandBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(bindings.ClientCommands); + ArgumentNullException.ThrowIfNull(bindings.Chat); + ArgumentNullException.ThrowIfNull(bindings.TurbineChat); + ArgumentNullException.ThrowIfNull(bindings.PlayerGuid); + ArgumentNullException.ThrowIfNull(bindings.SendTalk); + ArgumentNullException.ThrowIfNull(bindings.SendTell); + ArgumentNullException.ThrowIfNull(bindings.SendChannel); + ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat); + + _clientCommands = bindings.ClientCommands; + var commands = new LiveCommandBus(); + var clientCommands = new ClientCommandController( + BuildGuardedClientCommands(bindings.ClientCommands)); + commands.Register(clientCommands.Execute); + commands.Register(command => + { + if (!string.IsNullOrEmpty(command.Text)) + SendIfActive(() => bindings.SendTalk(command.Text)); + }); + commands.Register(command => RouteChat(bindings, command)); + _commands = commands; + } + + public bool IsActive + { + get + { + lock (_gate) + return _state == 1; + } + } + + public void Activate() + { + lock (_gate) + { + if (_state == 2) + throw new ObjectDisposedException(nameof(LiveSessionCommandRouter)); + _state = 1; + } + } + + public void Publish(T command) where T : notnull + { + lock (_gate) + { + if (_state == 1) + _commands?.Publish(command); + } + } + + public void Dispose() + { + LiveCommandBus? commands; + lock (_gate) + { + _state = 2; + commands = _commands; + _commands = null; + _clientCommands = null; + } + + commands?.Clear(); + } + + private void RouteChat( + LiveSessionCommandBindings bindings, + SendChatCmd command) + { + if (string.IsNullOrEmpty(command.Text)) + return; + + switch (command.Channel) + { + case ChatChannelKind.Say: + // ACE echoes HearSpeech to the sender. Retail therefore uses + // the authoritative inbound line rather than a local echo. + SendIfActive(() => bindings.SendTalk(command.Text)); + return; + + case ChatChannelKind.Tell: + if (string.IsNullOrEmpty(command.TargetName)) + return; + if (!SendIfActive(() => + bindings.SendTell(command.TargetName, command.Text))) + return; + bindings.Chat.OnSelfSent( + ChatKind.Tell, + command.Text, + targetOrChannel: command.TargetName); + return; + } + + TurbineResolution? turbine = TurbineChatRouting.Resolve( + command.Channel, + bindings.TurbineChat); + if (turbine is not null) + { + uint cookie = bindings.TurbineChat.NextContextId(); + uint senderGuid = bindings.PlayerGuid(); + bindings.Log?.Invoke( + $"chat: outbound TurbineChat {turbine.Value.DisplayName} " + + $"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " + + $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={command.Text.Length}"); + SendIfActive(() => bindings.SendTurbineChat( + turbine.Value.RoomId, + turbine.Value.ChatType, + (uint)TurbineChat.DispatchType.SendToRoomById, + senderGuid, + command.Text, + cookie)); + return; + } + + ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(command.Channel); + if (legacy is null) + { + bindings.Log?.Invoke( + $"chat: SendChatCmd kind={command.Channel} dropped " + + $"(turbine.Enabled={bindings.TurbineChat.Enabled} no legacy id)"); + return; + } + + bindings.Log?.Invoke( + $"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " + + $"id=0x{legacy.Value.ChannelId:X8} len={command.Text.Length}"); + if (!SendIfActive(() => + bindings.SendChannel(legacy.Value.ChannelId, command.Text))) + return; + bindings.Chat.OnSelfSent( + ChatKind.Channel, + command.Text, + targetOrChannel: legacy.Value.DisplayName); + } + + private ClientCommandController.Bindings BuildGuardedClientCommands( + ClientCommandController.Bindings source) => new( + TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()), + TeleportToMarketplace: () => InvokeClient(static b => b.TeleportToMarketplace()), + TeleportToPkArena: () => InvokeClient(static b => b.TeleportToPkArena()), + TeleportToPkLiteArena: () => InvokeClient(static b => b.TeleportToPkLiteArena()), + TeleportToHouse: () => InvokeClient(static b => b.TeleportToHouse()), + TeleportToMansion: () => InvokeClient(static b => b.TeleportToMansion()), + QueryAge: () => InvokeClient(static b => b.QueryAge()), + QueryBirth: () => InvokeClient(static b => b.QueryBirth()), + ToggleFrameRate: () => InvokeClient(static b => b.ToggleFrameRate()), + ToggleUiLock: () => InvokeClient(static b => b.ToggleUiLock()), + ShowSystemMessage: text => InvokeClient(b => b.ShowSystemMessage(text)), + ShowWeenieError: error => InvokeClient(b => b.ShowWeenieError(error)), + PlayerPublicWeenieBitfield: () => + ReadClient(static b => b.PlayerPublicWeenieBitfield(), default(uint?)), + ClientVersion: () => ReadClient(static b => b.ClientVersion(), string.Empty), + CurrentPosition: () => + ReadClient(static b => b.CurrentPosition(), default(AcDream.Core.Physics.Position?)), + LastOutsideCorpsePosition: () => + ReadClient(static b => b.LastOutsideCorpsePosition(), default(AcDream.Core.Physics.Position?)), + ShowConfirmation: (text, callback) => + InvokeClient(b => b.ShowConfirmation(text, callback)), + Suicide: () => InvokeClient(static b => b.Suicide()), + ClearChat: all => InvokeClient(b => b.ClearChat(all)), + SaveUi: name => InvokeClient(b => b.SaveUi(name)), + LoadUi: name => InvokeClient(b => b.LoadUi(name)), + SaveAutoUi: () => InvokeClient(static b => b.SaveAutoUi()), + LoadAutoUi: () => InvokeClient(static b => b.LoadAutoUi()), + IsAway: () => ReadClient(static b => b.IsAway(), false), + SetAway: away => InvokeClient(b => b.SetAway(away)), + SetAwayMessage: message => InvokeClient(b => b.SetAwayMessage(message)), + AcceptLootPermits: () => ReadClient(static b => b.AcceptLootPermits(), false), + SetAcceptLootPermits: accept => InvokeClient(b => b.SetAcceptLootPermits(accept)), + DisplayConsent: () => InvokeClient(static b => b.DisplayConsent()), + ClearConsent: () => InvokeClient(static b => b.ClearConsent()), + RemoveConsent: name => InvokeClient(b => b.RemoveConsent(name)), + SendEmote: text => InvokeClient(b => b.SendEmote(text)), + Friends: source.Friends, + AddFriend: name => InvokeClient(b => b.AddFriend(name)), + RemoveFriend: id => InvokeClient(b => b.RemoveFriend(id)), + ClearFriends: () => InvokeClient(static b => b.ClearFriends()), + RequestLegacyFriends: () => InvokeClient(static b => b.RequestLegacyFriends()), + Squelch: source.Squelch, + ModifyCharacterSquelch: (add, id, name, chatType) => + InvokeClient(b => b.ModifyCharacterSquelch(add, id, name, chatType)), + ModifyAccountSquelch: (add, name) => + InvokeClient(b => b.ModifyAccountSquelch(add, name)), + ModifyGlobalSquelch: (add, chatType) => + InvokeClient(b => b.ModifyGlobalSquelch(add, chatType)), + LastTeller: () => ReadClient(static b => b.LastTeller(), default(string?)), + ClearDesiredComponents: () => InvokeClient(static b => b.ClearDesiredComponents()), + HasOpenVendor: () => ReadClient(static b => b.HasOpenVendor(), false), + FillComponentBuyList: (componentId, targetCount) => + InvokeClient(b => b.FillComponentBuyList(componentId, targetCount))); + + private bool InvokeClient(Action invoke) + { + lock (_gate) + { + ClientCommandController.Bindings? bindings = _clientCommands; + if (_state != 1 || bindings is null) + return false; + invoke(bindings); + return true; + } + } + + private TResult ReadClient( + Func read, + TResult fallback) + { + lock (_gate) + { + ClientCommandController.Bindings? bindings = _clientCommands; + return _state == 1 && bindings is not null + ? read(bindings) + : fallback; + } + } + + private bool SendIfActive(Action send) + { + lock (_gate) + { + if (_state != 1) + return false; + send(); + return true; + } + } +} + +internal readonly record struct TurbineResolution( + uint RoomId, + uint ChatType, + string DisplayName); + +internal static class TurbineChatRouting +{ + /// + /// Resolve the server-assigned Turbine room for one UI channel. This is + /// the existing holtburger resolve_turbine_channel mapping + /// (references/holtburger/.../client/commands.rs, lines 64-98), + /// moved intact from GameWindow with its runtime-room gate preserved. + /// + public static TurbineResolution? Resolve( + ChatChannelKind kind, + TurbineChatState state) + { + ArgumentNullException.ThrowIfNull(state); + if (!state.Enabled) + return null; + + (uint Room, uint ChatType, string Name) = kind switch + { + ChatChannelKind.Allegiance => + (state.AllegianceRoom, (uint)TurbineChat.ChatType.Allegiance, "Allegiance"), + ChatChannelKind.General => + (state.GeneralRoom, (uint)TurbineChat.ChatType.General, "General"), + ChatChannelKind.Trade => + (state.TradeRoom, (uint)TurbineChat.ChatType.Trade, "Trade"), + ChatChannelKind.Lfg => + (state.LfgRoom, (uint)TurbineChat.ChatType.Lfg, "LFG"), + ChatChannelKind.Roleplay => + (state.RoleplayRoom, (uint)TurbineChat.ChatType.Roleplay, "Roleplay"), + ChatChannelKind.Society => + (state.SocietyRoom, (uint)TurbineChat.ChatType.Society, "Society"), + ChatChannelKind.Olthoi => + (state.OlthoiRoom, (uint)TurbineChat.ChatType.Olthoi, "Olthoi"), + _ => (0u, 0u, string.Empty), + }; + + return Room == 0u + ? null + : new TurbineResolution(Room, ChatType, Name); + } + + /// + /// Derive the retail-facing channel label from the inbound Turbine chat + /// type, retaining the numeric-room fallback for unknown extensions. + /// + public static string DisplayName(uint roomId, uint chatType) => + (TurbineChat.ChatType)chatType switch + { + TurbineChat.ChatType.Allegiance => "Allegiance", + TurbineChat.ChatType.General => "General", + TurbineChat.ChatType.Trade => "Trade", + TurbineChat.ChatType.Lfg => "LFG", + TurbineChat.ChatType.Roleplay => "Roleplay", + TurbineChat.ChatType.Society => "Society", + TurbineChat.ChatType.SocietyCelHan => "Celestial Hand", + TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web", + TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood", + TurbineChat.ChatType.Olthoi => "Olthoi", + _ => $"Room 0x{roomId:X8}", + }; +} diff --git a/src/AcDream.App/Net/LiveSessionEventRouter.cs b/src/AcDream.App/Net/LiveSessionEventRouter.cs new file mode 100644 index 00000000..585dbe08 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionEventRouter.cs @@ -0,0 +1,341 @@ +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Player; +using AcDream.Core.Social; +using AcDream.Core.Spells; + +namespace AcDream.App.Net; + +internal sealed record LiveEntitySessionSink( + Action Spawned, + Action Deleted, + Action PickedUp, + Action MotionUpdated, + Action PositionUpdated, + Action VectorUpdated, + Action StateUpdated, + Action ParentUpdated, + Action TeleportStarted, + Action AppearanceUpdated, + Action PlayPhysicsScript, + Action PlayPhysicsScriptType); + +internal sealed record LiveEnvironmentSessionSink( + Action EnvironChanged, + Action ServerTimeUpdated); + +internal sealed record LiveInventorySessionBindings( + ClientObjectTable Objects, + LocalPlayerState LocalPlayer, + Func PlayerGuid, + Action>? OnShortcuts, + Action? OnUseDone, + ItemManaState? ItemMana, + Action>? OnDesiredComponents, + ExternalContainerState? ExternalContainers); + +internal sealed record LiveCharacterSessionBindings( + CombatState Combat, + Spellbook Spellbook, + Func, uint>? ResolveSkillFormulaBonus, + Action? OnSkillsUpdated, + Action? OnConfirmationRequest, + Action? OnConfirmationDone, + Action? OnCharacterOptions, + Func? ClientTime); + +internal sealed record LiveSocialSessionBindings( + ChatLog Chat, + TurbineChatState TurbineChat, + FriendsState? Friends, + SquelchState? Squelch); + +/// +/// Owns every inbound subscription for one exact live session. Domain state +/// remains in the supplied sinks; this class owns only routing and teardown. +/// +internal sealed class LiveSessionEventRouter : IDisposable +{ + private readonly LiveSessionSubscriptionSet _subscriptions = new(); + private readonly Action? _constructionCheckpoint; + private int _constructionStep; + private int _accepting = 1; + + public LiveSessionEventRouter( + WorldSession session, + LiveEntitySessionSink entities, + LiveEnvironmentSessionSink environment, + LiveInventorySessionBindings inventory, + LiveCharacterSessionBindings character, + LiveSocialSessionBindings social, + Action? constructionCheckpoint = null) + { + ArgumentNullException.ThrowIfNull(session); + Validate(entities, environment, inventory, character, social); + _constructionCheckpoint = constructionCheckpoint; + + try + { + // Preserve the shipped pre-Connect registration order. Property + // state is installed before lifecycle packets can be dispatched. + _subscriptions.Add(ObjectTableWiring.Wire( + session, + inventory.Objects, + inventory.PlayerGuid, + inventory.LocalPlayer, + IsAccepting)); + ConstructionCheckpoint(); + _subscriptions.Add(CombatStateWiring.Wire( + session, + character.Combat, + IsAccepting)); + ConstructionCheckpoint(); + + Subscribe(h => session.EntitySpawned += h, h => session.EntitySpawned -= h, entities.Spawned); + Subscribe(h => session.EntityDeleted += h, h => session.EntityDeleted -= h, entities.Deleted); + Subscribe(h => session.EntityPickedUp += h, h => session.EntityPickedUp -= h, entities.PickedUp); + Subscribe(h => session.MotionUpdated += h, h => session.MotionUpdated -= h, entities.MotionUpdated); + Subscribe(h => session.PositionUpdated += h, h => session.PositionUpdated -= h, entities.PositionUpdated); + Subscribe(h => session.VectorUpdated += h, h => session.VectorUpdated -= h, entities.VectorUpdated); + Subscribe(h => session.StateUpdated += h, h => session.StateUpdated -= h, entities.StateUpdated); + Subscribe(h => session.ParentUpdated += h, h => session.ParentUpdated -= h, entities.ParentUpdated); + Subscribe(h => session.TeleportStarted += h, h => session.TeleportStarted -= h, entities.TeleportStarted); + Subscribe(h => session.AppearanceUpdated += h, h => session.AppearanceUpdated -= h, entities.AppearanceUpdated); + Subscribe( + h => session.PlayPhysicsScriptReceived += h, + h => session.PlayPhysicsScriptReceived -= h, + entities.PlayPhysicsScript); + Subscribe( + h => session.PlayPhysicsScriptTypeReceived += h, + h => session.PlayPhysicsScriptTypeReceived -= h, + entities.PlayPhysicsScriptType); + + Subscribe(h => session.EnvironChanged += h, h => session.EnvironChanged -= h, environment.EnvironChanged); + Subscribe(h => session.ServerTimeUpdated += h, h => session.ServerTimeUpdated -= h, environment.ServerTimeUpdated); + + _subscriptions.Add(GameEventWiring.WireAll( + session.GameEvents, + inventory.Objects, + character.Combat, + character.Spellbook, + social.Chat, + inventory.LocalPlayer, + social.TurbineChat, + onSkillsUpdated: character.OnSkillsUpdated, + resolveSkillFormulaBonus: character.ResolveSkillFormulaBonus, + onShortcuts: inventory.OnShortcuts, + playerGuid: inventory.PlayerGuid, + onUseDone: inventory.OnUseDone, + itemMana: inventory.ItemMana, + onConfirmationRequest: character.OnConfirmationRequest, + onConfirmationDone: character.OnConfirmationDone, + friends: social.Friends, + squelch: social.Squelch, + onDesiredComponents: inventory.OnDesiredComponents, + onCharacterOptions: character.OnCharacterOptions, + clientTime: character.ClientTime, + externalContainers: inventory.ExternalContainers, + accepting: IsAccepting)); + ConstructionCheckpoint(); + _subscriptions.Add(new CombatChatTranslator( + character.Combat, + social.Chat, + IsAccepting)); + ConstructionCheckpoint(); + + Subscribe(h => session.SpeechHeard += h, h => session.SpeechHeard -= h, speech => + social.Chat.OnLocalSpeech( + speech.SenderName, + speech.Text, + speech.SenderGuid, + speech.IsRanged)); + Subscribe( + h => session.ServerMessageReceived += h, + h => session.ServerMessageReceived -= h, + message => social.Chat.OnSystemMessage(message.Message, message.ChatType)); + Subscribe(h => session.EmoteHeard += h, h => session.EmoteHeard -= h, emote => + social.Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid)); + Subscribe(h => session.SoulEmoteHeard += h, h => session.SoulEmoteHeard -= h, emote => + social.Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid)); + Subscribe( + h => session.PlayerKilledReceived += h, + h => session.PlayerKilledReceived -= h, + killed => social.Chat.OnPlayerKilled( + killed.DeathMessage, + killed.VictimGuid, + killed.KillerGuid)); + Subscribe( + h => session.TurbineChatReceived += h, + h => session.TurbineChatReceived -= h, + parsed => RouteTurbineChat(social.Chat, parsed)); + Subscribe(h => session.VitalUpdated += h, h => session.VitalUpdated -= h, vital => + inventory.LocalPlayer.OnVitalUpdate( + vital.VitalId, + vital.Ranks, + vital.Start, + vital.Xp, + vital.Current)); + Subscribe( + h => session.VitalCurrentUpdated += h, + h => session.VitalCurrentUpdated -= h, + vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current)); + } + catch (Exception constructionError) + { + Interlocked.Exchange(ref _accepting, 0); + try + { + _subscriptions.Dispose(); + } + catch (Exception cleanupError) + { + throw new AggregateException( + "live-session event routing failed and cleanup also failed", + constructionError, + cleanupError); + } + + throw; + } + } + + public bool Accepting => IsAccepting(); + + public void Dispose() + { + Interlocked.Exchange(ref _accepting, 0); + _subscriptions.Dispose(); + } + + private void Subscribe( + Action> attach, + Action> detach, + Action sink) + { + Action handler = value => + { + if (Volatile.Read(ref _accepting) != 0) + sink(value); + }; + + attach(handler); + try + { + _subscriptions.Add(() => detach(handler)); + } + catch + { + detach(handler); + throw; + } + + ConstructionCheckpoint(); + } + + private void ConstructionCheckpoint() => + _constructionCheckpoint?.Invoke(++_constructionStep); + + private bool IsAccepting() => Volatile.Read(ref _accepting) != 0; + + private static void RouteTurbineChat(ChatLog chat, TurbineChat.Parsed parsed) + { + if (parsed.Body is not TurbineChat.Payload.EventSendToRoom message) + return; + + chat.OnChannelBroadcast( + message.RoomId, + message.SenderName, + message.Message, + TurbineChatRouting.DisplayName(message.RoomId, message.ChatType)); + } + + private static void Validate( + LiveEntitySessionSink entities, + LiveEnvironmentSessionSink environment, + LiveInventorySessionBindings inventory, + LiveCharacterSessionBindings character, + LiveSocialSessionBindings social) + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(social); + ArgumentNullException.ThrowIfNull(entities.Spawned); + ArgumentNullException.ThrowIfNull(entities.Deleted); + ArgumentNullException.ThrowIfNull(entities.PickedUp); + ArgumentNullException.ThrowIfNull(entities.MotionUpdated); + ArgumentNullException.ThrowIfNull(entities.PositionUpdated); + ArgumentNullException.ThrowIfNull(entities.VectorUpdated); + ArgumentNullException.ThrowIfNull(entities.StateUpdated); + ArgumentNullException.ThrowIfNull(entities.ParentUpdated); + ArgumentNullException.ThrowIfNull(entities.TeleportStarted); + ArgumentNullException.ThrowIfNull(entities.AppearanceUpdated); + ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScript); + ArgumentNullException.ThrowIfNull(entities.PlayPhysicsScriptType); + ArgumentNullException.ThrowIfNull(environment.EnvironChanged); + ArgumentNullException.ThrowIfNull(environment.ServerTimeUpdated); + ArgumentNullException.ThrowIfNull(inventory.Objects); + ArgumentNullException.ThrowIfNull(inventory.LocalPlayer); + ArgumentNullException.ThrowIfNull(inventory.PlayerGuid); + ArgumentNullException.ThrowIfNull(character.Combat); + ArgumentNullException.ThrowIfNull(character.Spellbook); + ArgumentNullException.ThrowIfNull(social.Chat); + ArgumentNullException.ThrowIfNull(social.TurbineChat); + } +} + +internal sealed class LiveSessionSubscriptionSet : IDisposable +{ + private List? _subscriptions = []; + + public void Add(IDisposable subscription) + { + ArgumentNullException.ThrowIfNull(subscription); + List? subscriptions = _subscriptions; + if (subscriptions is null) + { + subscription.Dispose(); + throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet)); + } + + subscriptions.Add(subscription); + } + + public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe)); + + public void Dispose() + { + List? subscriptions = Interlocked.Exchange(ref _subscriptions, null); + if (subscriptions is null) + return; + + List? errors = null; + for (int index = subscriptions.Count - 1; index >= 0; index--) + { + try + { + subscriptions[index].Dispose(); + } + catch (Exception error) + { + (errors ??= []).Add(error); + } + } + + if (errors is not null) + throw new AggregateException( + "one or more live-session subscriptions failed to detach", + errors); + } + + private sealed class ActionSubscription(Action unsubscribe) : IDisposable + { + private Action? _unsubscribe = unsubscribe; + + public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke(); + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1d31c22a..d7479c15 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -813,16 +813,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext private bool DevToolsEnabled => _options.DevTools; private bool DumpMoveTruthEnabled => _options.DumpMoveTruth; - // Phase I.3 — real ICommandBus for live sessions. Constructed when - // the live session spins up (so SendChatCmd handlers can close over - // _liveSession + Chat). Null when offline; PanelContext then falls - // back to NullCommandBus.Instance. - private AcDream.UI.Abstractions.LiveCommandBus? _commandBus; - - // Phase I.7 — bridges CombatState's typed events into ChatLog as - // retail-faithful "You hit ..." / "... evaded your attack." lines. - // Disposable; lives for the duration of the live session. - private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator; + // Slice 3: exact session-generation owners. The command router is itself + // an ICommandBus and becomes inert before the displaced socket closes. + private AcDream.App.Net.LiveSessionEventRouter? _liveSessionEvents; + private AcDream.App.Net.LiveSessionCommandRouter? _liveSessionCommands; // Phase G.1-G.2 world lighting/time state. public readonly AcDream.Core.World.WorldTimeService WorldTime = @@ -2221,7 +2215,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm), Chat: new AcDream.App.UI.ChatRuntimeBindings( retailChatVm, - () => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus) + () => _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) AcDream.UI.Abstractions.NullCommandBus.Instance), Radar: new AcDream.App.UI.RadarRuntimeBindings( radarSnapshotProvider.BuildSnapshot, @@ -2867,10 +2861,15 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents); if (_liveSession is null) { - _commandBus = null; - _combatChatTranslator?.Dispose(); - _combatChatTranslator = null; - _liveSessionController = null; + try + { + DisposeLiveSessionRouting(); + } + finally + { + _liveSessionController.Dispose(); + _liveSessionController = null; + } return; } @@ -2890,12 +2889,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext out AcDream.Core.Net.Messages.CharacterList.Selection selection)) { Console.WriteLine("live: no available characters on account; disconnecting"); - _commandBus = null; - _combatChatTranslator?.Dispose(); - _combatChatTranslator = null; - _liveSessionController.Dispose(); - _liveSessionController = null; - _liveSession = null; + try + { + DisposeLiveSessionRouting(); + } + finally + { + _liveSessionController.Dispose(); + _liveSessionController = null; + _liveSession = null; + } ClearInboundEntityState(); return; } @@ -2909,6 +2912,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); Combat.Clear(); _liveSession.EnterWorld(characterIndex: selection.ActiveIndex); + _liveSessionCommands?.Activate(); _activeToonKey = chosen.Name; _retailUiRuntime?.RestoreLayout(); @@ -2926,12 +2930,16 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext catch (Exception ex) { Console.WriteLine($"live: session failed: {ex.Message}"); - _commandBus = null; - _combatChatTranslator?.Dispose(); - _combatChatTranslator = null; - _liveSessionController?.Dispose(); - _liveSessionController = null; - _liveSession = null; + try + { + DisposeLiveSessionRouting(); + } + finally + { + _liveSessionController?.Dispose(); + _liveSessionController = null; + _liveSession = null; + } ClearInboundEntityState(); } } @@ -2995,445 +3003,250 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext } /// - /// Step 2 helper: subscribes the live to all - /// the parsers / handlers / translators that GameWindow needs. - /// Called once by - /// immediately after the - /// is constructed and BEFORE any network I/O. + /// Composes the exact inbound and outbound owners for one live session. + /// Registration completes before Connect; outbound commands remain inert + /// until EnterWorld succeeds. /// private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session) { _liveSession = session; - // D.5.4: wire non-lifecycle object quality/property updates. The live - // handlers below apply Create/Delete to both projections only after - // their canonical timestamp decision. - AcDream.Core.Net.ObjectTableWiring.Wire( - session, Objects, () => _playerServerGuid, LocalPlayer); - AcDream.Core.Net.CombatStateWiring.Wire(session, Combat); - _liveSession.EntitySpawned += spawn => - _inboundEntityEvents.Run( - this, - spawn, - static (window, value) => window.OnLiveEntitySpawned(value)); - _liveSession.EntityDeleted += deletion => - _inboundEntityEvents.Run( - this, - deletion, - static (window, value) => window.OnLiveEntityDeleted(value)); - _liveSession.EntityPickedUp += pickup => - _inboundEntityEvents.Run( - this, - pickup, - static (window, value) => window.OnLiveEntityPickedUp(value)); - _liveSession.MotionUpdated += motion => - _inboundEntityEvents.Run( - this, - motion, - static (window, value) => window.OnLiveMotionUpdated(value)); - _liveSession.PositionUpdated += position => - _inboundEntityEvents.Run( - this, - position, - static (window, value) => window.OnLivePositionUpdated(value)); - _liveSession.VectorUpdated += vector => - _inboundEntityEvents.Run( - this, - vector, - static (window, value) => window.OnLiveVectorUpdated(value)); - _liveSession.StateUpdated += state => - _inboundEntityEvents.Run( - this, - state, - static (window, value) => window.OnLiveStateUpdated(value)); - _liveSession.ParentUpdated += parent => - _inboundEntityEvents.Run( - this, - parent, - static (window, value) => window.OnLiveParentUpdated(value)); - _liveSession.TeleportStarted += teleport => - _inboundEntityEvents.Run( - this, - teleport, - static (window, value) => window.OnTeleportStarted(value)); - _liveSession.AppearanceUpdated += appearance => - _inboundEntityEvents.Run( - this, - appearance, - static (window, value) => window.OnLiveAppearanceUpdated(value)); + if (_liveSessionEvents is not null || _liveSessionCommands is not null) + throw new InvalidOperationException("live-session routing is already bound"); - // Phase 6c — PlayScript (0xF754) arrives from the server as - // a (guid, scriptId) pair. Resolve the guid's current world - // position and feed the PhysicsScript runner; it schedules - // the script's hooks (particle spawns, sound cues, light - // toggles) at their StartTime offsets. This is the channel - // retail uses for spell casts, combat flinches, emote - // gestures, AND — per Agent #5 research — lightning - // flashes during stormy weather. - _liveSession.PlayPhysicsScriptReceived += script => - _inboundEntityEvents.Run( - this, - script, - static (window, value) => window.OnPlayScriptReceived(value)); - _liveSession.PlayPhysicsScriptTypeReceived += message => - _inboundEntityEvents.Run( - this, - message, - static (window, value) => window._entityEffects?.HandleTyped(value)); + var skillTable = _dats?.Get(0x0E000004u); + if (_characterSheetProvider is not null && _dats is not null) + { + _characterSheetProvider.SkillTable = skillTable; + _characterSheetProvider.ExperienceTable = + AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable( + _dats, + Console.WriteLine); + } - // Phase 5d — AdminEnvirons (0xEA60): fog presets + sound - // cues. Fog types (0x00..0x06) set WeatherSystem.Override; - // sound types (0x65..0x7B) play a one-shot audio cue. - // Lightning flashes arrive as a PAIRED PlayScript (the - // visual) + AdminEnvirons ThunderXSound (the audio) — both - // are handled here and in OnPlayScriptReceived respectively. - _liveSession.EnvironChanged += OnEnvironChanged; - - // Phase G.1: keep the client's day/night clock in sync with - // server time. Fires once from ConnectRequest (initial seed) - // and repeatedly on TimeSync-flagged packets. - // Phase 3a: also re-roll the active DayGroup if the Dereth-day - // index changed — retail rolls one weather preset per server - // day (r12 §11), deterministic from the day index so retail - // and acdream converge without a wire message. - _liveSession.ServerTimeUpdated += ticks => - { - WorldTime.SyncFromServer(ticks); - RefreshSkyForCurrentDay(); - }; - - // Phase F.1-H.1: wire every parsed GameEvent into the right - // Core state class (chat, combat, spellbook, items). After - // this one call, server-sent ChannelBroadcast / damage - // notifications / spell learns / wield events all update - // the corresponding client-side state without further glue. - // K-fix13 (2026-04-26): cache portal.dat's SkillTable so the - // skill-formula resolver can apply the AttributeFormula - // contribution. Without this, our wire-derived "skill" - // value was missing the dominant attribute-derived term - // and jumps undershot retail by ~30 % at typical - // attribute levels. - var skillTable = _dats?.Get(0x0E000004u); - if (_characterSheetProvider is not null && _dats is not null) - { - _characterSheetProvider.SkillTable = skillTable; - _characterSheetProvider.ExperienceTable = - AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable( - _dats, Console.WriteLine); - } - - AcDream.Core.Net.GameEventWiring.WireAll( - _liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer, - TurbineChat, - resolveSkillFormulaBonus: (skillId, attrCurrents) => - { - // ACE GetFormula (AttributeFormula.cs:55-): when - // formula.X (Attribute1Multiplier) is 0, the formula - // is "no attribute contribution" and the function - // returns 0. Otherwise: - // bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive - if (skillTable?.Skills is null) return 0u; - if (!skillTable.Skills.TryGetValue( - (DatReaderWriter.Enums.SkillId)skillId, out var skillBase)) - return 0u; - var f = skillBase.Formula; - if (f.Attribute1Multiplier == 0 || f.Divisor == 0) return 0u; - attrCurrents.TryGetValue((uint)f.Attribute1, out uint a1); - attrCurrents.TryGetValue((uint)f.Attribute2, out uint a2); - long num = (long)a1 * f.Attribute1Multiplier - + (long)a2 * f.Attribute2Multiplier; - long bonus = num / f.Divisor + f.AdditiveBonus; - return bonus < 0 ? 0u : (uint)bonus; - }, - onSkillsUpdated: (runSkill, jumpSkill) => - { - // K-fix7 (2026-04-26): cache the latest server-sent - // Run / Jump skill values so the next - // EnterPlayerModeNow can hand them to the new - // PlayerMovementController. Push immediately too, - // so a PD that arrives WHILE player mode is active - // (re-equip / log-in mid-session) updates the live - // controller. -1 from the wiring means "skill not - // present in this PD" — keep the previous cached - // value rather than overwriting with -1. - if (runSkill >= 0) _lastSeenRunSkill = runSkill; - if (jumpSkill >= 0) _lastSeenJumpSkill = jumpSkill; - if (_playerController is not null - && _lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0) + AcDream.App.Net.LiveSessionEventRouter? events = null; + AcDream.App.Net.LiveSessionCommandRouter? commands = null; + try + { + events = new AcDream.App.Net.LiveSessionEventRouter( + session, + CreateLiveEntitySessionSink(), + new AcDream.App.Net.LiveEnvironmentSessionSink( + OnEnvironChanged, + ticks => { - _playerController.SetCharacterSkills( - _lastSeenRunSkill, _lastSeenJumpSkill); - Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}"); - } - }, - onShortcuts: list => Shortcuts = list, - playerGuid: () => _playerServerGuid, - onUseDone: error => - { - _externalContainers.ApplyUseDone(error); - _itemInteractionController?.CompleteUse(error); - }, - itemMana: ItemMana, - onConfirmationRequest: request => - _retailUiRuntime?.HandleConfirmationRequest(request), - onConfirmationDone: done => - _retailUiRuntime?.HandleConfirmationDone(done), - friends: Friends, - squelch: Squelch, - onDesiredComponents: components => DesiredComponents = components, - onCharacterOptions: (options1, _) => - _characterOptions1 = - (AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1) - options1, - clientTime: ClientTimerNow, - externalContainers: _externalContainers); - - // Phase I.7: subscribe to CombatState events and emit - // retail-faithful "You hit X for Y damage" chat lines into - // the unified ChatLog. The translator owns the wording - // (templates ported from holtburger chat.rs:221-308); the - // panel renders combat entries via TextColored. - _combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat); - - // Phase H.1: feed inbound HearSpeech into the chat log. - _liveSession.SpeechHeard += speech => - Chat.OnLocalSpeech( - sender: speech.SenderName, - text: speech.Text, - senderGuid: speech.SenderGuid, - isRanged: speech.IsRanged); - - // Phase I.6: feed inbound TurbineChat events into the chat log. - // The Response variant is fire-and-forget (server-side ack); - // EventSendToRoom is a real chat message broadcast to a room. - // Phase J: ACE's GameMessageSystemChat (used for the login - // banner "Welcome to Asheron's Call ... type @acehelp" and - // for SystemChat broadcasts) rides opcode 0xF7E0 ServerMessage, - // parsed in I.5 but never wired. Surface it as a System - // chat line so the welcome banner appears + future server - // pushes (announcements, command responses) show. - _liveSession.ServerMessageReceived += sm => - Chat.OnSystemMessage(sm.Message, sm.ChatType); - - // Phase I.5 + J: emotes already had ChatLog adapters; wire - // their session events here so they actually reach chat. - _liveSession.EmoteHeard += emote => - Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid); - _liveSession.SoulEmoteHeard += emote => - Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid); - _liveSession.PlayerKilledReceived += pk => - Chat.OnPlayerKilled(pk.DeathMessage, pk.VictimGuid, pk.KillerGuid); - - _liveSession.TurbineChatReceived += parsed => - { - if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev) - { - // Pass the friendly channel name out-of-band via - // ChatLog.OnChannelBroadcast's channelName param so - // ChatVM.FormatEntry can render the retail-style - // "[Trade] +Acdream says, \"hello\"" without us - // mangling the payload text. - string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType); - Chat.OnChannelBroadcast( - channelId: ev.RoomId, - sender: ev.SenderName, - text: ev.Message, - channelName: label); - } - // Response (server ack of an outbound RequestSendToRoomById) - // and Unknown payloads are intentionally not surfaced — - // the inbound EventSendToRoom for our own message acts as - // the canonical echo. - }; - - // Phase I.3: real ICommandBus. Panels publish SendChatCmd here - // and we route it to the right wire opcode (Talk / Tell / ChatChannel) - // plus a local echo into ChatLog so the player sees their own - // message immediately. Closes over _liveSession + Chat so this - // wiring only exists for the lifetime of the live session. - // Step 2: capture the non-null `session` parameter rather than - // the nullable _liveSession field. Semantically identical (the - // field WAS set to `session` at the top of WireLiveSessionEvents) - // but the compiler can prove non-null for the lambda body. - var liveSession = session; - var chat = Chat; - _commandBus = new AcDream.UI.Abstractions.LiveCommandBus(); - var turbineChat = TurbineChat; - uint playerGuid = _playerServerGuid; - var clientCommandController = new AcDream.App.UI.ClientCommandController( - new AcDream.App.UI.ClientCommandController.Bindings( - TeleportToLifestone: liveSession.SendTeleportToLifestone, - TeleportToMarketplace: liveSession.SendTeleportToMarketplace, - TeleportToPkArena: liveSession.SendTeleportToPkArena, - TeleportToPkLiteArena: liveSession.SendTeleportToPkLiteArena, - TeleportToHouse: liveSession.SendTeleportToHouse, - TeleportToMansion: liveSession.SendTeleportToMansion, - QueryAge: liveSession.SendQueryAge, - QueryBirth: liveSession.SendQueryBirth, - ToggleFrameRate: ToggleRetailFrameRate, - ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI), - ShowSystemMessage: text => chat.OnSystemMessage(text, 0u), - ShowWeenieError: code => chat.OnWeenieError(code, null), - PlayerPublicWeenieBitfield: () => - Objects.Get(_playerServerGuid)?.PublicWeenieBitfield, - ClientVersion: () => - typeof(GameWindow).Assembly.GetName().Version?.ToString(3) - ?? "unknown", - CurrentPosition: () => _playerController?.CellPosition, - LastOutsideCorpsePosition: () => - LocalPlayer.GetPosition(0x0Eu), - ShowConfirmation: (message, completed) => - _retailUiRuntime?.ShowConfirmation(message, completed), - Suicide: liveSession.SendSuicide, - ClearChat: _ => chat.Clear(), - SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name), - LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name), - SaveAutoUi: () => _retailUiRuntime?.SaveLayout(), - LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(), - IsAway: () => - Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true, - SetAway: away => SetRetailAway(liveSession, away), - SetAwayMessage: liveSession.SendSetAfkMessage, - AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits, - SetAcceptLootPermits: SetRetailAcceptLootPermits, - DisplayConsent: liveSession.SendDisplayConsent, - ClearConsent: liveSession.SendClearConsent, - RemoveConsent: liveSession.SendRemoveConsent, - SendEmote: liveSession.SendEmote, - Friends: Friends, - AddFriend: liveSession.SendAddFriend, - RemoveFriend: liveSession.SendRemoveFriend, - ClearFriends: liveSession.SendClearFriends, - RequestLegacyFriends: liveSession.SendLegacyFriendsListRequest, - Squelch: Squelch, - ModifyCharacterSquelch: liveSession.SendModifyCharacterSquelch, - ModifyAccountSquelch: liveSession.SendModifyAccountSquelch, - ModifyGlobalSquelch: liveSession.SendModifyGlobalSquelch, - LastTeller: () => _retailChatVm?.LastIncomingTellSender, - ClearDesiredComponents: () => - { - liveSession.SendClearDesiredComponents(); - DesiredComponents = System.Array.Empty<(uint Id, uint Amount)>(); - }, - HasOpenVendor: () => false, - FillComponentBuyList: (_, _) => { })); - _commandBus.Register( - clientCommandController.Execute); - _commandBus.Register(cmd => - { - if (!string.IsNullOrEmpty(cmd.Text)) - liveSession.SendTalk(cmd.Text); - }); - _commandBus.Register(cmd => - { - if (string.IsNullOrEmpty(cmd.Text)) return; - switch (cmd.Channel) - { - case AcDream.UI.Abstractions.ChatChannelKind.Say: - // Phase J: drop optimistic /say echo. ACE's - // HandleActionTalk broadcasts a HearSpeech back - // to the sender too, and ChatLog.OnLocalSpeech - // detects own-guid match to render it as - // "You say, ...". Optimistic-echoing here - // doubled the line. ALSO: don't echo "@xxx" - // server-side admin commands — ACE consumes - // them silently and replies via SystemChat. - liveSession.SendTalk(cmd.Text); - break; - case AcDream.UI.Abstractions.ChatChannelKind.Tell: - if (string.IsNullOrEmpty(cmd.TargetName)) return; - liveSession.SendTell(cmd.TargetName, cmd.Text); - chat.OnSelfSent( - AcDream.Core.Chat.ChatKind.Tell, cmd.Text, - targetOrChannel: cmd.TargetName); - break; - default: - // Phase I.6: try TurbineChat first for the global - // community channels (General/Trade/LFG/Roleplay/ - // Society/Olthoi) — they ride 0xF7DE TurbineChat. - // Allegiance is double-routed: try TurbineChat first - // (when the player has a Turbine allegiance room) and - // fall back to the legacy 0x0147 ChatChannel. - // - // We do NOT optimistic-echo channels: ACE's - // TurbineChatHandler broadcasts EventSendToRoom back - // to the sender too, so we always get the canonical - // echo from the server. Optimistic-echoing here - // double-prints the message (one as "[Trade] hello" - // from us, one as "[Trade] +Acdream says, \"hello\"" - // from the server). Trust the server. - var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat); - if (turbine is not null) - { - uint cookie = turbineChat.NextContextId(); - // Use the live player guid if it's been captured; - // otherwise 0 (server treats unknown sender_id - // gracefully — the cookie is what we care about). - uint senderGuid = _playerServerGuid != 0u - ? _playerServerGuid - : playerGuid; - Console.WriteLine( - $"chat: outbound TurbineChat {turbine.Value.DisplayName} " + - $"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " + - $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={cmd.Text.Length}"); - liveSession.SendTurbineChatTo( - roomId: turbine.Value.RoomId, - chatType: turbine.Value.ChatType, - dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById, - senderGuid: senderGuid, - text: cmd.Text, - cookie: cookie); - // No optimistic echo: server EventSendToRoom - // broadcast comes back with sender="+Acdream" - // and is rendered by ChatVM as - // "[Trade] +Acdream says, \"hello\"". - break; - } - - var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel); - if (resolved is null) - { - // Diagnostic: the user picked a channel kind that - // (a) isn't a Turbine channel TurbineChatState - // knows about and (b) has no legacy ChatChannel - // mapping. Most common cause: TurbineChat hasn't - // been enabled yet (server didn't send 0x0295) - // and the kind is General/Trade/LFG/etc. - Console.WriteLine( - $"chat: SendChatCmd kind={cmd.Channel} dropped " + - $"(turbine.Enabled={turbineChat.Enabled} no legacy id)"); - return; - } - Console.WriteLine( - $"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " + - $"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}"); - liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text); - // Legacy channels (Fellowship / Allegiance / Patron / - // Monarch / Vassals / CoVassals) — keep the optimistic - // echo because legacy ChatChannel does NOT always - // broadcast back to the sender. ChannelName is the - // friendly display name so ChatVM renders it as - // "[Fellowship] +Acdream says, \"hello\"". - chat.OnSelfSent( - AcDream.Core.Chat.ChatKind.Channel, cmd.Text, - targetOrChannel: resolved.Value.DisplayName); - break; - } - }); - - // Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent - // into LocalPlayer so VitalsPanel can draw Stam / Mana bars. - _liveSession.VitalUpdated += v => - LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current); - _liveSession.VitalCurrentUpdated += v => - LocalPlayer.OnVitalCurrent(v.VitalId, v.Current); + WorldTime.SyncFromServer(ticks); + RefreshSkyForCurrentDay(); + }), + CreateLiveInventorySessionBindings(), + CreateLiveCharacterSessionBindings(skillTable), + CreateLiveSocialSessionBindings()); + commands = new AcDream.App.Net.LiveSessionCommandRouter( + CreateLiveSessionCommandBindings(session)); + _liveSessionEvents = events; + _liveSessionCommands = commands; + } + catch + { + commands?.Dispose(); + events?.Dispose(); + throw; + } + } + + private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new( + Spawned: spawn => _inboundEntityEvents.Run( + this, spawn, static (window, value) => window.OnLiveEntitySpawned(value)), + Deleted: deletion => _inboundEntityEvents.Run( + this, deletion, static (window, value) => window.OnLiveEntityDeleted(value)), + PickedUp: pickup => _inboundEntityEvents.Run( + this, pickup, static (window, value) => window.OnLiveEntityPickedUp(value)), + MotionUpdated: motion => _inboundEntityEvents.Run( + this, motion, static (window, value) => window.OnLiveMotionUpdated(value)), + PositionUpdated: position => _inboundEntityEvents.Run( + this, position, static (window, value) => window.OnLivePositionUpdated(value)), + VectorUpdated: vector => _inboundEntityEvents.Run( + this, vector, static (window, value) => window.OnLiveVectorUpdated(value)), + StateUpdated: state => _inboundEntityEvents.Run( + this, state, static (window, value) => window.OnLiveStateUpdated(value)), + ParentUpdated: parent => _inboundEntityEvents.Run( + this, parent, static (window, value) => window.OnLiveParentUpdated(value)), + TeleportStarted: teleport => _inboundEntityEvents.Run( + this, teleport, static (window, value) => window.OnTeleportStarted(value)), + AppearanceUpdated: appearance => _inboundEntityEvents.Run( + this, appearance, static (window, value) => window.OnLiveAppearanceUpdated(value)), + PlayPhysicsScript: script => _inboundEntityEvents.Run( + this, script, static (window, value) => window.OnPlayScriptReceived(value)), + PlayPhysicsScriptType: message => _inboundEntityEvents.Run( + this, message, static (window, value) => window._entityEffects?.HandleTyped(value))); + + private AcDream.App.Net.LiveInventorySessionBindings + CreateLiveInventorySessionBindings() => new( + Objects, + LocalPlayer, + PlayerGuid: () => _playerServerGuid, + OnShortcuts: list => Shortcuts = list, + OnUseDone: error => + { + _externalContainers.ApplyUseDone(error); + _itemInteractionController?.CompleteUse(error); + }, + ItemMana, + OnDesiredComponents: components => DesiredComponents = components, + ExternalContainers: _externalContainers); + + private AcDream.App.Net.LiveCharacterSessionBindings + CreateLiveCharacterSessionBindings( + DatReaderWriter.DBObjs.SkillTable? skillTable) => new( + Combat, + SpellBook, + ResolveSkillFormulaBonus: (skillId, attributeCurrents) => + { + // ACE AttributeFormula.GetFormula + // (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-): + // Attribute1Multiplier == 0 means no attribute contribution. + // Otherwise the retail data formula is + // (attr1 * mult1 + attr2 * mult2) / divisor + additive. + // Guard a zero divisor because malformed/custom DAT input must not + // take down the live-session dispatcher. + if (skillTable?.Skills is null) + return 0u; + if (!skillTable.Skills.TryGetValue( + (DatReaderWriter.Enums.SkillId)skillId, + out var skillBase)) + return 0u; + + var formula = skillBase.Formula; + if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0) + return 0u; + + attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1); + attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2); + long numerator = + (long)attribute1 * formula.Attribute1Multiplier + + (long)attribute2 * formula.Attribute2Multiplier; + long bonus = numerator / formula.Divisor + formula.AdditiveBonus; + return bonus < 0 ? 0u : (uint)bonus; + }, + OnSkillsUpdated: (runSkill, jumpSkill) => + { + if (runSkill >= 0) + _lastSeenRunSkill = runSkill; + if (jumpSkill >= 0) + _lastSeenJumpSkill = jumpSkill; + if (_playerController is not null + && _lastSeenRunSkill >= 0 + && _lastSeenJumpSkill >= 0) + { + _playerController.SetCharacterSkills( + _lastSeenRunSkill, + _lastSeenJumpSkill); + Console.WriteLine( + $"player: applied server skills run={_lastSeenRunSkill} " + + $"jump={_lastSeenJumpSkill}"); + } + }, + OnConfirmationRequest: request => + _retailUiRuntime?.HandleConfirmationRequest(request), + OnConfirmationDone: done => + _retailUiRuntime?.HandleConfirmationDone(done), + OnCharacterOptions: (options1, _) => + _characterOptions1 = + (AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1) + options1, + ClientTime: ClientTimerNow); + + private AcDream.App.Net.LiveSocialSessionBindings + CreateLiveSocialSessionBindings() => new( + Chat, + TurbineChat, + Friends, + Squelch); + + private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings( + AcDream.Core.Net.WorldSession session) => new( + ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings( + TeleportToLifestone: session.SendTeleportToLifestone, + TeleportToMarketplace: session.SendTeleportToMarketplace, + TeleportToPkArena: session.SendTeleportToPkArena, + TeleportToPkLiteArena: session.SendTeleportToPkLiteArena, + TeleportToHouse: session.SendTeleportToHouse, + TeleportToMansion: session.SendTeleportToMansion, + QueryAge: session.SendQueryAge, + QueryBirth: session.SendQueryBirth, + ToggleFrameRate: ToggleRetailFrameRate, + ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI), + ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u), + ShowWeenieError: code => Chat.OnWeenieError(code, null), + PlayerPublicWeenieBitfield: () => + Objects.Get(_playerServerGuid)?.PublicWeenieBitfield, + ClientVersion: () => + typeof(GameWindow).Assembly.GetName().Version?.ToString(3) + ?? "unknown", + CurrentPosition: () => _playerController?.CellPosition, + LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu), + ShowConfirmation: (message, completed) => + _retailUiRuntime?.ShowConfirmation(message, completed), + Suicide: session.SendSuicide, + ClearChat: _ => Chat.Clear(), + SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name), + LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name), + SaveAutoUi: () => _retailUiRuntime?.SaveLayout(), + LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(), + IsAway: () => + Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true, + SetAway: away => SetRetailAway(session, away), + SetAwayMessage: session.SendSetAfkMessage, + AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits, + SetAcceptLootPermits: SetRetailAcceptLootPermits, + DisplayConsent: session.SendDisplayConsent, + ClearConsent: session.SendClearConsent, + RemoveConsent: session.SendRemoveConsent, + SendEmote: session.SendEmote, + Friends, + AddFriend: session.SendAddFriend, + RemoveFriend: session.SendRemoveFriend, + ClearFriends: session.SendClearFriends, + RequestLegacyFriends: session.SendLegacyFriendsListRequest, + Squelch, + ModifyCharacterSquelch: session.SendModifyCharacterSquelch, + ModifyAccountSquelch: session.SendModifyAccountSquelch, + ModifyGlobalSquelch: session.SendModifyGlobalSquelch, + LastTeller: () => _retailChatVm?.LastIncomingTellSender, + ClearDesiredComponents: () => + { + session.SendClearDesiredComponents(); + DesiredComponents = Array.Empty<(uint Id, uint Amount)>(); + }, + HasOpenVendor: () => false, + FillComponentBuyList: (_, _) => { }), + Chat, + TurbineChat, + PlayerGuid: () => _playerServerGuid, + SendTalk: session.SendTalk, + SendTell: session.SendTell, + SendChannel: session.SendChannel, + SendTurbineChat: (roomId, chatType, dispatchType, senderGuid, text, cookie) => + session.SendTurbineChatTo( + roomId, chatType, dispatchType, senderGuid, text, cookie), + Log: Console.WriteLine); + + private void DisposeLiveSessionRouting() + { + AcDream.App.Net.LiveSessionCommandRouter? commands = _liveSessionCommands; + AcDream.App.Net.LiveSessionEventRouter? events = _liveSessionEvents; + _liveSessionCommands = null; + _liveSessionEvents = null; + try + { + commands?.Dispose(); + } + finally + { + events?.Dispose(); + } } - /// - /// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated - /// mesh refs and register it in IGameState. Called from WorldSession events - /// on the main thread (Tick runs in the Silk.NET Update callback). - /// private enum LiveProjectionPurpose { LogicalRegistration, @@ -11145,7 +10958,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext // is up so panel-emitted SendChatCmd actually flows server-ward. // Fall back to NullCommandBus for offline / pre-connect renders. AcDream.UI.Abstractions.ICommandBus bus = - _commandBus ?? (AcDream.UI.Abstractions.ICommandBus) + _liveSessionCommands ?? (AcDream.UI.Abstractions.ICommandBus) AcDream.UI.Abstractions.NullCommandBus.Instance; var ctx = new AcDream.UI.Abstractions.PanelContext( (float)deltaSeconds, @@ -14330,7 +14143,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _magicCatalog = null; }), new("streamer", () => _streamer?.Dispose()), - new("combat chat", () => _combatChatTranslator?.Dispose()), + new("live session routing", DisposeLiveSessionRouting), new("equipped children", () => _equippedChildRenderer?.Dispose()), new("live session", () => { @@ -14470,76 +14283,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext _window = null; } - // ── Phase I.6 — TurbineChat outbound helpers ────────────────── - - /// - /// Result of resolving a UI - /// to a runtime Turbine room. Returned by - /// when the player has access - /// to that Turbine channel; null otherwise. - /// - private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName); - - /// - /// Map a to a - /// runtime Turbine room id + chat-type. Returns null when - /// isn't - /// or the channel has no assigned room (e.g. player not in a society). - /// Mirrors holtburger's resolve_turbine_channel - /// (references/holtburger/.../client/commands.rs lines 64-98). - /// - private static TurbineResolution? ResolveTurbineForKind( - AcDream.UI.Abstractions.ChatChannelKind kind, - AcDream.Core.Chat.TurbineChatState state) - { - if (!state.Enabled) return null; - - var (room, chatType, name) = kind switch - { - AcDream.UI.Abstractions.ChatChannelKind.Allegiance => - (state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"), - AcDream.UI.Abstractions.ChatChannelKind.General => - (state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"), - AcDream.UI.Abstractions.ChatChannelKind.Trade => - (state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"), - AcDream.UI.Abstractions.ChatChannelKind.Lfg => - (state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"), - AcDream.UI.Abstractions.ChatChannelKind.Roleplay => - (state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"), - AcDream.UI.Abstractions.ChatChannelKind.Society => - (state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"), - AcDream.UI.Abstractions.ChatChannelKind.Olthoi => - (state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"), - _ => (0u, 0u, string.Empty), - }; - - if (room == 0u) return null; - return new TurbineResolution(room, chatType, name); - } - - /// - /// Pick a human-readable label for a Turbine room broadcast. Uses - /// the chat-type when known (semantic name), falls back to the - /// numeric room id for unknown rooms. - /// - private static string TurbineRoomDisplayName(uint roomId, uint chatType) - { - return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch - { - AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance", - AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society", - AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand", - AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web", - AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood", - AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi", - _ => $"Room 0x{roomId:X8}", - }; - } - /// /// Fallback for the /// sequencer diff --git a/src/AcDream.Core.Net/CombatStateWiring.cs b/src/AcDream.Core.Net/CombatStateWiring.cs index 9e637e60..9cd19156 100644 --- a/src/AcDream.Core.Net/CombatStateWiring.cs +++ b/src/AcDream.Core.Net/CombatStateWiring.cs @@ -12,13 +12,19 @@ public static class CombatStateWiring { public const uint CombatModePropertyId = 40u; - public static IDisposable Wire(WorldSession session, CombatState combat) + public static IDisposable Wire( + WorldSession session, + CombatState combat, + Func? accepting = null) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(combat); Action handler = update => + { + if (accepting?.Invoke() == false) return; ApplyPlayerIntProperty(combat, update.Property, update.Value); + }; session.PlayerIntPropertyUpdated += handler; return new EventSubscription(session, handler); } diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 89f9561c..648b9f62 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -81,7 +81,8 @@ public static class GameEventWiring Action>? onDesiredComponents = null, Action? onCharacterOptions = null, Func? clientTime = null, - ExternalContainerState? externalContainers = null) + ExternalContainerState? externalContainers = null, + Func? accepting = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -89,7 +90,7 @@ public static class GameEventWiring ArgumentNullException.ThrowIfNull(spellbook); ArgumentNullException.ThrowIfNull(chat); clientTime ??= static () => 0d; - var registrar = new OwnedGameEventRegistrar(dispatcher); + var registrar = new OwnedGameEventRegistrar(dispatcher, accepting); using var construction = new RegistrationBuildScope(registrar); // ── Chat ────────────────────────────────────────────────── @@ -659,14 +660,23 @@ public static class GameEventWiring } private sealed class OwnedGameEventRegistrar( - GameEventDispatcher dispatcher) : IDisposable + GameEventDispatcher dispatcher, + Func? accepting) : IDisposable { private readonly SubscriptionSet _subscriptions = new(); public void Register( GameEventType type, - GameEventDispatcher.EventHandler handler) => - _subscriptions.Add(dispatcher.RegisterOwned(type, handler)); + GameEventDispatcher.EventHandler handler) + { + GameEventDispatcher.EventHandler registered = accepting is null + ? handler + : envelope => + { + if (accepting()) handler(envelope); + }; + _subscriptions.Add(dispatcher.RegisterOwned(type, registered)); + } public void Dispose() => _subscriptions.Dispose(); } diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index b9d320a7..b204bb5c 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -23,7 +23,8 @@ public static class ObjectTableWiring WorldSession session, ClientObjectTable table, Func? playerGuid = null, - LocalPlayerState? localPlayer = null) + LocalPlayerState? localPlayer = null, + Func? accepting = null) { ArgumentNullException.ThrowIfNull(session); ArgumentNullException.ThrowIfNull(table); @@ -37,7 +38,10 @@ public static class ObjectTableWiring // UiEffects — the server is the authority on object properties. UpdateIntProperty // stores it in the bundle and still mirrors UiEffects → the typed Effects field. Action objectIntUpdated = u => + { + if (accepting?.Invoke() == false) return; table.UpdateIntProperty(u.Guid, u.Property, u.Value); + }; session.ObjectIntPropertyUpdated += objectIntUpdated; subscriptions.Add(() => session.ObjectIntPropertyUpdated -= objectIntUpdated); @@ -49,6 +53,7 @@ public static class ObjectTableWiring // than creating a phantom — the next PD / CreateObject seeds it. Action playerIntUpdated = u => { + if (accepting?.Invoke() == false) return; if (playerGuid is not null) table.UpdateIntProperty(playerGuid(), u.Property, u.Value); }; @@ -61,20 +66,30 @@ public static class ObjectTableWiring // Apply the authoritative 0x02CF update to both in this one wiring owner so they // cannot drift after the login PlayerDescription snapshot. Action playerInt64Updated = u => + { + if (accepting?.Invoke() == false) return; ApplyPlayerInt64PropertyUpdate( - table, localPlayer, playerGuid?.Invoke() ?? 0u, u); + table, localPlayer, playerGuid?.Invoke() ?? 0u, u); + }; session.PlayerInt64PropertyUpdated += playerInt64Updated; subscriptions.Add(() => session.PlayerInt64PropertyUpdated -= playerInt64Updated); // B-Wire: SetStackSize (0x0197) — update the object's stack count + value. Action stackSizeUpdated = u => + { + if (accepting?.Invoke() == false) return; table.UpdateStackSize(u.Guid, u.StackSize, u.Value); + }; session.StackSizeUpdated += stackSizeUpdated; subscriptions.Add(() => session.StackSizeUpdated -= stackSizeUpdated); // B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory // view; drop it from the table (retail ClientUISystem removes it from maintenance). - Action inventoryObjectRemoved = guid => table.Remove(guid); + Action inventoryObjectRemoved = guid => + { + if (accepting?.Invoke() == false) return; + table.Remove(guid); + }; session.InventoryObjectRemoved += inventoryObjectRemoved; subscriptions.Add(() => session.InventoryObjectRemoved -= inventoryObjectRemoved); diff --git a/src/AcDream.Core/Chat/CombatChatTranslator.cs b/src/AcDream.Core/Chat/CombatChatTranslator.cs index 4bec9247..d1b8ab3a 100644 --- a/src/AcDream.Core/Chat/CombatChatTranslator.cs +++ b/src/AcDream.Core/Chat/CombatChatTranslator.cs @@ -51,16 +51,34 @@ public sealed class CombatChatTranslator : IDisposable private bool _disposed; - public CombatChatTranslator(CombatState combat, ChatLog chat) + public CombatChatTranslator( + CombatState combat, + ChatLog chat, + Func? accepting = null) { _combat = combat ?? throw new ArgumentNullException(nameof(combat)); _chat = chat ?? throw new ArgumentNullException(nameof(chat)); - _onDealt = HandleDamageDealt; - _onTaken = HandleDamageTaken; - _onMissed = HandleMissedOutgoing; - _onEvaded = HandleEvadedIncoming; - _onKill = HandleKillLanded; + _onDealt = value => + { + if (accepting?.Invoke() != false) HandleDamageDealt(value); + }; + _onTaken = value => + { + if (accepting?.Invoke() != false) HandleDamageTaken(value); + }; + _onMissed = value => + { + if (accepting?.Invoke() != false) HandleMissedOutgoing(value); + }; + _onEvaded = value => + { + if (accepting?.Invoke() != false) HandleEvadedIncoming(value); + }; + _onKill = (name, guid) => + { + if (accepting?.Invoke() != false) HandleKillLanded(name, guid); + }; _combat.DamageDealtAccepted += _onDealt; _combat.DamageTaken += _onTaken; diff --git a/src/AcDream.UI.Abstractions/LiveCommandBus.cs b/src/AcDream.UI.Abstractions/LiveCommandBus.cs index 3f67d4ad..cb260c8e 100644 --- a/src/AcDream.UI.Abstractions/LiveCommandBus.cs +++ b/src/AcDream.UI.Abstractions/LiveCommandBus.cs @@ -61,4 +61,11 @@ public sealed class LiveCommandBus : ICommandBus $"[LiveCommandBus] no handler registered for {typeof(T).FullName}; dropping."); } } + + /// + /// Release every registered handler. Session-scoped owners call this + /// during teardown so a retained bus cannot keep an obsolete transport or + /// host object graph alive. + /// + public void Clear() => _handlers.Clear(); } diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs new file mode 100644 index 00000000..5674cac4 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -0,0 +1,301 @@ +using System.Runtime.CompilerServices; +using AcDream.App.Net; +using AcDream.App.UI; +using AcDream.Core.Chat; +using AcDream.Core.Social; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionCommandRouterTests +{ + [Fact] + public void InactiveAndDisposedRouter_CannotReachTransport() + { + var sent = new List(); + var router = NewRouter(sendTalk: sent.Add); + + router.Publish(new SendServerCommandCmd("@before")); + router.Activate(); + router.Activate(); + router.Publish(new SendServerCommandCmd("@active")); + router.Dispose(); + router.Dispose(); + router.Publish(new SendServerCommandCmd("@after")); + + Assert.Equal(["@active"], sent); + Assert.False(router.IsActive); + } + + [Fact] + public void TellAndLegacyChannel_PreserveOutboundAndEchoPolicy() + { + var tells = new List<(string Target, string Text)>(); + var channels = new List<(uint Id, string Text)>(); + var chat = new ChatLog(); + var router = NewRouter( + chat: chat, + sendTell: (target, text) => tells.Add((target, text)), + sendChannel: (id, text) => channels.Add((id, text))); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.Tell, "Friend", "hello")); + router.Publish(new SendChatCmd(ChatChannelKind.Fellowship, null, "group")); + + Assert.Equal([("Friend", "hello")], tells); + Assert.Equal([(0x00000800u, "group")], channels); + Assert.Collection( + chat.Snapshot(), + entry => + { + Assert.Equal(ChatKind.Tell, entry.Kind); + Assert.Equal("Friend", entry.Sender); + }, + entry => + { + Assert.Equal(ChatKind.Channel, entry.Kind); + Assert.Equal("Fellowship", entry.ChannelName); + }); + } + + [Fact] + public void TurbineChannel_UsesRuntimeRoomCookieAndNoOptimisticEcho() + { + var turbine = new TurbineChatState(); + turbine.OnChannelsReceived( + allegianceRoom: 0u, + generalRoom: 0x70000001u, + tradeRoom: 0u, + lfgRoom: 0u, + roleplayRoom: 0u, + olthoiRoom: 0u, + societyRoom: 0u, + societyCelestialHandRoom: 0u, + societyEldrytchWebRoom: 0u, + societyRadiantBloodRoom: 0u); + var sent = new List<(uint Room, uint Type, uint Dispatch, uint Sender, string Text, uint Cookie)>(); + var chat = new ChatLog(); + var router = NewRouter( + chat: chat, + turbine: turbine, + playerGuid: () => 0x50000001u, + sendTurbine: (room, type, dispatch, sender, text, cookie) => + sent.Add((room, type, dispatch, sender, text, cookie))); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world")); + + Assert.Collection(sent, message => + { + Assert.Equal(0x70000001u, message.Room); + Assert.Equal(0x02u, message.Type); + Assert.Equal(0x02u, message.Dispatch); + Assert.Equal(0x50000001u, message.Sender); + Assert.Equal("world", message.Text); + Assert.Equal(1u, message.Cookie); + }); + Assert.Equal(0, chat.Count); + } + + [Fact] + public void ActivateAfterDispose_IsRejected() + { + var router = NewRouter(); + router.Dispose(); + + Assert.Throws(router.Activate); + } + + [Fact] + public async Task ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert() + { + using var sendEntered = new ManualResetEventSlim(); + using var releaseSend = new ManualResetEventSlim(); + var sent = new List(); + var router = NewRouter(sendTalk: text => + { + sendEntered.Set(); + releaseSend.Wait(); + sent.Add(text); + }); + router.Activate(); + + Task publish = Task.Run(() => + router.Publish(new SendServerCommandCmd("@active"))); + Assert.True(sendEntered.Wait(TimeSpan.FromSeconds(5))); + using var disposeStarted = new ManualResetEventSlim(); + Task dispose = Task.Run(() => + { + disposeStarted.Set(); + router.Dispose(); + }); + Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5))); + Task firstCompletion = await Task.WhenAny( + dispose, + Task.Delay(TimeSpan.FromMilliseconds(100))); + Assert.NotSame(dispose, firstCompletion); + + releaseSend.Set(); + await Task.WhenAll(publish, dispose); + router.Publish(new SendServerCommandCmd("@after")); + + Assert.Equal(["@active"], sent); + Assert.False(router.IsActive); + } + + [Fact] + public void ReentrantDisposeFromLog_PreventsLaterTransportAndEcho() + { + var turbine = new TurbineChatState(); + turbine.OnChannelsReceived( + 0u, 0x70000001u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u); + var sent = new List(); + var chat = new ChatLog(); + LiveSessionCommandRouter? router = null; + router = NewRouter( + chat: chat, + turbine: turbine, + sendTurbine: (_, _, _, _, text, _) => sent.Add(text), + log: _ => router!.Dispose()); + router.Activate(); + + router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world")); + + Assert.Empty(sent); + Assert.Equal(0, chat.Count); + Assert.False(router.IsActive); + } + + [Fact] + public void RetainedDisposedRouter_ReleasesCapturedTransportGraph() + { + (LiveSessionCommandRouter router, WeakReference transport) = + CreateRouterWithCapturedTransport(); + + router.Dispose(); + ForceFullCollection(); + + Assert.False(transport.TryGetTarget(out _)); + GC.KeepAlive(router); + } + + [Fact] + public void DelayedConfirmationCallback_ReleasesTransportAndBecomesInert() + { + (LiveSessionCommandRouter router, WeakReference transport, Action callback) = + CreateRouterWithDelayedConfirmation(); + + router.Dispose(); + ForceFullCollection(); + + Assert.False(transport.TryGetTarget(out _)); + callback(true); + Assert.False(router.IsActive); + GC.KeepAlive(callback); + GC.KeepAlive(router); + } + + private static LiveSessionCommandRouter NewRouter( + ChatLog? chat = null, + TurbineChatState? turbine = null, + Func? playerGuid = null, + Action? sendTalk = null, + Action? sendTell = null, + Action? sendChannel = null, + Action? sendTurbine = null, + Action? log = null, + ClientCommandController.Bindings? clientBindings = null) => new( + new LiveSessionCommandBindings( + clientBindings ?? NewClientBindings(), + chat ?? new ChatLog(), + turbine ?? new TurbineChatState(), + playerGuid ?? (() => 0u), + sendTalk ?? (_ => { }), + sendTell ?? ((_, _) => { }), + sendChannel ?? ((_, _) => { }), + sendTurbine ?? ((_, _, _, _, _, _) => { }), + log)); + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (LiveSessionCommandRouter, WeakReference) + CreateRouterWithCapturedTransport() + { + object transport = new object(); + var weak = new WeakReference(transport); + var router = NewRouter(sendTalk: _ => GC.KeepAlive(transport)); + return (router, weak); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (LiveSessionCommandRouter, WeakReference, Action) + CreateRouterWithDelayedConfirmation() + { + object transport = new object(); + var weak = new WeakReference(transport); + Action? callback = null; + ClientCommandController.Bindings bindings = NewClientBindings() with + { + ShowConfirmation = (_, completion) => callback = completion, + Suicide = () => GC.KeepAlive(transport), + }; + var router = NewRouter(clientBindings: bindings); + router.Activate(); + router.Publish(new ExecuteClientCommandCmd(ClientCommandId.Die, string.Empty)); + return (router, weak, Assert.IsType>(callback)); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ForceFullCollection() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + private static ClientCommandController.Bindings NewClientBindings() => new( + TeleportToLifestone: () => { }, + TeleportToMarketplace: () => { }, + TeleportToPkArena: () => { }, + TeleportToPkLiteArena: () => { }, + TeleportToHouse: () => { }, + TeleportToMansion: () => { }, + QueryAge: () => { }, + QueryBirth: () => { }, + ToggleFrameRate: () => { }, + ToggleUiLock: () => { }, + ShowSystemMessage: _ => { }, + ShowWeenieError: _ => { }, + PlayerPublicWeenieBitfield: () => null, + ClientVersion: () => "test", + CurrentPosition: () => null, + LastOutsideCorpsePosition: () => null, + ShowConfirmation: (_, _) => { }, + Suicide: () => { }, + ClearChat: _ => { }, + SaveUi: _ => { }, + LoadUi: _ => { }, + SaveAutoUi: () => { }, + LoadAutoUi: () => { }, + IsAway: () => false, + SetAway: _ => { }, + SetAwayMessage: _ => { }, + AcceptLootPermits: () => false, + SetAcceptLootPermits: _ => { }, + DisplayConsent: () => { }, + ClearConsent: () => { }, + RemoveConsent: _ => { }, + SendEmote: _ => { }, + Friends: new FriendsState(), + AddFriend: _ => { }, + RemoveFriend: _ => { }, + ClearFriends: () => { }, + RequestLegacyFriends: () => { }, + Squelch: new SquelchState(), + ModifyCharacterSquelch: (_, _, _, _) => { }, + ModifyAccountSquelch: (_, _) => { }, + ModifyGlobalSquelch: (_, _) => { }, + LastTeller: () => null, + ClearDesiredComponents: () => { }, + HasOpenVendor: () => false, + FillComponentBuyList: (_, _) => { }); +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs new file mode 100644 index 00000000..8402cbfb --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs @@ -0,0 +1,291 @@ +using System.Net; +using System.Reflection; +using AcDream.App.Net; +using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Player; +using AcDream.Core.Social; +using AcDream.Core.Spells; + +namespace AcDream.App.Tests.Net; + +public sealed class LiveSessionEventRouterTests +{ + [Fact] + public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var counters = new Counters(); + var combat = new CombatState(); + var chat = new ChatLog(); + var router = NewRouter(session, counters, combat: combat, chat: chat); + + AssertSessionHandlerCounts(session, multiplier: 1); + Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents); + Action copiedTime = EventDelegate>( + session, + nameof(session.ServerTimeUpdated)); + Action copiedTeleport = EventDelegate>( + session, + nameof(session.TeleportStarted)); + Action copiedPlayerInt = + EventDelegate>( + session, + nameof(session.PlayerIntPropertyUpdated)); + Action copiedDamage = + EventDelegate>( + combat, + nameof(combat.DamageDealtAccepted)); + copiedTime(123d); + copiedTeleport(0x50000001u); + copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate( + CombatStateWiring.CombatModePropertyId, + (int)CombatMode.Melee)); + copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 5u, 0.1f)); + Assert.Equal(1, counters.ServerTime); + Assert.Equal(1, counters.Teleport); + Assert.Equal(CombatMode.Melee, combat.CurrentMode); + Assert.Equal(1, chat.Count); + + router.Dispose(); + router.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + + copiedTime(456d); + copiedTeleport(0x50000002u); + copiedPlayerInt(new WorldSession.PlayerIntPropertyUpdate( + CombatStateWiring.CombatModePropertyId, + (int)CombatMode.Magic)); + copiedDamage(new CombatState.DamageDealt("Drudge", 1u, 7u, 0.2f)); + Assert.Equal(1, counters.ServerTime); + Assert.Equal(1, counters.Teleport); + Assert.Equal(CombatMode.Melee, combat.CurrentMode); + Assert.Equal(1, chat.Count); + } + + [Fact] + public void NestedRouters_DisposeOlderFirstLeavesOnlyNewerRouter() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var first = new Counters(); + var second = new Counters(); + var routerA = NewRouter(session, first); + var routerB = NewRouter(session, second); + + AssertSessionHandlerCounts(session, multiplier: 2); + EventDelegate>(session, nameof(session.ServerTimeUpdated))(1d); + Assert.Equal(1, first.ServerTime); + Assert.Equal(1, second.ServerTime); + + routerA.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 1); + EventDelegate>(session, nameof(session.ServerTimeUpdated))(2d); + Assert.Equal(1, first.ServerTime); + Assert.Equal(2, second.ServerTime); + + routerB.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + + [Fact] + public void NestedRouters_DisposeNewerFirstLeavesOnlyOlderRouter() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var first = new Counters(); + var second = new Counters(); + var routerA = NewRouter(session, first); + var routerB = NewRouter(session, second); + + routerB.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 1); + EventDelegate>(session, nameof(session.ServerTimeUpdated))(1d); + Assert.Equal(1, first.ServerTime); + Assert.Equal(0, second.ServerTime); + + routerA.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + + [Fact] + public void ThrowingSink_DoesNotCorruptLaterTeardown() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + var router = NewRouter( + session, + new Counters { ThrowOnServerTime = true }); + + Assert.Throws(() => + EventDelegate>( + session, + nameof(session.ServerTimeUpdated))(1d)); + + router.Dispose(); + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + + [Fact] + public void ConstructionFailure_UnwindsEveryPriorRegistration() + { + using var session = NewSession(); + int baselineGameEvents = session.GameEvents.RegisteredHandlerCount; + + Assert.Throws(() => NewRouter( + session, + new Counters(), + step => + { + if (step == 20) + throw new InvalidOperationException("injected construction failure"); + })); + + AssertSessionHandlerCounts(session, multiplier: 0); + Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount); + } + + private static LiveSessionEventRouter NewRouter( + WorldSession session, + Counters counters, + Action? constructionCheckpoint = null, + CombatState? combat = null, + ChatLog? chat = null) => new( + session, + new LiveEntitySessionSink( + Spawned: _ => { }, + Deleted: _ => { }, + PickedUp: _ => { }, + MotionUpdated: _ => { }, + PositionUpdated: _ => { }, + VectorUpdated: _ => { }, + StateUpdated: _ => { }, + ParentUpdated: _ => { }, + TeleportStarted: _ => counters.Teleport++, + AppearanceUpdated: _ => { }, + PlayPhysicsScript: _ => { }, + PlayPhysicsScriptType: _ => { }), + new LiveEnvironmentSessionSink( + EnvironChanged: _ => { }, + ServerTimeUpdated: _ => + { + if (counters.ThrowOnServerTime) + throw new InvalidOperationException("injected sink failure"); + counters.ServerTime++; + }), + NewInventoryBindings(), + NewCharacterBindings(combat), + NewSocialBindings(chat), + constructionCheckpoint); + + private static LiveInventorySessionBindings NewInventoryBindings() => new( + new ClientObjectTable(), + new LocalPlayerState(), + PlayerGuid: () => 0x50000001u, + OnShortcuts: null, + OnUseDone: null, + ItemMana: new ItemManaState(), + OnDesiredComponents: null, + ExternalContainers: new ExternalContainerState()); + + private static LiveCharacterSessionBindings NewCharacterBindings( + CombatState? combat = null) => new( + combat ?? new CombatState(), + new Spellbook(), + ResolveSkillFormulaBonus: null, + OnSkillsUpdated: null, + OnConfirmationRequest: null, + OnConfirmationDone: null, + OnCharacterOptions: null, + ClientTime: () => 0d); + + private static LiveSocialSessionBindings NewSocialBindings( + ChatLog? chat = null) => new( + chat ?? new ChatLog(), + new TurbineChatState(), + new FriendsState(), + new SquelchState()); + + private static WorldSession NewSession() => + new(new IPEndPoint(IPAddress.Loopback, 9)); + + private static void AssertSessionHandlerCounts( + WorldSession session, + int multiplier) + { + string[] directEvents = + [ + nameof(session.EntitySpawned), + nameof(session.EntityDeleted), + nameof(session.EntityPickedUp), + nameof(session.MotionUpdated), + nameof(session.PositionUpdated), + nameof(session.VectorUpdated), + nameof(session.StateUpdated), + nameof(session.ParentUpdated), + nameof(session.TeleportStarted), + nameof(session.AppearanceUpdated), + nameof(session.PlayPhysicsScriptReceived), + nameof(session.PlayPhysicsScriptTypeReceived), + nameof(session.EnvironChanged), + nameof(session.ServerTimeUpdated), + nameof(session.SpeechHeard), + nameof(session.ServerMessageReceived), + nameof(session.EmoteHeard), + nameof(session.SoulEmoteHeard), + nameof(session.PlayerKilledReceived), + nameof(session.TurbineChatReceived), + nameof(session.VitalUpdated), + nameof(session.VitalCurrentUpdated), + ]; + foreach (string eventName in directEvents) + Assert.Equal(multiplier, HandlerCount(session, eventName)); + + Assert.Equal(multiplier, HandlerCount(session, nameof(session.ObjectIntPropertyUpdated))); + Assert.Equal(multiplier * 2, HandlerCount(session, nameof(session.PlayerIntPropertyUpdated))); + Assert.Equal(multiplier, HandlerCount(session, nameof(session.PlayerInt64PropertyUpdated))); + Assert.Equal(multiplier, HandlerCount(session, nameof(session.StackSizeUpdated))); + Assert.Equal(multiplier, HandlerCount(session, nameof(session.InventoryObjectRemoved))); + } + + private static int HandlerCount(WorldSession session, string eventName) => + (EventField(session, eventName).GetValue(session) as MulticastDelegate)? + .GetInvocationList() + .Length ?? 0; + + private static TDelegate EventDelegate( + WorldSession session, + string eventName) + where TDelegate : Delegate => + Assert.IsType(EventField(session, eventName).GetValue(session)); + + private static TDelegate EventDelegate( + TOwner owner, + string eventName) + where TOwner : class + where TDelegate : Delegate => + Assert.IsType( + typeof(TOwner).GetField( + eventName, + BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(owner)); + + private static FieldInfo EventField(WorldSession session, string eventName) => + typeof(WorldSession).GetField( + eventName, + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"event field {eventName} not found"); + + private sealed class Counters + { + public int ServerTime; + public int Teleport; + public bool ThrowOnServerTime; + } +} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs index 32cba289..a2059722 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionWiringOwnershipTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Reflection; +using System.Buffers.Binary; using AcDream.Core.Chat; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -115,6 +116,35 @@ public sealed class WorldSessionWiringOwnershipTests Assert.Equal(baselineCount, session.GameEvents.RegisteredHandlerCount); } + [Fact] + public void GameEventWiring_AcceptanceGateSilencesCopiedOrInFlightDispatch() + { + var dispatcher = new GameEventDispatcher(); + var combat = new CombatState(); + bool accepting = true; + using IDisposable registration = GameEventWiring.WireAll( + dispatcher, + new ClientObjectTable(), + combat, + new Spellbook(), + new ChatLog(), + accepting: () => accepting); + byte[] first = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(first, 0x50000001u); + BinaryPrimitives.WriteSingleLittleEndian(first.AsSpan(4), 0.75f); + byte[] stale = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(stale, 0x50000001u); + BinaryPrimitives.WriteSingleLittleEndian(stale.AsSpan(4), 0.25f); + + dispatcher.Dispatch(new GameEventEnvelope( + 0u, 0u, GameEventType.UpdateHealth, first)); + accepting = false; + dispatcher.Dispatch(new GameEventEnvelope( + 0u, 0u, GameEventType.UpdateHealth, stale)); + + Assert.Equal(0.75f, combat.GetHealthPercent(0x50000001u)); + } + private static WorldSession NewSession() => new(new IPEndPoint(IPAddress.Loopback, 9)); diff --git a/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs b/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs index d8710e97..e5115b5a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/LiveCommandBusTests.cs @@ -35,6 +35,20 @@ public sealed class LiveCommandBusTests Assert.Throws(() => bus.Register(_ => { })); } + + [Fact] + public void Clear_ReleasesHandlersAndMakesExistingBusInert() + { + var bus = new LiveCommandBus(); + int calls = 0; + bus.Register(_ => calls++); + bus.Publish(new FakeCmd(1)); + + bus.Clear(); + bus.Publish(new FakeCmd(2)); + + Assert.Equal(1, calls); + } } public sealed class ChannelResolverTests