feat(headless): expose the retail chat-command dispatch seam to a console

HeadlessSessionHost already built ChatCommandRouter.Submit's exact
dependencies (LiveChatCommandSurface over the plugin-verb registry, a
RuntimeChatCommandFeedback) for LoginCommandSequence — that pipeline is
the same one every graphical chat window calls. No lift was needed;
this promotes those two ctor locals to fields and adds
SubmitConsoleLine, the console's one entry point for a typed line.

HeadlessConsoleChatFeedback decorates the real feedback so the
console ALSO sees retail's transient SpewBox/ClientLocal interface
text (bad-args refusals, unknown-command text) — that path never
touches ChatLog, so it never reaches the K2 event-stream renderer
added in the next commit.

Mutation check (reverted before commit): commenting out
_onInterfaceText(text) in HeadlessConsoleChatFeedback.ShowInterfaceText,
and swapping SubmitConsoleLine's ChatChannelKind.Say for .Tell, each
turn a still-to-be-added HeadlessConsoleTests case red.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-07 06:56:13 +02:00
parent 7bff9c649d
commit 51f262b285
2 changed files with 104 additions and 0 deletions

View file

@ -0,0 +1,43 @@
using AcDream.Runtime.Chat;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Decorates the real <see cref="IChatCommandFeedback"/> a session already
/// builds for <c>LoginCommandSequence</c> so the console ALSO sees retail's
/// transient "interface text" — <c>ShowInterfaceText</c>'s SpewBox
/// (<c>ClientLocal</c>) path never touches <c>ChatLog</c> (see
/// <c>RuntimeCommunicationState.AddText</c>), so it never reaches the
/// console's <see cref="HeadlessConsoleRenderer"/> through the normal
/// <see cref="AcDream.Runtime.RuntimeChatDelta"/> event stream. The real
/// side effect (enqueuing into <c>SpewBoxState</c>, exactly what the
/// graphical chat box's own SpewBox overlay would show) still happens via
/// <paramref name="inner"/> — this only ADDS the console line, it never
/// replaces the canonical behavior.
/// </summary>
internal sealed class HeadlessConsoleChatFeedback : IChatCommandFeedback
{
private readonly IChatCommandFeedback _inner;
private readonly Action<string> _onInterfaceText;
internal HeadlessConsoleChatFeedback(
IChatCommandFeedback inner,
Action<string> onInterfaceText)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_onInterfaceText = onInterfaceText
?? throw new ArgumentNullException(nameof(onInterfaceText));
}
public string? LastIncomingTellSender => _inner.LastIncomingTellSender;
public string? LastOutgoingTellTarget => _inner.LastOutgoingTellTarget;
public void ShowInterfaceText(string text)
{
_inner.ShowInterfaceText(text);
_onInterfaceText(text);
}
public void ShowSystemMessage(string text) => _inner.ShowSystemMessage(text);
}

View file

@ -183,6 +183,25 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly IHeadlessBotPolicy _policy;
private readonly IDisposable _policySubscription;
private readonly HeadlessPluginSession _pluginSession;
/// <summary>
/// Headless console (docs/plans/2026-09-07-headless-console.md): the
/// SAME plugin-verb registry <see cref="_chatCommandSurface"/>'s bus
/// forwards to (via <c>TryHandlePluginCommand</c>) and
/// <see cref="HeadlessPluginSession.Create"/> hands to every loaded
/// plugin. Exposed only so a test can register a verb directly without
/// loading a real plugin assembly — production callers reach it
/// exclusively through <see cref="SubmitConsoleLine"/> /
/// <see cref="LoginCommandSequence"/>, never this field.
/// </summary>
private readonly AcDream.Core.Plugins.PluginCommandRegistry _pluginCommands;
/// <summary>
/// Headless console: the SAME retained bus <c>LoginCommandSequence</c>
/// submits through — see <see cref="SubmitConsoleLine"/>. One instance
/// for the host's whole lifetime; <see cref="CreateEventRoute"/>
/// attaches/detaches a fresh <see cref="LiveChatCommandRoute"/> to it on
/// every (re)connect, exactly as it does today for login commands.
/// </summary>
private readonly LiveChatCommandSurface _chatCommandSurface;
private readonly LiveSessionHost _liveSession;
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
@ -453,6 +472,8 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime = runtime;
Commands = commands;
_liveSession = liveSession;
_pluginCommands = pluginCommands;
_chatCommandSurface = chatCommandSurface;
_statusWriter = statusWriter;
_localPlayerFrame =
runtime.CreateLocalPlayerFrameController(
@ -521,7 +542,20 @@ internal sealed class HeadlessSessionHost : IDisposable
/// </summary>
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
internal HeadlessPluginSession Plugins => _pluginSession;
/// <summary>Test seam (mirrors <see cref="OptionsSeeder"/>'s own
/// pattern): registers a plugin verb directly against the SAME registry
/// a real loaded plugin would use, without loading a plugin assembly.
/// </summary>
internal AcDream.Core.Plugins.PluginCommandRegistry PluginCommands =>
_pluginCommands;
internal string SessionId => _descriptor.Id;
/// <summary>
/// Headless console: invoked at the end of every <see cref="Tick"/> so
/// console input drains ON the session tick, in order, never on the
/// reader thread. <see langword="null"/> (every non-console host) costs
/// nothing extra per tick.
/// </summary>
internal Action? ConsolePump { get; set; }
internal string ActiveCharacterName { get; private set; } =
string.Empty;
internal bool IsPolicyComplete =>
@ -563,6 +597,29 @@ internal sealed class HeadlessSessionHost : IDisposable
_pendingConfirmation = null;
}
/// <summary>
/// The headless console's ONE entry point for a typed line — the exact
/// pipeline <see cref="LoginCommandSequence"/> already submits through:
/// <see cref="ChatCommandRouter.Submit"/> against this host's retained
/// <see cref="_chatCommandSurface"/> (plugin verb registry first, then
/// retail client/server slash commands, then plain chat). Console
/// output for retail's transient interface text (bad-args refusals,
/// unknown-command text — never routed through
/// <see cref="AcDream.Runtime.RuntimeChatDelta"/>, see
/// <see cref="HeadlessConsoleChatFeedback"/>'s own doc) is written via
/// <paramref name="onInterfaceText"/>.
/// </summary>
internal SubmitOutcome SubmitConsoleLine(
string line,
Action<string> onInterfaceText) =>
ChatCommandRouter.Submit(
line,
new HeadlessConsoleChatFeedback(
new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
onInterfaceText),
_chatCommandSurface,
ChatChannelKind.Say);
internal RuntimeSessionStartResult Start()
{
// Campaign LA slice LA1: "started" = session host start — the
@ -607,6 +664,10 @@ internal sealed class HeadlessSessionHost : IDisposable
_localPlayerFrame.RunPostNetworkCommandPhase();
Runtime.ActionOwner.CombatAttack.Tick();
_policy.Tick(Runtime, Commands);
// Headless console: drain any input queued by the background reader
// thread since the last tick, in order, on THIS thread — never the
// reader thread (see HeadlessConsoleInputReader's own doc).
ConsolePump?.Invoke();
}
internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped")