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

@ -581,7 +581,7 @@ internal sealed class FrameRootCompositionPhase
var liveFrameCoordinator = new RetailLiveFrameCoordinator(
session.LiveObjectFrame,
live.WorldState,
session.LiveSession,
session.SessionHost,
session.LocalPlayerFrame,
session.LiveSpatialReconciler,
live.WorldAvailability,

View file

@ -1130,7 +1130,9 @@ internal sealed class SessionPlayerCompositionPhase
liveSessionCommands,
d.Log,
d.StatusWriter,
d.Options.SessionId ?? "app");
d.Options.SessionId ?? "app",
d.Options.LoginCommands,
d.Options.LoginCommandDelayMs);
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
liveSession,
new LiveSessionConnectOptions(

View file

@ -115,12 +115,12 @@ internal sealed record SessionDescriptor
/// allow-list semantics for this field (OP7 D8).</summary>
public Dictionary<string, bool>? CharacterOptions { get; init; }
/// <summary>LA1: plugin ids to load. Absent = load all (LA5 consumes
/// this; parsed and carried here now per the pinned launch contract).</summary>
/// <summary>LA1/LA5: plugin ids to load. Absent = load all; explicit
/// empty = load none.</summary>
public List<string>? Plugins { get; init; }
/// <summary>LA1: ordered chat-typed strings run after entering world
/// (LA6 consumes this; parsed and carried here now).</summary>
/// <summary>LA1/LA6: ordered chat-typed strings run through the shared
/// Runtime parser/router after entering world.</summary>
public List<string>? LoginCommands { get; init; }
/// <summary>LA1: inter-command delay for <see cref="LoginCommands"/>,

View file

@ -1,4 +1,5 @@
global using AcDream.Runtime.Gameplay;
global using AcDream.Runtime.Physics;
global using AcDream.Runtime.Chat;
global using ILocalPlayerMotionSource =
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;

View file

@ -12,5 +12,5 @@ internal interface ILiveWorldSessionSource
internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource
{
AcDream.UI.Abstractions.ICommandBus Commands { get; }
AcDream.Runtime.Chat.ICommandBus Commands { get; }
}

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()),

View file

@ -28,6 +28,7 @@ using AcDream.Runtime;
using AcDream.Runtime.Entities;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Runtime.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Vitals;
using DatReaderWriter;
@ -106,6 +107,9 @@ internal sealed class LiveSessionRuntimeFactory
private readonly LiveMovementStatsApplier _movementStats;
private readonly SessionStatusWriter _statusWriter;
private readonly string _sessionId;
private readonly IReadOnlyList<string> _loginCommands;
private readonly TimeSpan _loginCommandDelay;
private readonly TimeProvider _timeProvider;
public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player,
@ -116,7 +120,10 @@ internal sealed class LiveSessionRuntimeFactory
LiveSessionCommandSurface commands,
Action<string> log,
SessionStatusWriter? statusWriter = null,
string sessionId = "app")
string sessionId = "app",
IReadOnlyList<string>? loginCommands = null,
int loginCommandDelayMs = 500,
TimeProvider? timeProvider = null)
{
_player = player ?? throw new ArgumentNullException(nameof(player));
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
@ -130,6 +137,14 @@ internal sealed class LiveSessionRuntimeFactory
// status file configured — every call site below stays unconditional.
_statusWriter = statusWriter ?? new SessionStatusWriter(null);
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
if (loginCommandDelayMs < 0)
{
throw new ArgumentOutOfRangeException(
nameof(loginCommandDelayMs));
}
_loginCommands = loginCommands is null ? [] : [.. loginCommands];
_loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs);
_timeProvider = timeProvider ?? TimeProvider.System;
// C3c-F1: stat recomputes route through the Runtime movement owner's
// typed application seam; App keeps zero direct controller mutations.
_movementStats = new LiveMovementStatsApplier(
@ -150,6 +165,17 @@ internal sealed class LiveSessionRuntimeFactory
LiveSessionResetPlan reset =
LiveSessionResetManifest.Create(
CreateResetBindings(resetHost));
var loginCommands = new LoginCommandSequence(
_loginCommands,
_loginCommandDelay,
new RuntimeChatCommandFeedback(_domain.Communication),
_commands,
failure => _statusWriter.LoginCommandFailed(
_sessionId,
failure.CommandIndex,
failure.Command,
failure.Error),
_timeProvider);
return new LiveSessionHost(controller, new LiveSessionHostBindings(
Routing: new(
CreateEventRouter,
@ -194,7 +220,8 @@ internal sealed class LiveSessionRuntimeFactory
CharacterEntered: selection => _statusWriter.EnteredWorld(
_sessionId,
selection.CharacterId,
selection.CharacterName)),
selection.CharacterName),
LoginCommands: loginCommands),
connectOptions);
}

View file

