using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Runtime.Session; 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 AddShortcut, Action RemoveShortcut, Action AddFavorite, Action RemoveFavorite, Action SetSpellbookFilter, Action ForgetSpell, Action SetDesiredComponent, Action ClearDesiredComponents, Action RaiseAttribute, Action RaiseVital, Action RaiseSkill, Action TrainSkill, Action SetCharacterOptions, Action AddFriend, Action RemoveFriend, Action ClearFriends, Action RequestLegacyFriends, Action ModifyCharacterSquelch, Action ModifyAccountSquelch, Action ModifyGlobalSquelch, Action? Log = null); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); internal readonly record struct RemoveShortcutRuntimeCmd(uint Index); internal readonly record struct AddFavoriteRuntimeCmd( uint SpellId, int Position, int TabIndex); internal readonly record struct RemoveFavoriteRuntimeCmd( uint SpellId, int TabIndex); internal readonly record struct SetSpellbookFilterRuntimeCmd(uint Filters); internal readonly record struct ForgetSpellRuntimeCmd(uint SpellId); internal readonly record struct SetDesiredComponentRuntimeCmd( uint ComponentId, uint Amount); internal readonly record struct ClearDesiredComponentsRuntimeCmd; internal readonly record struct RaiseAttributeRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct RaiseVitalRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct RaiseSkillRuntimeCmd(uint StatId, ulong Cost); internal readonly record struct TrainSkillRuntimeCmd(uint StatId, uint Cost); internal readonly record struct SetCharacterOptionsRuntimeCmd(uint Options); internal readonly record struct AddFriendRuntimeCmd(string Name); internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); internal readonly record struct ClearFriendsRuntimeCmd; internal readonly record struct RequestLegacyFriendsRuntimeCmd; internal readonly record struct ModifyCharacterSquelchRuntimeCmd( bool Add, uint CharacterId, string Name, uint MessageType); internal readonly record struct ModifyAccountSquelchRuntimeCmd( bool Add, string Name); internal readonly record struct ModifyGlobalSquelchRuntimeCmd( bool Add, uint MessageType); /// /// 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.Register( command => SendIfActive(() => bindings.AddShortcut(command.Entry))); commands.Register( command => SendIfActive(() => bindings.RemoveShortcut(command.Index))); commands.Register( command => SendIfActive(() => bindings.AddFavorite( command.SpellId, command.Position, command.TabIndex))); commands.Register( command => SendIfActive(() => bindings.RemoveFavorite( command.SpellId, command.TabIndex))); commands.Register( command => SendIfActive(() => bindings.SetSpellbookFilter(command.Filters))); commands.Register( command => SendIfActive(() => bindings.ForgetSpell(command.SpellId))); commands.Register( command => SendIfActive(() => bindings.SetDesiredComponent( command.ComponentId, command.Amount))); commands.Register( _ => SendIfActive(bindings.ClearDesiredComponents)); commands.Register( command => SendIfActive(() => bindings.RaiseAttribute(command.StatId, command.Cost))); commands.Register( command => SendIfActive(() => bindings.RaiseVital(command.StatId, command.Cost))); commands.Register( command => SendIfActive(() => bindings.RaiseSkill(command.StatId, command.Cost))); commands.Register( command => SendIfActive(() => bindings.TrainSkill(command.StatId, command.Cost))); commands.Register( command => SendIfActive(() => bindings.SetCharacterOptions(command.Options))); commands.Register( command => SendIfActive(() => bindings.AddFriend(command.Name))); commands.Register( command => SendIfActive(() => bindings.RemoveFriend(command.CharacterId))); commands.Register( _ => SendIfActive(bindings.ClearFriends)); commands.Register( _ => SendIfActive(bindings.RequestLegacyFriends)); commands.Register( command => SendIfActive(() => bindings.ModifyCharacterSquelch( command.Add, command.CharacterId, command.Name, command.MessageType))); commands.Register( command => SendIfActive(() => bindings.ModifyAccountSquelch( command.Add, command.Name))); commands.Register( command => SendIfActive(() => bindings.ModifyGlobalSquelch( command.Add, command.MessageType))); _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, // Retail's own "You tell ..." echo is Speech_Direct_Send // (0x04), distinct from an incoming Tell's 0x03 — see // ChatMessageType.OutgoingTell's "You tell ..." comment. logTextType: 0x04u); 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, // Precise per-bit own-send type (LegacyChannelChatType.Resolve's // ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/ // Vassal/Follower become 0x0B, the admin catch-all stays 0x0E. logTextType: LegacyChannelChatType.Resolve(legacy.Value.ChannelId, ownSend: true)); } 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)), EnterPkLite: () => InvokeClient(static b => b.EnterPkLite())); 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); } }