Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula. Co-authored-by: Codex <codex@openai.com>
326 lines
13 KiB
C#
326 lines
13 KiB
C#
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<uint> PlayerGuid,
|
|
Action<string> SendTalk,
|
|
Action<string, string> SendTell,
|
|
Action<uint, string> SendChannel,
|
|
Action<uint, uint, uint, uint, string, uint> SendTurbineChat,
|
|
Action<string>? Log = null);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<ExecuteClientCommandCmd>(clientCommands.Execute);
|
|
commands.Register<SendServerCommandCmd>(command =>
|
|
{
|
|
if (!string.IsNullOrEmpty(command.Text))
|
|
SendIfActive(() => bindings.SendTalk(command.Text));
|
|
});
|
|
commands.Register<SendChatCmd>(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>(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<ClientCommandController.Bindings> invoke)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ClientCommandController.Bindings? bindings = _clientCommands;
|
|
if (_state != 1 || bindings is null)
|
|
return false;
|
|
invoke(bindings);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private TResult ReadClient<TResult>(
|
|
Func<ClientCommandController.Bindings, TResult> 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
|
|
{
|
|
/// <summary>
|
|
/// Resolve the server-assigned Turbine room for one UI channel. This is
|
|
/// the existing holtburger <c>resolve_turbine_channel</c> mapping
|
|
/// (<c>references/holtburger/.../client/commands.rs</c>, lines 64-98),
|
|
/// moved intact from GameWindow with its runtime-room gate preserved.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Derive the retail-facing channel label from the inbound Turbine chat
|
|
/// type, retaining the numeric-room fallback for unknown extensions.
|
|
/// </summary>
|
|
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}",
|
|
};
|
|
}
|