@ -87,10 +87,10 @@ public sealed record RuntimeOptions(
string? StatusFilePath,
/// <summary>Campaign LA slice LA1: plugin ids to load.
/// <see langword="null"/> = load every discovered plugin (today's
/// behavior). Consumed by LA5; parsed and carried now.</summary>
/// behavior). Consumed by the shared graphical plugin session.</summary>
IReadOnlyList<string>? Plugins,
/// <summary>Campaign LA slice LA1: ordered chat-typed strings run once
/// entered-world. Consumed by LA6; parsed and carried now.</summary>
/// entered-world through the shared Runtime parser/router.</summary>
IReadOnlyList<string> LoginCommands,
/// <summary>Campaign LA slice LA1: inter-command delay for
/// <see cref="LoginCommands"/>, milliseconds.</summary>

View file

@ -207,7 +207,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
/// <param name="busProvider">Factory that returns the live command bus at submit time.
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
/// Called on every chat submit so it resolves <see cref="LiveCommandBus"/>
/// even when the live session is established AFTER <see cref="Bind"/> runs
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
/// <param name="windowFilters">Runtime's canonical per-window filter/open state

View file

@ -99,16 +99,15 @@ internal sealed record HeadlessSessionDescriptor
/// <summary>
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
/// Absent means load every discovered plugin (today's dev behavior);
/// LA5 wires this into an actual allow-list filter. Parsed and carried
/// here now so the session-config shape is stable before LA5 lands.
/// Absent means load every discovered plugin (the developer flow);
/// explicit empty means load none. LA5 host composition consumes this
/// as the actual allow-list filter.
/// </summary>
public List<string>? Plugins { get; init; }
/// <summary>
/// Campaign LA slice LA1: ordered chat-typed strings run once the
/// session enters world. LA6 wires actual execution; parsed and carried
/// here now.
/// Campaign LA slice LA1/LA6: ordered chat-typed strings run through the
/// shared Runtime parser/router once the session enters world.
/// </summary>
public List<string>? LoginCommands { get; init; }
@ -123,8 +122,7 @@ internal sealed record HeadlessSessionDescriptor
/// <summary>
/// Campaign LA slice LA1: absolute path for this session's status-event
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
/// §6). Absent means no <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>
/// is constructed for this session.
/// §6). Absent selects the writer's permanent no-op mode.
/// </summary>
public string? StatusFile { get; init; }
}

View file

