feat(runtime): share chat commands and run login sequence
This commit is contained in:
parent
5535d0adac
commit
41b15efd4d
57 changed files with 1739 additions and 345 deletions
|
|
@ -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()),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue