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 : ILiveSessionCommandRouting { 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}", }; }