@ -323,8 +323,9 @@ internal static class HeadlessConfigurationLoader
/// Campaign LA slice LA1: validates the four new optional per-session
/// fields shared with the App session-config reader (see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's pinned
/// contract). All four stay optional; only their SHAPE is checked here
/// — parsing/executing <c>plugins</c>/<c>loginCommands</c> is LA5/LA6.
/// contract). All four stay optional; this loader owns their strict
/// shape checks while LA5/LA6 host composition consumes the resulting
/// plugin allow-list and ordered login-command sequence.
/// </summary>
private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session)
{

View file

@ -6,6 +6,7 @@ using AcDream.Headless.Policies;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime;
using AcDream.Runtime.Chat;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Physics;
using AcDream.Runtime.Session;
@ -14,29 +15,54 @@ namespace AcDream.Headless.Hosting;
internal sealed class HeadlessSessionHost : IDisposable
{
private sealed class SessionCommandRoute(
ILiveSessionCommandRouting gameplay,
ILiveSessionCommandRouting commands)
: ILiveSessionCommandRouting
private sealed class SessionCommandRoute : ILiveSessionCommandRouting
{
private bool _gameplayActive;
private bool _commandsActive;
private ILiveSessionCommandRouting? _gameplay;
private ILiveSessionCommandRouting? _commands;
private ILiveSessionCommandRouting? _chat;
private bool _activated;
internal SessionCommandRoute(
ILiveSessionCommandRouting gameplay,
ILiveSessionCommandRouting commands,
ILiveSessionCommandRouting chat)
{
_gameplay = gameplay
?? throw new ArgumentNullException(nameof(gameplay));
_commands = commands
?? throw new ArgumentNullException(nameof(commands));
_chat = chat
?? throw new ArgumentNullException(nameof(chat));
}
public void Activate()
{
if (_gameplayActive || _commandsActive)
if (_activated)
return;
gameplay.Activate();
_gameplayActive = true;
if (_gameplay is null || _commands is null || _chat is null)
throw new ObjectDisposedException(nameof(SessionCommandRoute));
_gameplay.Activate();
try
{
commands.Activate();
_commandsActive = true;
_commands.Activate();
_chat.Activate();
_activated = true;
}
catch
catch (Exception activationError)
{
gameplay.Dispose();
_gameplayActive = false;
try
{
Dispose();
}
catch (Exception disposalError)
{
throw new AggregateException(
"Headless command-route activation and rollback failed.",
activationError,
disposalError);
}
throw;
}
}
@ -44,30 +70,9 @@ internal sealed class HeadlessSessionHost : IDisposable
public void Dispose()
{
List<Exception>? failures = null;
if (_commandsActive)
{
try
{
commands.Dispose();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
_commandsActive = false;
}
if (_gameplayActive)
{
try
{
gameplay.Dispose();
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
_gameplayActive = false;
}
TryDispose(ref _chat, ref failures);
TryDispose(ref _commands, ref failures);
TryDispose(ref _gameplay, ref failures);
if (failures is not null)
{
throw new AggregateException(
@ -75,6 +80,24 @@ internal sealed class HeadlessSessionHost : IDisposable
failures);
}
}
private static void TryDispose(
ref ILiveSessionCommandRouting? route,
ref List<Exception>? failures)
{
if (route is not { } current)
return;
try
{
current.Dispose();
route = null;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
private sealed class SessionCommandBridge : IRuntimeSessionCommands
@ -301,6 +324,18 @@ internal sealed class HeadlessSessionHost : IDisposable
// descriptor.StatusFile is unset — every call site below stays
// unconditional.
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
var chatCommandSurface = new LiveChatCommandSurface();
var loginCommands = new LoginCommandSequence(
descriptor.LoginCommands,
TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs),
new RuntimeChatCommandFeedback(runtime.CommunicationOwner),
chatCommandSurface,
failure => statusWriter.LoginCommandFailed(
descriptor.Id,
failure.CommandIndex,
failure.Command,
failure.Error),
_timeProvider);
pluginSession = HeadlessPluginSession.Create(
runtime,
diagnostics,
@ -315,7 +350,12 @@ internal sealed class HeadlessSessionHost : IDisposable
CreateEventRoute,
session => new SessionCommandRoute(
gameplay.CreateRoute(session),
commands.CreateRoute(session))),
commands.CreateRoute(session),
chatCommandSurface.Attach(
new LiveChatCommandRoute(
CreateChatCommandBindings(
session,
runtime))))),
generation =>
runtime.ResetGeneration(generation, _resetHost),
new LiveSessionSelectionBindings(
@ -368,7 +408,8 @@ internal sealed class HeadlessSessionHost : IDisposable
selection => statusWriter.EnteredWorld(
descriptor.Id,
selection.CharacterId,
selection.CharacterName)));
selection.CharacterName),
loginCommands));
Runtime = runtime;
Commands = commands;
@ -506,7 +547,7 @@ internal sealed class HeadlessSessionHost : IDisposable
_ = Runtime.Clock.Advance(deltaSeconds);
_localPlayerFrame.AdvanceBeforeNetwork(
checked((float)deltaSeconds));
Runtime.Session.Tick();
_liveSession.Tick();
// C3c: pump pending first-entry sequences after the network drain —
// collision-generation progress and freshly accepted Creates both
// surface here, mirroring the graphical per-frame retry phase.
@ -821,6 +862,142 @@ internal sealed class HeadlessSessionHost : IDisposable
}
}
private LiveChatCommandBindings CreateChatCommandBindings(
AcDream.Core.Net.WorldSession session,
GameRuntime runtime) => new(
ExecuteClientCommand: command =>
ExecuteHeadlessClientCommand(session, runtime, command),
Communication: runtime.CommunicationOwner,
Chat: runtime.CommunicationOwner.Chat,
TurbineChat: runtime.CommunicationOwner.TurbineChat,
CharacterState: runtime.CharacterOwner,
PlayerGuid: () => runtime.PlayerIdentity.ServerGuid,
SendTalk: session.SendTalk,
SendTell: session.SendTell,
SendChannel: session.SendChannel,
SendTurbineChat: session.SendTurbineChatTo,
Log: message => _diagnostics.Message(
_descriptor.Id,
message,
runtime.Generation.Value));
/// <summary>
/// Presentation-free subset of retail client commands. Commands whose
/// semantics require a graphical confirmation/window or a host-specific
/// presentation service fail explicitly; the login sequence reports that
/// one line and continues. Wire-only and canonical-state commands take
/// the exact same WorldSession/Runtime paths as the graphical bindings.
/// </summary>
private static void ExecuteHeadlessClientCommand(
AcDream.Core.Net.WorldSession session,
GameRuntime runtime,
ExecuteClientCommandCmd command)
{
switch (command.Command)
{
case ClientCommandId.LifestoneRecall:
session.SendTeleportToLifestone();
return;
case ClientCommandId.MarketplaceRecall:
session.SendTeleportToMarketplace();
return;
case ClientCommandId.PkArenaRecall:
session.SendTeleportToPkArena();
return;
case ClientCommandId.PkLiteArenaRecall:
session.SendTeleportToPkLiteArena();
return;
case ClientCommandId.EnterPkLite:
session.SendEnterPkLite();
return;
case ClientCommandId.HouseRecall:
session.SendTeleportToHouse();
return;
case ClientCommandId.MansionRecall:
session.SendTeleportToMansion();
return;
case ClientCommandId.QueryAge:
session.SendQueryAge();
return;
case ClientCommandId.QueryBirth:
session.SendQueryBirth();
return;
case ClientCommandId.Emote
when !string.IsNullOrWhiteSpace(command.Arguments):
session.SendEmote(command.Arguments.Trim());
return;
case ClientCommandId.ClearChat:
runtime.CommunicationOwner.Chat.Clear();
return;
case ClientCommandId.IndexChannels:
session.SendIndexChannels();
return;
case ClientCommandId.ListChannel:
SendResolvedChannel(
command.Arguments,
session.SendListChannel);
return;
case ClientCommandId.OnChannel:
SendResolvedChannel(
command.Arguments,
session.SendOnChannel);
return;
case ClientCommandId.OffChannel:
SendResolvedChannel(
command.Arguments,
session.SendOffChannel);
return;
case ClientCommandId.AllegianceHometown:
session.SendRecallAllegianceHometown();
return;
case ClientCommandId.AllegianceInfo:
session.SendAllegianceInfoRequest(command.Arguments.Trim());
return;
case ClientCommandId.HouseAvailableList
when RetailClientCommandCatalog.TryResolveHouseType(
command.Arguments,
out uint houseType):
session.SendListAvailableHouses(houseType);
return;
case ClientCommandId.JoinChannel
when RetailClientCommandCatalog.TryResolveJoinLeaveOption(
command.Arguments,
out uint joinOption):
_ = runtime.CharacterOwner.Options.TrySetOption(
joinOption,
true,
session.SendSetSingleCharacterOption);
return;
case ClientCommandId.LeaveChannel
when RetailClientCommandCatalog.TryResolveJoinLeaveOption(
command.Arguments,
out uint leaveOption):
_ = runtime.CharacterOwner.Options.TrySetOption(
leaveOption,
false,
session.SendSetSingleCharacterOption);
return;
default:
throw new NotSupportedException(
$"Client command '{command.Command}' is not available "
+ "in the headless host.");
}
static void SendResolvedChannel(
string arguments,
Action<uint> send)
{
if (!RetailChannelTagTable.TryResolve(
arguments.Trim(),
out uint channelId))
{
throw new InvalidOperationException(
$"Chat channel '{arguments.Trim()}' does not exist.");
}
send(channelId);
}
}
private ILiveSessionEventRouting CreateEventRoute(
AcDream.Core.Net.WorldSession session)
{

View file

@ -849,6 +849,11 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
activity.Error = $"Plugin failed: {failed.Plugin}: {failed.Error}";
activity.Status = activity.Error;
break;
case LoginCommandFailedStatusEvent failed:
activity.Error =
$"Login command {failed.CommandIndex} failed: {failed.Error}";
activity.Status = activity.Error;
break;
case DisconnectedStatusEvent disconnected:
if (activity.State != LauncherActivityState.Stopping)
{

View file

@ -56,6 +56,15 @@ public sealed record PluginFailedStatusEvent : StatusEvent
public required string Error { get; init; }
}
public sealed record LoginCommandFailedStatusEvent : StatusEvent
{
public required int CommandIndex { get; init; }
public required string Command { get; init; }
public required string Error { get; init; }
}
public sealed record DisconnectedStatusEvent : StatusEvent
{
public required string Reason { get; init; }

View file

@ -101,6 +101,8 @@ public static class StatusEventParser
ParsePluginLoaded(root, v, e, t, sessionId),
"pluginFailed" =>
ParsePluginFailed(root, v, e, t, sessionId),
"loginCommandFailed" =>
ParseLoginCommandFailed(root, v, e, t, sessionId),
"disconnected" =>
ParseDisconnected(root, v, e, t, sessionId),
"exited" =>
@ -128,6 +130,7 @@ public static class StatusEventParser
"enteredWorld" or
"pluginLoaded" or
"pluginFailed" or
"loginCommandFailed" or
"disconnected" or
"exited";
@ -280,6 +283,32 @@ public static class StatusEventParser
Reason = RequireString(root, "reason"),
};
private static StatusEvent ParseLoginCommandFailed(
JsonElement root,
int v,
string e,
DateTimeOffset t,
string sessionId)
{
int commandIndex = RequireInt32(root, "commandIndex");
if (commandIndex < 0)
{
throw new FormatException(
"status event field 'commandIndex' is negative.");
}
return new LoginCommandFailedStatusEvent
{
V = v,
E = e,
T = t,
SessionId = sessionId,
CommandIndex = commandIndex,
Command = RequireString(root, "command"),
Error = RequireString(root, "error"),
};
}
private static StatusEvent ParseExited(
JsonElement root,
int v,

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Maps a <see cref="ChatChannelKind"/> to the legacy <c>ChatChannel</c>

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Outbound chat channel selector. Mirrors holtburger's <c>ChatChannelKind</c>

View file

@ -2,7 +2,7 @@ using System;
using System.Linq;
using AcDream.Core.Chat;
namespace AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.Runtime.Chat;
/// <summary>What a submit did, so the caller can clear its input + give feedback.
/// <c>UnknownCommand</c> is produced only for command-shaped but verbless
@ -11,8 +11,8 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
/// <summary>
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @
/// 0x004F5100</c> analogue). Both the ImGui devtools <see cref="ChatPanel"/>
/// and retained retail chat window route through here.
/// 0x004F5100</c> analogue). Every graphical and headless chat entrance routes
/// through here.
///
/// <para>
/// Flow: emote-prefix rewrite, retail client-command catalog, local
@ -20,17 +20,20 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
/// <c>ChannelSystem::GetChannelID</c> fallback (unregistered GM/faction
/// channel tags), explicit server command, then chat parse. Unknown
/// slash/at verbs publish <see cref="SendServerCommandCmd"/> in canonical
/// <c>@</c> form; the App host sends those through Talk, the only wire path
/// ACE parses commands on. Prefix text with no letter verb is refused locally
/// so command-shaped input can never leak into speech.
/// <c>@</c> form; the active host sends those through Talk, the only wire
/// path ACE parses commands on. Prefix text with no letter verb is refused
/// locally so command-shaped input can never leak into speech.
/// </para>
/// </summary>
public static class ChatCommandRouter
{
public static SubmitOutcome Submit(
string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel)
string? raw,
IChatCommandFeedback feedback,
ICommandBus bus,
ChatChannelKind defaultChannel)
{
ArgumentNullException.ThrowIfNull(vm);
ArgumentNullException.ThrowIfNull(feedback);
ArgumentNullException.ThrowIfNull(bus);
string trimmed = (raw ?? string.Empty).Trim();
if (trimmed.Length == 0)
@ -80,7 +83,7 @@ public static class ChatCommandRouter
// ClientLocal is correct for it.
if (clientCommand.InvalidArgumentsText is { } bespokeRefusal)
{
vm.ShowInterfaceText(bespokeRefusal);
feedback.ShowInterfaceText(bespokeRefusal);
}
else
{
@ -88,9 +91,9 @@ public static class ChatCommandRouter
WeenieErrorMessages.Resolve(0x026u, null);
string fallbackText = fallback.text ?? "That is not a valid command.";
if (fallback.type == RetailLogTextType.ClientLocal)
vm.ShowInterfaceText(fallbackText);
feedback.ShowInterfaceText(fallbackText);
else
vm.ShowSystemMessage(fallbackText);
feedback.ShowSystemMessage(fallbackText);
}
return SubmitOutcome.ClientHandled;
}
@ -100,7 +103,7 @@ public static class ChatCommandRouter
return SubmitOutcome.ClientHandled;
}
if (TryHandleLocalPresentationCommand(trimmed, vm))
if (TryHandleLocalPresentationCommand(trimmed, feedback))
return SubmitOutcome.ClientHandled;
// Command-shaped but no letter verb ("/", "//shrug", "@ x"):
@ -112,7 +115,7 @@ public static class ChatCommandRouter
if (trimmed[0] is '/' or '@'
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
{
vm.ShowInterfaceText(
feedback.ShowInterfaceText(
$"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands.");
return SubmitOutcome.UnknownCommand;
}
@ -130,7 +133,7 @@ public static class ChatCommandRouter
// would resolve them too, but retail never reaches this fallback
// for a REGISTERED verb (it's intercepted by the main hash table
// first).
SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, vm, bus);
SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, bus);
if (fallbackOutcome is { } outcome)
return outcome;
@ -147,18 +150,23 @@ public static class ChatCommandRouter
// null" shape does for these cases.
if (ChatInputParser.IsBareRegisteredChannelVerb(trimmed))
{
vm.ShowInterfaceText("You must specify the text you wish to say!");
feedback.ShowInterfaceText("You must specify the text you wish to say!");
return SubmitOutcome.ClientHandled;
}
if (ChatInputParser.IsReplyMissingLastTeller(trimmed, vm.LastIncomingTellSender))
if (ChatInputParser.IsReplyMissingLastTeller(
trimmed,
feedback.LastIncomingTellSender))
{
vm.ShowInterfaceText("Someone must @tell you first!");
feedback.ShowInterfaceText("Someone must @tell you first!");
return SubmitOutcome.ClientHandled;
}
var parsed = ChatInputParser.Parse(
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
trimmed,
defaultChannel,
feedback.LastIncomingTellSender,
feedback.LastOutgoingTellTarget);
if (parsed is { } chat)
{
bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text));
@ -173,7 +181,9 @@ public static class ChatCommandRouter
/// fallback channel tags (caller continues its own dispatch chain);
/// otherwise returns the outcome to return immediately.
/// </summary>
private static SubmitOutcome? TryDispatchChannelFallback(string trimmed, ChatVM vm, ICommandBus bus)
private static SubmitOutcome? TryDispatchChannelFallback(
string trimmed,
ICommandBus bus)
{
if (trimmed[0] is not ('/' or '@'))
return null;
@ -240,11 +250,13 @@ public static class ChatCommandRouter
return true;
}
private static bool TryHandleLocalPresentationCommand(string trimmed, ChatVM vm)
private static bool TryHandleLocalPresentationCommand(
string trimmed,
IChatCommandFeedback feedback)
{
if (EqAny(trimmed, "/help", "/?", "@help", "@?"))
{
EmitBareHelp(vm);
EmitBareHelp(feedback);
return true;
}
@ -255,7 +267,7 @@ public static class ChatCommandRouter
if (StartsWithAny(trimmed, "/help ", "@help ", "/? ", "@? "))
{
string verb = trimmed[(trimmed.IndexOf(' ') + 1)..].Trim();
EmitVerbHelp(verb, vm);
EmitVerbHelp(verb, feedback);
return true;
}
@ -268,10 +280,10 @@ public static class ChatCommandRouter
/// user-gate round 3, finding (b) — see <see cref="RetailCommandHelpTable"/>'s
/// class remarks for the full print-sequence trace).
/// </summary>
private static void EmitBareHelp(ChatVM vm)
private static void EmitBareHelp(IChatCommandFeedback feedback)
{
vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
vm.ShowSystemMessage(RetailCommandHelpTable.AvailableHelpListing);
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
feedback.ShowSystemMessage(RetailCommandHelpTable.AvailableHelpListing);
}
/// <summary>
@ -302,11 +314,13 @@ public static class ChatCommandRouter
/// — chat-alias/channel verbs and the group-topic nodes, a disjoint key
/// space from the catalog so this reordering changes nothing for them.
/// </remarks>
private static void EmitVerbHelp(string verb, ChatVM vm)
private static void EmitVerbHelp(
string verb,
IChatCommandFeedback feedback)
{
if (verb.Length == 0)
{
EmitBareHelp(vm);
EmitBareHelp(feedback);
return;
}
@ -319,35 +333,35 @@ public static class ChatCommandRouter
// SAME fallback an unregistered verb gets — DoHelp's help-
// pointer-null guard skips its callback branch entirely. See
// RetailCommandHelpTable.CatalogVerbsWithNoRetailHelp's remarks.
vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
return;
}
if (RetailCommandHelpTable.TryGetCatalogVerbDetailText(normalized, out string retailDetailText))
{
vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + retailDetailText);
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + retailDetailText);
return;
}
if (RetailClientCommandCatalog.TryGetHelpText(normalized, out string catalogText))
{
vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + catalogText);
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + catalogText);
return;
}
if (RetailCommandHelpTable.TryGetHelpText(normalized, out string tableText))
{
vm.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
vm.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + tableText);
feedback.ShowSystemMessage(RetailCommandHelpTable.HelpPrefixNote);
feedback.ShowSystemMessage(RetailCommandHelpTable.ForMoreInformationPrefix + tableText);
return;
}
// Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367:
// now routed through ChatVM.ShowInterfaceText instead of the chat
// scroll — see the class remarks on RetailCommandHelpTable.UnknownCommand.
vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
// now routed through IChatCommandFeedback.ShowInterfaceText instead
// of the chat scroll — see RetailCommandHelpTable.UnknownCommand.
feedback.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
}
private static bool EqAny(string value, params string[] options)

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Phase I.4: pure-function parse of a chat-input line into the

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Backend-neutral identity for a command that retail executes in the client
@ -85,8 +85,8 @@ public enum ClientCommandId
/// <summary>
/// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with
/// any subcommand beyond the 2 ported ones (info, hometown/ho). Never
/// dispatched — <see cref="AcDream.UI.Abstractions.Panels.Chat.
/// RetailClientCommandCatalog.Match.HasValidArguments"/> is always
/// dispatched — <see cref="RetailClientCommandCatalog.Match.HasValidArguments"/>
/// is always
/// false for this id, so <c>ChatCommandRouter</c> shows retail's own
/// "Please see @help Allegiance..." refusal and never publishes an
/// <c>ExecuteClientCommandCmd</c>.

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// User intent to execute a retail client command. The application host owns

