feat(runtime): share chat commands and run login sequence

This commit is contained in:
Erik 2026-08-14 20:27:45 +02:00
parent 5535d0adac
commit 41b15efd4d
57 changed files with 1739 additions and 345 deletions

View file

@ -1,10 +1,8 @@
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.UI.Abstractions;
namespace AcDream.App.Net;
@ -149,6 +147,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
{
private readonly object _gate = new();
private LiveCommandBus? _commands;
private LiveChatCommandRoute? _chatCommands;
private ClientCommandController.Bindings? _clientCommands;
private int _state; // 0 = constructed, 1 = active, 2 = disposed
@ -170,19 +169,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
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));
_chatCommands = new LiveChatCommandRoute(new LiveChatCommandBindings(
clientCommands.Execute,
bindings.Communication,
bindings.Chat,
bindings.TurbineChat,
bindings.CharacterState,
bindings.PlayerGuid,
bindings.SendTalk,
bindings.SendTell,
bindings.SendChannel,
bindings.SendTurbineChat,
bindings.Log));
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
// ChannelSystem::GetChannelID fallback tags — bypasses
// ChatChannelKind/ChannelResolver entirely and sends the raw
// legacy ChatChannel (0x0147) broadcast directly.
commands.Register<SendRawChannelCmd>(
command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text)));
commands.Register<AddShortcutRuntimeCmd>(
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
commands.Register<RemoveShortcutRuntimeCmd>(
@ -320,6 +322,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
{
if (_state == 2)
throw new ObjectDisposedException(nameof(LiveSessionCommandRouter));
_chatCommands?.Activate();
_state = 1;
}
}
@ -329,202 +332,31 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
lock (_gate)
{
if (_state == 1)
_commands?.Publish(command);
{
if (_chatCommands?.TryPublish(command) != true)
_commands?.Publish(command);
}
}
}
public void Dispose()
{
LiveCommandBus? commands;
LiveChatCommandRoute? chatCommands;
lock (_gate)
{
_state = 2;
commands = _commands;
_commands = null;
chatCommands = _chatCommands;
_chatCommands = null;
_clientCommands = null;
}
chatCommands?.Dispose();
commands?.Clear();
}
/// <summary>
/// The seven <see cref="ChatChannelKind"/> values that ride Turbine
/// (0xF7DE), mapped to the lighter <see cref="ChatChannelKindLite"/>
/// <see cref="TurbineChatMembershipGate"/> reads. Every OTHER channel
/// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast)
/// is legacy-only (0x0147) — those pipelines never overlap Turbine.
/// <see cref="ChatChannelKind.Allegiance"/> is the one exception, and
/// <see cref="RouteChat"/> special-cases it BEFORE this table is
/// consulted: S3 (CH3 Opus review, 2026-08-09) corrected the original
/// CH3 filing (research doc §5.3) — retail's <c>/a</c> is bound to the
/// LEGACY <c>AllegianceBroadcast</c> bitflag by default and is only
/// rebound to <c>DoTurbineChat_Allegiance</c> once
/// <c>StartupTurbineChatSystem</c> successfully starts Turbine chat
/// (research doc §4.3). So "Turbine never started" (<c>TurbineChat.
/// Enabled == false</c>) still falls back to legacy, while "Turbine is
/// up but this character has no allegiance room" (<c>Enabled == true</c>,
/// <c>AllegianceRoom == 0</c>) correctly keeps retail's local
/// "Turbine chat is not available." refusal at the membership gate.
/// </summary>
private static readonly Dictionary<ChatChannelKind, ChatChannelKindLite> TurbineChannelKinds = new()
{
[ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance,
[ChatChannelKind.General] = ChatChannelKindLite.General,
[ChatChannelKind.Trade] = ChatChannelKindLite.Trade,
[ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg,
[ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay,
[ChatChannelKind.Society] = ChatChannelKindLite.Society,
[ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi,
};
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,
// 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: (uint)RetailLogTextType.SpeechDirectSend,
targetOrChannel: command.TargetName);
return;
}
// S3 (CH3 Opus review, 2026-08-09): see the TurbineChannelKinds doc
// comment above — Turbine chat never having started (no 0x0295
// SetTurbineChatChannels received at all) still routes /a through
// the legacy AllegianceBroadcast bitflag, exactly like retail's
// default binding before StartupTurbineChatSystem runs.
if (command.Channel == ChatChannelKind.Allegiance
&& !bindings.TurbineChat.Enabled)
{
RouteLegacyChannel(bindings, ChatChannelKind.AllegianceBroadcast, command.Text);
return;
}
if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind))
{
RouteTurbineChat(bindings, liteKind, command.Text);
return;
}
RouteLegacyChannel(bindings, command.Channel, command.Text);
}
/// <summary>
/// Step 2 of the CH3 fix list: retail
/// <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>'s local
/// membership gate, raised through the same
/// <c>RuntimeCommunicationState.AddText</c> chokepoint CH2 built for
/// every other client-raised refusal.
/// </summary>
private void RouteTurbineChat(
LiveSessionCommandBindings bindings,
ChatChannelKindLite kind,
string text)
{
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
kind,
bindings.TurbineChat,
bindings.CharacterState.Options,
bindings.CharacterState.IsOlthoiPlayer);
// N3 (CH3 Opus review): the gate-result-to-refusal-text mapping is
// now shared with DirectGameRuntimeCommandAdapter.TrySendChannel via
// TurbineChatMembershipGate.ResolveRefusalText — this used to be an
// independent copy of the same switch.
if (gate.Status != TurbineChatGateStatus.Allowed)
{
if (TurbineChatMembershipGate.ResolveRefusalText(gate) is
(string refusalText, RetailLogTextType refusalType))
{
bindings.Communication.AddText(refusalText, refusalType);
}
return;
}
uint cookie = bindings.TurbineChat.NextContextId();
uint senderGuid = bindings.PlayerGuid();
bindings.Log?.Invoke(
$"chat: outbound TurbineChat {gate.DisplayName} " +
$"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " +
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}");
SendIfActive(() => bindings.SendTurbineChat(
gate.RoomId,
gate.ChatType,
(uint)TurbineChat.DispatchType.SendToRoomById,
senderGuid,
text,
cookie));
}
private void RouteLegacyChannel(
LiveSessionCommandBindings bindings,
ChatChannelKind channel,
string text)
{
ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel);
if (legacy is null)
{
bindings.Log?.Invoke(
$"chat: SendChatCmd kind={channel} dropped (no legacy id)");
return;
}
bindings.Log?.Invoke(
$"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " +
$"id=0x{legacy.Value.ChannelId:X8} len={text.Length}");
if (!SendIfActive(() =>
bindings.SendChannel(legacy.Value.ChannelId, text)))
return;
// Step 5: wire ChatChannelInfo.IsSelfEchoChannel() — ACE resends
// Fellow/Vassals/Patron/Monarch/CoVassals to the sender with an
// empty sender name, so a local optimistic echo double-prints. S1
// (CH3 Opus review, 2026-08-09) corrected AllegianceBroadcast into
// this SAME group: ACE's GameActionChatChannel handler iterates
// player.Allegiance.Members and the sender is one of them, so they
// get their own line back with their real name too — a different
// mechanism (no separate ""-sender resend) but the same
// double-print risk, so it must ALSO skip the local echo (research
// doc §3.7/§5.4, corrected).
bool serverEchoes = new ChatChannelInfo.Legacy(
legacy.Value.ChannelId,
legacy.Value.DisplayName).IsSelfEchoChannel();
if (serverEchoes)
return;
bindings.Chat.OnSelfSent(
ChatKind.Channel,
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/audit/sentinel
// catch-all becomes 0x09 Channel_Send (corrected 2026-08-09,
// Opus review of 172c6f9a — was wrongly 0x0E).
logTextType: LegacyChannelChatType.Resolve(legacy.Value.ChannelId, ownSend: true));
}
private ClientCommandController.Bindings BuildGuardedClientCommands(
ClientCommandController.Bindings source) => new(
TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()),