View file

@ -0,0 +1,17 @@
namespace AcDream.Runtime.Chat;
/// <summary>
/// The exact feedback surface used by the retail chat command parser/router.
/// Presentation hosts may implement it, while headless execution binds these
/// members directly to the canonical Runtime communication state.
/// </summary>
public interface IChatCommandFeedback
{
void ShowInterfaceText(string text);
void ShowSystemMessage(string text);
string? LastIncomingTellSender { get; }
string? LastOutgoingTellTarget { get; }
}

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Publishes user-intent commands from panels to the systems that handle

View file

@ -0,0 +1,345 @@
using AcDream.Core.Chat;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Exact live-session dependencies for the four chat-core command records.
/// Both graphical and no-window hosts bind this record to the active
/// <c>WorldSession</c>'s send methods and the same canonical Runtime state.
/// </summary>
public sealed record LiveChatCommandBindings(
Action<ExecuteClientCommandCmd> ExecuteClientCommand,
RuntimeCommunicationState Communication,
ChatLog Chat,
TurbineChatState TurbineChat,
RuntimeCharacterState CharacterState,
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>
/// One generation's active binding for the four chat-core records. The route
/// becomes inert before the transport is disposed and clears every delegate
/// during teardown.
/// </summary>
public sealed class LiveChatCommandRoute
: ILiveSessionCommandRouting,
ICommandBus
{
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 readonly object _gate = new();
private LiveCommandBus? _commands;
private int _state; // 0 = constructed, 1 = active, 2 = disposed
public LiveChatCommandRoute(LiveChatCommandBindings bindings)
{
ArgumentNullException.ThrowIfNull(bindings);
ArgumentNullException.ThrowIfNull(bindings.ExecuteClientCommand);
ArgumentNullException.ThrowIfNull(bindings.Communication);
ArgumentNullException.ThrowIfNull(bindings.Chat);
ArgumentNullException.ThrowIfNull(bindings.TurbineChat);
ArgumentNullException.ThrowIfNull(bindings.CharacterState);
ArgumentNullException.ThrowIfNull(bindings.PlayerGuid);
ArgumentNullException.ThrowIfNull(bindings.SendTalk);
ArgumentNullException.ThrowIfNull(bindings.SendTell);
ArgumentNullException.ThrowIfNull(bindings.SendChannel);
ArgumentNullException.ThrowIfNull(bindings.SendTurbineChat);
var commands = new LiveCommandBus();
commands.Register<ExecuteClientCommandCmd>(command =>
SendIfActive(() => bindings.ExecuteClientCommand(command)));
commands.Register<SendServerCommandCmd>(command =>
{
if (!string.IsNullOrEmpty(command.Text))
SendIfActive(() => bindings.SendTalk(command.Text));
});
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
commands.Register<SendRawChannelCmd>(command =>
SendIfActive(() =>
bindings.SendChannel(command.ChannelId, command.Text)));
_commands = commands;
}
public bool IsActive
{
get
{
lock (_gate)
return _state == 1;
}
}
public void Activate()
{
lock (_gate)
{
if (_state == 2)
throw new ObjectDisposedException(nameof(LiveChatCommandRoute));
_state = 1;
}
}
public void Publish<T>(T command) where T : notnull
{
if (!TryPublish(command))
{
Console.WriteLine(
$"[LiveChatCommandRoute] unsupported command type "
+ $"{typeof(T).FullName}; dropping.");
}
}
/// <summary>
/// Routes one of the four extracted command records and returns
/// <see langword="true"/>. Other records are left to a host's sibling
/// command router and return <see langword="false"/> without logging.
/// </summary>
public bool TryPublish<T>(T command) where T : notnull
{
ArgumentNullException.ThrowIfNull(command);
Type type = typeof(T);
if (type != typeof(ExecuteClientCommandCmd)
&& type != typeof(SendServerCommandCmd)
&& type != typeof(SendChatCmd)
&& type != typeof(SendRawChannelCmd))
{
return false;
}
lock (_gate)
{
if (_state == 1)
_commands?.Publish(command);
}
return true;
}
public void Dispose()
{
LiveCommandBus? commands;
lock (_gate)
{
_state = 2;
commands = _commands;
_commands = null;
}
commands?.Clear();
}
private void RouteChat(
LiveChatCommandBindings bindings,
SendChatCmd command)
{
if (string.IsNullOrEmpty(command.Text))
return;
switch (command.Channel)
{
case ChatChannelKind.Say:
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,
logTextType: (uint)RetailLogTextType.SpeechDirectSend,
targetOrChannel: command.TargetName);
return;
}
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);
}
private void RouteTurbineChat(
LiveChatCommandBindings bindings,
ChatChannelKindLite kind,
string text)
{
TurbineChatState turbineChat = bindings.TurbineChat;
TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate(
kind,
turbineChat,
bindings.CharacterState.Options,
bindings.CharacterState.IsOlthoiPlayer);
if (gate.Status != TurbineChatGateStatus.Allowed)
{
if (TurbineChatMembershipGate.ResolveRefusalText(gate) is
(string refusalText, RetailLogTextType refusalType))
{
bindings.Communication.AddText(refusalText, refusalType);
}
return;
}
uint cookie = 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(
LiveChatCommandBindings 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;
}
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,
logTextType: LegacyChannelChatType.Resolve(
legacy.Value.ChannelId,
ownSend: true));
}
private bool SendIfActive(Action send)
{
lock (_gate)
{
if (_state != 1)
return false;
send();
return true;
}
}
}
/// <summary>
/// Stable host-owned bus over a replaceable generation route. A retained
/// login-command runner never captures an obsolete transport.
/// </summary>
public sealed class LiveChatCommandSurface : ICommandBus
{
private readonly object _gate = new();
private LiveChatCommandRoute? _active;
public ILiveSessionCommandRouting Attach(LiveChatCommandRoute route)
{
ArgumentNullException.ThrowIfNull(route);
lock (_gate)
{
if (_active is not null)
{
throw new InvalidOperationException(
"A live chat command route is already attached.");
}
_active = route;
return new RouteLease(this, route);
}
}
public void Publish<T>(T command) where T : notnull
{
LiveChatCommandRoute? route;
lock (_gate)
route = _active;
route?.Publish(command);
}
private void Release(LiveChatCommandRoute expected)
{
expected.Dispose();
lock (_gate)
{
if (ReferenceEquals(_active, expected))
_active = null;
}
}
private sealed class RouteLease(
LiveChatCommandSurface owner,
LiveChatCommandRoute route)
: ILiveSessionCommandRouting
{
private readonly object _gate = new();
private LiveChatCommandSurface? _owner = owner;
public void Activate() => route.Activate();
public void Dispose()
{
lock (_gate)
{
if (_owner is null)
return;
_owner.Release(route);
_owner = null;
}
}
}
}

View file

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Real <see cref="ICommandBus"/> implementation — single-handler-per-type

View file

@ -0,0 +1,170 @@
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Chat;
public readonly record struct LoginCommandFailure(
int CommandIndex,
string Command,
string Error);
/// <summary>
/// Executes configured login lines through the same parser and command bus as
/// typed chat. A sequence is armed only by an entered-world edge, belongs to
/// one exact Runtime generation, and never lets one command or status-report
/// failure abort the remaining lines or the session.
/// </summary>
public sealed class LoginCommandSequence
{
private readonly string[] _commands;
private readonly TimeSpan _delay;
private readonly TimeProvider _timeProvider;
private readonly IChatCommandFeedback _feedback;
private readonly ICommandBus _bus;
private readonly Action<LoginCommandFailure> _onFailure;
private RuntimeGenerationToken _generation;
private RuntimeGenerationToken? _lastStartedGeneration;
private long _nextDeadline;
private int _nextIndex;
private bool _active;
public LoginCommandSequence(
IEnumerable<string?>? commands,
TimeSpan delay,
IChatCommandFeedback feedback,
ICommandBus bus,
Action<LoginCommandFailure>? onFailure = null,
TimeProvider? timeProvider = null)
{
if (delay < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(delay));
ArgumentNullException.ThrowIfNull(feedback);
ArgumentNullException.ThrowIfNull(bus);
_commands = commands?.Select(static command => command ?? string.Empty)
.ToArray() ?? [];
_delay = delay;
_feedback = feedback;
_bus = bus;
_onFailure = onFailure ?? (static _ => { });
_timeProvider = timeProvider ?? TimeProvider.System;
}
public int CommandCount => _commands.Length;
public bool IsActive => _active;
public int NextCommandIndex => _nextIndex;
/// <summary>
/// Arms this generation once and executes its first due command
/// immediately. Repeated entered-world callbacks in the same generation
/// are ignored; a reconnect generation starts the list again from zero.
/// </summary>
public void EnteredWorld(RuntimeGenerationToken generation)
{
if (_lastStartedGeneration == generation)
return;
_lastStartedGeneration = generation;
_generation = generation;
_nextIndex = 0;
_active = _commands.Length > 0;
_nextDeadline = _timeProvider.GetTimestamp();
DrainDue(generation, isInWorld: true);
}
public void Tick(
RuntimeGenerationToken generation,
bool isInWorld)
{
DrainDue(generation, isInWorld);
}
public void Cancel(RuntimeGenerationToken generation)
{
if (_active && _generation == generation)
_active = false;
}
private void DrainDue(
RuntimeGenerationToken generation,
bool isInWorld)
{
if (!_active || !isInWorld || generation != _generation)
return;
long now = _timeProvider.GetTimestamp();
while (_active
&& generation == _generation
&& _nextIndex < _commands.Length
&& now >= _nextDeadline)
{
int commandIndex = _nextIndex;
string command = _commands[commandIndex];
try
{
SubmitOutcome outcome = ChatCommandRouter.Submit(
command,
_feedback,
_bus,
ChatChannelKind.Say);
if (outcome is SubmitOutcome.UnknownCommand
or SubmitOutcome.Dropped)
{
ReportFailure(new LoginCommandFailure(
commandIndex,
command,
$"Chat command routing returned {outcome}."));
}
}
catch (Exception error)
{
ReportFailure(new LoginCommandFailure(
commandIndex,
command,
error.GetBaseException().Message));
}
// The command handler may have synchronously stopped or replaced
// the session. Cancel() then owns the state; never advance an old
// generation after returning from user-controlled code.
if (!_active || generation != _generation)
return;
_nextIndex++;
if (_nextIndex >= _commands.Length)
{
_active = false;
return;
}
// Inter-command delay starts after the prior handler returns.
// A slow synchronous wire/client handler must not consume the
// configured delay merely by taking time itself.
now = _timeProvider.GetTimestamp();
_nextDeadline = Add(_timeProvider, now, _delay);
}
}
private void ReportFailure(LoginCommandFailure failure)
{
try
{
_onFailure(failure);
}
catch (Exception)
{
// Status/diagnostic reporting observes the sequence. It can never
// poison login command execution or the session transaction.
}
}
private static long Add(
TimeProvider provider,
long timestamp,
TimeSpan duration)
{
double delta = duration.TotalSeconds * provider.TimestampFrequency;
if (delta >= long.MaxValue - timestamp)
return long.MaxValue;
return checked(timestamp + (long)Math.Ceiling(delta));
}
}

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// No-op <see cref="ICommandBus"/>. Accepts any published command and

View file

@ -1,6 +1,6 @@
using System.Collections.Frozen;
namespace AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Retail's <c>ChannelSystem::GetChannelID @ 0x005CF1F0</c> — every legacy

View file

@ -1,6 +1,6 @@
using System.Collections.Frozen;
namespace AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Immutable catalog of commands the named retail client executes locally.

View file

@ -1,7 +1,7 @@
using System.Collections.Frozen;
using AcDream.Core.Chat;
namespace AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Campaign CH slice CH4 (2026-08-09): <c>/help &lt;verb&gt;</c> text for
@ -213,11 +213,12 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// <para>
/// <b>Issue #363 (2026-08-10):</b> <c>ChatCommandRouter</c> now routes this
/// fallback (and every other <c>0x1A</c> command-refusal call site) through
/// <c>ChatVM.ShowInterfaceText</c> — an optional hook the App-layer host
/// <c>IChatCommandFeedback.ShowInterfaceText</c> — an optional hook the host
/// wires to <c>RuntimeCommunicationState.AddText</c>, the same SpewBox
/// chokepoint every other producer of interface text uses. UI.Abstractions
/// still never references Runtime directly (Code Structure Rules); the hook
/// is the seam. Closes ISSUES.md #367 and retires register row AP-186.
/// chokepoint every other producer of interface text uses. The retained
/// <c>ChatVM</c> implements this four-member feedback seam without entering
/// command-routing code. Closes ISSUES.md #367 and retires register row
/// AP-186.
/// </para>
/// </summary>
public static class RetailCommandHelpTable
@ -267,7 +268,7 @@ public static class RetailCommandHelpTable
// DoHelp's fallback when the verb hash lookup fails, or resolves to an
// entry with no registered help callback. Retail types this 0x1A
// (ClientLocal) -- SpewBox-only; see the class remarks' routing note --
// ChatCommandRouter now routes it through ChatVM.ShowInterfaceText
// ChatCommandRouter routes it through IChatCommandFeedback.ShowInterfaceText
// (issue #363), closing #367.
public const string UnknownCommand = "Unknown command";

View file

@ -0,0 +1,32 @@
using AcDream.Core.Chat;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Presentation-free feedback for chat commands executed by a host rather
/// than a panel. It borrows the canonical communication owner; it creates no
/// transcript or reply-target mirror.
/// </summary>
public sealed class RuntimeChatCommandFeedback : IChatCommandFeedback
{
private readonly RuntimeCommunicationState _communication;
public RuntimeChatCommandFeedback(RuntimeCommunicationState communication)
{
_communication = communication
?? throw new ArgumentNullException(nameof(communication));
}
public string? LastIncomingTellSender =>
_communication.CommandTargets.LastIncomingTellSender;
public string? LastOutgoingTellTarget =>
_communication.CommandTargets.LastOutgoingTellTarget;
public void ShowInterfaceText(string text) =>
_communication.AddText(text, RetailLogTextType.ClientLocal);
public void ShowSystemMessage(string text) =>
_communication.Chat.OnSystemMessage(text, chatType: 0x00u);
}

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Command published by chat panels to send a message. The host resolves

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Campaign CH slice CH4 (2026-08-09): broadcast to a legacy <c>ChatChannel
@ -11,7 +11,7 @@ namespace AcDream.UI.Abstractions;
/// (GM/faction channels like <c>@admin</c>, <c>@sentinel</c>,
/// <c>@celestialhand</c>) plus the argument channel-tag resolution
/// <c>@clist</c>/<c>@on</c>/<c>@off</c> use. See
/// <see cref="Panels.Chat.RetailChannelTagTable"/> for the tag→id table.
/// <see cref="RetailChannelTagTable"/> for the tag→id table.
/// </para>
/// </summary>
public sealed record SendRawChannelCmd(uint ChannelId, string Text);

View file

@ -1,4 +1,4 @@
namespace AcDream.UI.Abstractions;
namespace AcDream.Runtime.Chat;
/// <summary>
/// Command text owned by the connected server rather than the retail client.

View file

@ -1,5 +1,6 @@
using System.Runtime.ExceptionServices;
using AcDream.Core.Net;
using AcDream.Runtime.Chat;
namespace AcDream.Runtime.Session;
@ -39,7 +40,8 @@ public sealed record LiveSessionHostBindings(
/// <see cref="EnteredWorld"/>'s narrow <c>SetActiveCharacter(string)</c>
/// fan-out, this exists so a status writer can emit the
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
Action<LiveSessionCharacterSelection> CharacterEntered);
Action<LiveSessionCharacterSelection> CharacterEntered,
LoginCommandSequence? LoginCommands = null);
/// <summary>
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
@ -47,7 +49,9 @@ public sealed record LiveSessionHostBindings(
/// composition, but never mirrors session, generation, identity, routing, or
/// command state.
/// </summary>
public sealed class LiveSessionHost : IRuntimeSessionCommands
public sealed class LiveSessionHost
: IRuntimeSessionCommands,
IRuntimeLiveSessionFramePhase
{
private sealed class PendingRouteRollback(
ILiveSessionCommandRouting? commands,
@ -98,6 +102,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
private readonly Action<RuntimeGenerationToken> _reset;
private readonly LiveSessionLifecycleHost _lifecycle;
private readonly LoginCommandSequence? _loginCommands;
private PendingRouteRollback? _pendingRouteRollback;
public LiveSessionHost(
@ -114,6 +119,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
_characterEntered = bindings.CharacterEntered
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
_loginCommands = bindings.LoginCommands;
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
ArgumentNullException.ThrowIfNull(bindings.Reset);
@ -176,6 +182,17 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
RuntimeGenerationToken expectedGeneration) =>
_controller.Stop(expectedGeneration);
/// <summary>
/// Pumps the canonical network session first, then any due login command.
/// Both graphical and headless frame loops use this host boundary so the
/// sequencing contract cannot drift between them.
/// </summary>
public void Tick()
{
_controller.Tick();
_loginCommands?.Tick(_controller.Generation, _controller.IsInWorld);
}
private LiveSessionBinding BindSession(WorldSession session)
{
DrainPendingRouteRollback();
@ -211,6 +228,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
// state below. Treat physical route convergence as the same hard
// barrier used by normal LiveSessionBinding teardown.
DrainPendingRouteRollback();
_loginCommands?.Cancel(retiringGeneration);
_reset(retiringGeneration);
}
@ -234,6 +252,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
_enteredWorld.LoadCharacterSettings(name);
_enteredWorld.ArmPlayerModeAutoEntry();
_characterEntered(selection);
_loginCommands?.EnteredWorld(_controller.Generation);
}
private void RethrowWithRetryableRollback(

View file

@ -228,6 +228,22 @@ public sealed class SessionStatusWriter
error,
});
public void LoginCommandFailed(
string sessionId,
int commandIndex,
string command,
string error) =>
Write(new
{
v = VocabularyVersion,
e = "loginCommandFailed",
t = Now(),
sessionId,
commandIndex,
command,
error,
});
public void Disconnected(string sessionId, string reason)
{
if (!IsEnabled)

View file

@ -11,5 +11,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Runtime\AcDream.Runtime.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1 @@
global using AcDream.Runtime.Chat;

View file

@ -21,7 +21,7 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// unchanged transcript does not require a queue snapshot each frame.
/// </para>
/// </summary>
public sealed class ChatVM : IDisposable
public sealed class ChatVM : IDisposable, IChatCommandFeedback
{
/// <summary>Default number of tail entries rendered.</summary>
public const int DefaultDisplayLimit = 20;