diff --git a/docs/launch-options.md b/docs/launch-options.md index 2baa58b4..6faae7e4 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -40,12 +40,16 @@ Assume a flag has a side effect until its row says otherwise. - **Everything diagnostic is OFF by default.** Every probe, dump, capture, and measurement flag in this document is inert until its variable is explicitly set — an unset environment runs zero diagnostics. Exactly - five flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, + six flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and `ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B off-switch (`=0` disables the behavior for a comparison run), while `ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses - the same explicit diagnostic opt-out. That five-flag set is frozen by + the same explicit diagnostic opt-out. `ACDREAM_HEADLESS_CONSOLE` is the + sixth: its unset default is terminal-shaped (on when stdin is a real + console, off when redirected — not unconditionally on like the other + five), but once the variable is SET AT ALL it uses the identical `=0` + override (any other value enables). That six-flag set is frozen by `LaunchOptionsDocumentationTests` — a new default-on flag fails the build. - `=1` means the code tests for exactly the string `1`. Setting `true`, @@ -93,6 +97,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release | `ACDREAM_DAT_DIR` | `=` | Fallback dat-directory when no positional argument is given. App: single read at `Program.cs:58`. Cli: read independently per-subcommand (each subcommand does `args.ElementAtOrDefault(N) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")`) plus once more for the default (no-subcommand) asset-inventory mode at line 152. | Two of the four `Program.cs` line numbers in the raw grep (91, 135) are **not reads** — they're the literal string `ACDREAM_DAT_DIR` inside `Log.Error` usage-text messages, not `GetEnvironmentVariable` calls. Only line 58 is a real read in `AcDream.App`. | none — hard usage error (exit 2) if unset and no positional arg | `Program.cs:58` (App); `Cli/Program.cs:24,35,47,59,71,84,113,125,137,152` (every Cli subcommand) | | `ACDREAM_DISPLAY_PROTOCOL` | `="auto"` / `"x11"` / `"wayland"` (case-insensitive, trimmed); any other value throws `InvalidOperationException` at startup | Linux-only: forces the GLFW 3.4 platform-init hint (X11 vs Wayland vs auto) before any window is created; ignored entirely on Windows (always `Windows` protocol) | An invalid value is fatal at startup (throws before any window exists), not a silent fallback | unset → auto-detected from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`/`DISPLAY`, falling back to GLFW `Automatic` | `GraphicalWindowBackendSelection.Resolve` (`GraphicalWindowBackendSelection.cs:26-58`) | | `ACDREAM_FAR_RADIUS` | `=` | Overrides preset's `FarRadius` (outer streaming/reveal window, landblocks) | Enlarging changes streaming memory budget and what's resident/rendered — CLAUDE.md: leave unset for measurement/gate runs (same family as legacy `ACDREAM_STREAM_RADIUS`) | preset's `FarRadius` (Low=5, Medium=8, High=12, Ultra=15) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:47`) | +| `ACDREAM_HEADLESS_CONSOLE` | `=0` disables (once set at all); any other value enables; unset falls through to the terminal-shaped default | Turns on the headless host's interactive console (docs/plans/2026-09-07-headless-console.md): a background thread reads stdin lines, each drained on the session tick through the SAME plugin-verb/client-slash-command pipeline the graphical chat box uses, with chat/lifecycle/portal output rendered to stdout. Only takes effect for `run` with a single configured session — a multi-session process reports `console: single-session only` via the diagnostics stream and does not attach one. | Starts a background stdin-reader thread and writes plain-text lines to the same stdout stream `HeadlessDiagnosticWriter` already uses for its JSON lines — the two interleave. Only applies to `run`; `--console` (bare flag, no value) always wins over this variable. S1 fix (2026-09-07): the variable itself now wins outright once SET AT ALL — `=0` disables even when stdin is a real terminal, matching every other `=0`-disables flag in this table; only an UNSET variable falls through to the terminal-shaped default. | unset → on when stdin is a real console, off when redirected (`!Console.IsInputRedirected`, checked once in `Program.cs`); set → `!= "0"` | `HeadlessConsoleOptions.Resolve` (`Configuration/HeadlessConsoleOptions.cs`) → `HeadlessEntryPoint.Run` → `HeadlessProcessHost`'s `consoleEnabled` | | `ACDREAM_LIVE` | `=1` (exactly the literal string `"1"`) | Core switch: connect to a live ACE server instead of running offline/no-connect. | The 4 non-`RuntimeOptions.cs` line numbers in the raw grep are **all comments or log-message text**, not reads — `SessionStartComposition.cs:39` is inside the string `"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"`; `Program.cs:126` is inside a `--session-config` override log line; `GameWindow.cs:614,627` are doc comments. The only actual parse is `RuntimeOptions.cs:141`. Requires `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` too (`HasLiveCredentials`) or the session silently reports `MissingCredentials` and skips. Forced to effectively-on (LiveMode=true) unconditionally by `--session-config` launches regardless of this var. | `false` | `RuntimeOptions.LiveMode` → `SessionStartComposition.cs` (log text only), `Program.cs:126` (log text only), `GameWindow.cs:614,627` (comments only), consumed for real via `RuntimeOptions.HasLiveCredentials` and `WorldSession`/`GameRuntime` session-start gating | | `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `=` | Overrides preset's per-frame streaming-completion throughput cap | Directly changes the streaming admission budget measured by perf/completion gates — do not vary during a measurement run | preset's value (Low=2, Medium=3, High=4, Ultra=6) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:59`) | | `ACDREAM_MSAA_SAMPLES` | `=` (0/2/4/8) | Overrides preset's MSAA sample count | Changes GPU multisample anti-aliasing (visual + GPU-cost change) | preset's `MsaaSamples` (Low=0, Medium=2, High/Ultra=4) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:48`) | @@ -143,6 +148,7 @@ config without connecting; `run` connects. | `--config ` | The versioned headless session-configuration document. Required. | — | | `--config-dir` / `--data-dir` / `--cache-dir` `` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. | | `-user` / `--user`, `-password` / `--password` | Direct single-session credentials, bypassing the config's credential source. | Plaintext in the process command line — prefer the config's credential reference. | +| `--console` | Forces the interactive console on for `run` (bare flag, no value) — see `ACDREAM_HEADLESS_CONSOLE`. | Same side effects as the environment variable; this flag always wins over it. | | `--help` / `-h` (or no args) | Prints usage, exits 0. | — | ### `AcDream.Launcher` diff --git a/docs/plans/2026-09-07-headless-console.md b/docs/plans/2026-09-07-headless-console.md index 04aece66..c15fc2a5 100644 --- a/docs/plans/2026-09-07-headless-console.md +++ b/docs/plans/2026-09-07-headless-console.md @@ -66,3 +66,158 @@ it or the lead may, it is not a visual gate. ## Ledger - 2026-09-07 planned; implementer dispatched. +- 2026-09-07 IMPLEMENTED. The dispatch seam already existed: + `AcDream.Runtime.Chat.ChatCommandRouter.Submit` is the SAME presentation- + free pipeline `LoginCommandSequence` (headless) and every graphical chat + window (`ChatWindowController`, `FloatingChatWindowController`, + `RetailUiRuntime`) already call — no lift was needed. Added + `HeadlessSessionHost.SubmitConsoleLine` (`Hosting/HeadlessSessionHost.cs`) + as the one new call site, reusing the host's own retained + `LiveChatCommandSurface`/plugin registry (now promoted from ctor locals to + fields) instead of a second parser. + New files: `Configuration/HeadlessConsoleOptions.cs` (typed `--console` / + `ACDREAM_HEADLESS_CONSOLE=1` / terminal-default resolution), + `Hosting/HeadlessConsoleInputReader.cs` (background stdin thread → FIFO + queue, never executes handler code), `Hosting/HeadlessConsoleController.cs` + (drains the queue on the session tick via a new `HeadlessSessionHost. + ConsolePump` hook; owns `/quit`/`/status`), `Hosting/ + HeadlessConsoleChatFormatter.cs` + `Hosting/HeadlessConsoleRenderer.cs` + (renders the K2 bot event stream — `IRuntimeEventObserver`, the same + interface a bot policy subscribes — as bracket-labelled lines: + `[Tell] Bob: hi`, `[Fellowship] …`, `[Local] …`), `Hosting/ + HeadlessConsoleChatFeedback.cs` (decorates `RuntimeChatCommandFeedback` so + retail's transient SpewBox/`ClientLocal` interface text — which never + touches `ChatLog`, so it never reaches the K2 event stream — also reaches + the console). `/quit` cancels a `CancellationTokenSource` linked into the + scheduler's run token in `HeadlessProcessHost` (the SAME graceful-exit + path an external Ctrl+C/SIGTERM already takes); `/status` reports + generation, position (or "unknown" without a live movement controller), + and loaded-plugin count (no plugin today reports a richer macro-state + string). Console only attaches for a single-session `run` (per the plan's + "out of scope for the first cut" multi-session note); constructed AFTER + every session's own credential resolution so the reader thread never + races a `StandardInput`-provider password prompt on the same stream. + Chosen console default: on when `!Console.IsInputRedirected` (a real + operator at a terminal), off when redirected (scripts/CI/piped fixtures, + where a blocked `ReadLine` on a background thread would just sit idle) — + resolved once in `Program.cs`, the only place that can see the real + `Console`. + Deviation from the plan's illustrative example: retail's own transcript + never prefixes Tell/Local lines with a bracket (`ChatVM.FormatEntry` + renders "Bob tells you, ..."/"Bob says, ..." with no label) — Headless + cannot reference `AcDream.UI.Abstractions` (the dependency-boundary + test), so `HeadlessConsoleChatFormatter` is a deliberately DIFFERENT, + terminal-shaped "[Label] Sender: text" rendering using the SAME channel- + name strings (matching the plan's literal `[Tell] Bob: hi` example), not + a byte-for-byte port of the graphical prose. + Tests: `tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs` (20 new + tests — options resolution, command-line flag parsing, reader-thread + ordering/never-on-reader-thread, controller drain/quit/status, chat + formatting, and full `/say`/plain-text/plugin-verb/unknown-verb dispatch + against a real `HeadlessSessionHost` + `FixtureSessionOperations`, no live + server) plus the existing `LaunchOptionsDocumentationTests` (4/4 green) + and `HeadlessDependencyBoundaryTests` (3/3 green, unchanged — Headless + still references only `AcDream.Runtime`). Every test in this batch was + mutation-checked to fail before the corresponding production line existed + (see the implementer's final report for the specific mutations run: + skipping the interface-text callback, skipping `_quitRequested.Cancel()`, + forcing `TryHandlePluginCommand` to always return false, dropping the + reader thread's `Enqueue`, and swapping `ChatChannelKind.Say` for `.Tell` + in `SubmitConsoleLine`). + Suites: `dotnet test tests/AcDream.Headless.Tests -c Release` → 193 + passed / 1 pre-existing failure (`LinuxRejectsGroupOrOtherCredentialPermissions`, + a Linux-only lane test that cannot run on this Windows host — unrelated + to this change) / 194 total. `dotnet test tests/AcDream.Runtime.Tests -c + Release` → 1891/1891 passed. `dotnet test tests/AcDream.App.Tests -c + Release --filter "FullyQualifiedName~Chat|FullyQualifiedName~Command| + FullyQualifiedName~LaunchOptions"` → 414 passed / 2 pre-existing failures + (`ChatIndicatorButtonLiveMountProbeTests`/`OptionsPanelLiveMountProbeTests` + — both gated on `ACDREAM_PROBE_LIVE_MOUNT=1`, a manual live-DAT probe lane, + unrelated to this change) / 3 skipped / 419 total. `dotnet build + AcDream.slnx -c Release` green throughout. + +- 2026-09-07 FIX ROUND (Opus review, APPROVE-WITH-FIXES). S1: `ACDREAM_ + HEADLESS_CONSOLE=0` now disables the console even when stdin is a real + terminal — the prior `== "1"` test let `"0"` silently fall through to the + terminal-shaped default; the flag is now the sixth entry in + `LaunchOptionsDocumentationTests.DefaultOnBehaviorFlags` (a default-on + behavior with an A/B off-switch, once set at all, like + `ACDREAM_RETAIL_CHASE`). S2: the reader-thread pin is now falsifiable — a + fixture `TextReader` records the actual thread id `ReadLine` ran on, and a + new test asserts the controller's submit callback runs on neither that + thread nor any other unexpected one, only the `DrainDue` caller's. S3: one + `HeadlessProcessHost` end-to-end test proves a console line reaches the + session's real `SubmitConsoleLine` pipeline and `/quit` returns + `HeadlessExitCode.Success`. S4: `HeadlessConsoleController.Handle` now + wraps `_submit` in try/catch (mirroring `LoginCommandSequence.DrainDue`) + and prints a line for `UnknownCommand`/`Dropped`, so a console typo can + never escape into the scheduler's per-session quarantine catch. S5: + deleted the per-call `HeadlessConsoleChatFeedback` decorator — it only + ever saw text produced by the console's OWN `SubmitConsoleLine` calls. + The new `HeadlessConsoleSpewBoxPump` polls the shared `SpewBoxState` on + the console's own per-tick pump instead, the SAME seam the graphical + overlay's `SpewBoxController.Tick` reads, so server- and plugin-driven + `ClientLocal` interface text prints too. S6: `Program.cs` now resolves + `standardOutputIsTerminal` next to the stdin probe and threads it through + `HeadlessEntryPoint.Run` → `HeadlessProcessHost`, which no longer reads + `System.Console.IsOutputRedirected` itself. S7: a multi-session process + launched with `--console` now reports `_diagnostics.Message("console", + "single-session only")` instead of silently skipping console attachment. + N1: corrected two stale dispatch-order doc comments + (`HeadlessSessionHost.SubmitConsoleLine`, `HeadlessConsoleController`'s + class remarks) to the real `ChatCommandRouter.Submit` order: retail's + client-command catalog, local `/help`, plugin verbs, the unregistered- + channel-tag fallback, an explicit server command, then plain chat. N2: + `HeadlessCommandLine.Console` renamed to `ConsoleEnabled`. N3: `validate` + mode now rejects `--console` outright rather than silently ignoring it. + N4: **`/status` and `/quit` are console-intercepted verbs — they never + reach `ChatCommandRouter`, unlike `@status`, which is a real server + command and still passes through untouched.** N5: `HeadlessConsoleRenderer` + now dims only lifecycle/command/portal lines; chat and interface text + print at the terminal's default weight. + Every new/changed test was shown to fail first against a targeted + mutation of the corresponding production code (see each commit's own + body for the specific mutation) before the fix landed; one commit per + item, all with `Co-Authored-By: Claude Fable 5.1`. + Suites (Release): `dotnet test tests/AcDream.Headless.Tests` → 207 + passed / 1 pre-existing Linux-lane failure + (`LinuxRejectsGroupOrOtherCredentialPermissions`) / 208 total (up from + 193/1/194 before this round — 14 new/changed tests). `dotnet test + tests/AcDream.App.Tests --filter "FullyQualifiedName~LaunchOptions"` → + 4/4 passed, including the corrected `OnlyTheSixProductBehaviorFlagsDefaultOn` + (renamed from Five). `dotnet build AcDream.slnx -c Release` green + throughout. + +### Connected proof recipe (owner runs; NOT run by the implementer) + +Against a running local ACE at `127.0.0.1:9000` with MossTank loaded for +the second half: + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +dotnet run --project src\AcDream.Headless\AcDream.Headless.csproj --no-build -c Release -- ` + run --config ` + -user testaccount -password testpassword --console +``` + +The referenced config's one session should target character `+Acdream` +(server guid `0x5000000A`) against `127.0.0.1:9000`, an `idle` bot policy, +and (for the second half) the MossTank plugin id under `plugins`. Once the +console prints `entered world`: + +1. Type `/say hello` and press Enter — expect the SAME line ACE echoes back + to any other observer (a retail client or a second acdream session + watching `+Acdream`) to also print `[Local] You: hello` in this console + (the server's own HearSpeech echo, rendered through the normal chat + event stream). +2. Type `/status` — expect a line with `generation=`, `position=` (a real + cell/local-frame triple once in world), and `plugins=N loaded`. +3. With MossTank loaded, type `/vt start` (or whatever verb MossTank + registers) — expect MossTank's own handler to run (check its own + status/log output) and confirm NOTHING was sent to the wire for that + line (no `@vt` server command). +4. Type `/quit` — expect a graceful ACE logout (same as the existing + Ctrl+C behavior) and the process to exit 0. + +This is not a visual gate; the owner (or the lead) runs it opportunistically +before considering the plan CLOSED. diff --git a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs index 38009512..73de5088 100644 --- a/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs +++ b/src/AcDream.Headless/Configuration/HeadlessCommandLine.cs @@ -4,7 +4,8 @@ internal sealed record HeadlessCommandLine( string Command, string ConfigurationPath, HeadlessPathOverrides Paths, - HeadlessDirectCredentials? DirectCredentials) + HeadlessDirectCredentials? DirectCredentials, + bool ConsoleEnabled = false) { internal static HeadlessCommandLine Parse( IReadOnlyList arguments) @@ -23,15 +24,26 @@ internal sealed record HeadlessCommandLine( string? cacheDirectory = null; string? user = null; string? password = null; - for (int index = 1; index < arguments.Count; index += 2) + bool console = false; + int index = 1; + while (index < arguments.Count) { + string name = arguments[index]; + // --console is a bare flag (no value token) — the interactive + // console for the run command (see HeadlessConsoleOptions). + if (name == "--console") + { + console = true; + index += 1; + continue; + } + if (index + 1 >= arguments.Count) { throw new HeadlessCommandLineException( "Every command option requires a value."); } - string name = arguments[index]; string value = arguments[index + 1]; if (string.IsNullOrWhiteSpace(value)) { @@ -65,6 +77,7 @@ internal sealed record HeadlessCommandLine( throw new HeadlessCommandLineException( "Unknown command option."); } + index += 2; } if (configurationPath is null) @@ -82,6 +95,14 @@ internal sealed record HeadlessCommandLine( throw new HeadlessCommandLineException( "Direct credentials are valid only for run mode."); } + // N3: reject rather than silently ignore --console for validate mode + // — validate never starts a session, so there is nothing for the + // console to attach to. + if (console && command != "run") + { + throw new HeadlessCommandLineException( + "--console is valid only for run mode."); + } return new HeadlessCommandLine( command, @@ -92,7 +113,8 @@ internal sealed record HeadlessCommandLine( cacheDirectory), user is null ? null - : new HeadlessDirectCredentials(user, password!)); + : new HeadlessDirectCredentials(user, password!), + console); } private static void SetOnce(ref string? destination, string value) diff --git a/src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs b/src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs new file mode 100644 index 00000000..88239404 --- /dev/null +++ b/src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs @@ -0,0 +1,53 @@ +namespace AcDream.Headless.Configuration; + +/// +/// Typed resolution for the headless interactive console (docs/plans/ +/// 2026-09-07-headless-console.md). Three inputs, first match wins: +/// the --console command-line flag, the +/// ACDREAM_HEADLESS_CONSOLE environment variable, and finally a +/// terminal-shaped default — on when stdin is a real console (an operator +/// typing at a keyboard), off when it is redirected (a script, CI runner, or +/// piped fixture, where a background reader thread blocked on +/// ReadLine would never see input and would just sit idle). See +/// docs/launch-options.md for the documented row this owns. +/// +/// +/// S1 fix (2026-09-07 review round): the environment variable is a +/// default-on override once it is SET at all, not a bare "equals 1" test — +/// ACDREAM_HEADLESS_CONSOLE=0 must disable the console even when +/// stdin is a real terminal, matching the +/// ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI +/// convention (any value other than the literal string "0" enables). +/// An UNSET variable still falls through to the terminal-shaped default — +/// this flag's "default on" is conditional on stdin, unlike those two, but +/// once set at all it behaves identically. +/// +internal static class HeadlessConsoleOptions +{ + internal const string EnvironmentVariable = "ACDREAM_HEADLESS_CONSOLE"; + + internal static bool Resolve( + bool commandLineFlag, + bool standardInputIsTerminal) => + Resolve( + commandLineFlag, + Environment.GetEnvironmentVariable, + standardInputIsTerminal); + + internal static bool Resolve( + bool commandLineFlag, + Func env, + bool standardInputIsTerminal) + { + ArgumentNullException.ThrowIfNull(env); + if (commandLineFlag) + return true; + if (env(EnvironmentVariable) is null) + return standardInputIsTerminal; + // Default-on once the flag is set at all: any value other than the + // literal string "0" enables the console — the same + // ACDREAM_RETAIL_CLOSE_DEGRADES / ACDREAM_RETAIL_UI idiom. + return !string.Equals( + env("ACDREAM_HEADLESS_CONSOLE"), "0", StringComparison.Ordinal); + } +} diff --git a/src/AcDream.Headless/HeadlessEntryPoint.cs b/src/AcDream.Headless/HeadlessEntryPoint.cs index e5576e54..4bd8406b 100644 --- a/src/AcDream.Headless/HeadlessEntryPoint.cs +++ b/src/AcDream.Headless/HeadlessEntryPoint.cs @@ -46,7 +46,9 @@ internal static class HeadlessEntryPoint TextReader standardInput, TextWriter output, TextWriter error, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool standardInputIsTerminal = false, + bool standardOutputIsTerminal = false) { ArgumentNullException.ThrowIfNull(arguments); ArgumentNullException.ThrowIfNull(standardInput); @@ -74,13 +76,18 @@ internal static class HeadlessEntryPoint configuredPaths.Merge(commandLine.Paths)); if (commandLine.Command == "run") { + bool consoleEnabled = HeadlessConsoleOptions.Resolve( + commandLine.ConsoleEnabled, + standardInputIsTerminal); using var host = new HeadlessProcessHost( configuration, paths, standardInput, output, directCredentials: - commandLine.DirectCredentials); + commandLine.DirectCredentials, + consoleEnabled: consoleEnabled, + standardOutputIsTerminal: standardOutputIsTerminal); return (int)host.RunAsync(cancellationToken) .GetAwaiter() .GetResult(); diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs new file mode 100644 index 00000000..de8fdbfa --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs @@ -0,0 +1,58 @@ +using AcDream.Core.Chat; +using AcDream.Runtime; + +namespace AcDream.Headless.Hosting; + +/// +/// Presentation for the console's rendered chat lines. A distinct, terminal- +/// shaped format from the graphical ChatVM.FormatEntry retail prose +/// (Headless cannot reference AcDream.UI.Abstractions — see the +/// dependency-boundary test — and a script piping console output wants a +/// stable, greppable "[Label] Sender: text" shape more than retail's exact +/// sentence). It uses the SAME channel-name strings the graphical SpewBox +/// shows (, "Tell", "Local") per +/// the plan's requirement, just not the same sentence template. +/// +internal static class HeadlessConsoleChatFormatter +{ + /// Formats one chat event for the console, or + /// when this kind renders nothing (there are + /// none today — kept for forward compatibility with a future silent + /// kind). + internal static string? Format(in RuntimeChatEntry entry) + { + var kind = (ChatKind)entry.Kind; + return kind switch + { + ChatKind.LocalSpeech or ChatKind.RangedSpeech => + $"[Local] {SpeakerLabel(entry.Sender)}: {entry.Text}", + ChatKind.Channel => + $"[{ChannelLabel(entry)}] {SpeakerLabel(entry.Sender)}: {entry.Text}", + ChatKind.Tell => FormatTell(entry), + ChatKind.Emote or ChatKind.SoulEmote => + $"* {entry.Sender} {entry.Text}", + ChatKind.Popup => $"[Popup] {entry.Text}", + // System/Combat lines arrive pre-formatted (system messages, + // combat translator output) — render bare, matching retail's own + // no-prefix system-chat convention (Campaign CH user-gate round + // 1, item B). + _ => entry.Text, + }; + } + + private static string FormatTell(in RuntimeChatEntry entry) => + // SenderGuid != 0 is an incoming whisper (see ChatLog.OnTellReceived); + // == 0 is our own outbound echo, where Sender carries the target + // name (ChatLog.OnSelfSent). Both directions get the "[Tell]" label + // the plan asks for; the "You -> " marker is what disambiguates an + // outgoing tell from an incoming one in the bracket-label shape. + entry.SenderGuid != 0 + ? $"[Tell] {entry.Sender}: {entry.Text}" + : $"[Tell] You -> {entry.Sender}: {entry.Text}"; + + private static string SpeakerLabel(string sender) => + string.IsNullOrEmpty(sender) || sender == "You" ? "You" : sender; + + private static string ChannelLabel(in RuntimeChatEntry entry) => + string.IsNullOrEmpty(entry.ChannelName) ? "Channel" : entry.ChannelName; +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs new file mode 100644 index 00000000..51e66bbe --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs @@ -0,0 +1,116 @@ +using AcDream.Runtime.Chat; + +namespace AcDream.Headless.Hosting; + +/// +/// The console's own orchestration: owns the background reader +/// () and, once per session tick +/// (), drains every line queued since the last call +/// and dispatches each one IN ORDER, on the calling thread — never the +/// reader thread (Slice K's monotonic scheduler contract; see +/// 's own doc). +/// +/// +/// /quit and /status are console-only controls (the plan's +/// "Control" section) — they never reach , +/// matching retail's own client-local commands. Every other line goes +/// through , which a production caller binds to +/// HeadlessSessionHost.SubmitConsoleLine — the exact +/// pipeline (retail's client-command +/// catalog first, then local /help, then the plugin-verb registry, +/// then the retail unregistered-channel-tag fallback, then an explicit +/// server command, then plain chat) LoginCommandSequence and the +/// graphical chat box both already use. +/// +internal sealed class HeadlessConsoleController : IDisposable +{ + private readonly HeadlessConsoleInputReader _reader; + private readonly TextWriter _output; + private readonly Func _submit; + private readonly Func _statusText; + private readonly CancellationTokenSource _quitRequested; + + internal HeadlessConsoleController( + TextReader input, + TextWriter output, + Func submit, + Func statusText, + CancellationTokenSource quitRequested) + { + ArgumentNullException.ThrowIfNull(input); + _output = output ?? throw new ArgumentNullException(nameof(output)); + _submit = submit ?? throw new ArgumentNullException(nameof(submit)); + _statusText = statusText ?? throw new ArgumentNullException(nameof(statusText)); + _quitRequested = quitRequested + ?? throw new ArgumentNullException(nameof(quitRequested)); + _reader = new HeadlessConsoleInputReader(input); + } + + /// Number of lines handled by the most recent + /// call — a test seam for the reader-thread + /// ordering assertion. + internal int LastDrainCount { get; private set; } + + /// Test seam: lets a bounded-fixture test wait for the + /// background reader thread to reach EOF before calling + /// , instead of sleeping or polling. + internal HeadlessConsoleInputReader Reader => _reader; + + internal void DrainDue() + { + int count = 0; + while (_reader.TryDequeue(out string line)) + { + Handle(line); + count++; + } + LastDrainCount = count; + } + + private void Handle(string rawLine) + { + string trimmed = rawLine.Trim(); + if (trimmed.Length == 0) + return; + + if (trimmed.Equals("/quit", StringComparison.OrdinalIgnoreCase)) + { + WriteLine("quitting (graceful logout)"); + _quitRequested.Cancel(); + return; + } + + if (trimmed.Equals("/status", StringComparison.OrdinalIgnoreCase)) + { + WriteLine(_statusText()); + return; + } + + // S4 (2026-09-07 review round): mirrors + // LoginCommandSequence.DrainDue's own try/catch and + // UnknownCommand/Dropped reporting — a console typo (a bad line, a + // downstream bug in a plugin verb handler) must never escape to the + // scheduler's per-session quarantine catch and fault the whole + // session, and the operator deserves the same "this line did + // nothing" signal LoginCommandSequence already gives a login-line + // failure. + try + { + SubmitOutcome outcome = _submit(rawLine); + if (outcome is SubmitOutcome.UnknownCommand or SubmitOutcome.Dropped) + WriteLine($"not handled ({outcome}): {rawLine}"); + } + catch (Exception error) + { + WriteLine($"command failed: {error.GetBaseException().Message}"); + } + } + + private void WriteLine(string text) + { + _output.WriteLine(text); + _output.Flush(); + } + + public void Dispose() => _reader.Dispose(); +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs new file mode 100644 index 00000000..ea159b24 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs @@ -0,0 +1,94 @@ +using System.Collections.Concurrent; + +namespace AcDream.Headless.Hosting; + +/// +/// Reads lines from a on one dedicated background +/// thread and hands them to whoever drains . Slice K's +/// scheduler contract binds every mutating call to one thread for a session's +/// whole lifetime (#368 — collision generations refuse migration), so console +/// input can never be executed from this thread: it only ever enqueues, and +/// the session tick is the sole reader of . +/// +/// +/// has no cancellable overload, so a real +/// Console.In reader can be blocked on it when the process wants to +/// exit. The thread is a background thread (does not keep the process alive) +/// and only requests the loop stop at its next +/// opportunity — it does not abort a pending read. A closed/EOF input (a +/// piped fixture reaching its last line, or the real console's stdin handle +/// closing) ends the loop on its own; lets a test +/// wait for that deterministically instead of polling or sleeping. +/// +internal sealed class HeadlessConsoleInputReader : IDisposable +{ + private readonly TextReader _input; + private readonly ConcurrentQueue _queue = new(); + private readonly Thread _thread; + private volatile bool _stopRequested; + + internal HeadlessConsoleInputReader(TextReader input) + { + _input = input ?? throw new ArgumentNullException(nameof(input)); + _thread = new Thread(ReadLoop) + { + IsBackground = true, + Name = "acdream-headless-console-reader", + }; + _thread.Start(); + } + + /// Set once the reader loop has returned (EOF or stop request). + /// Tests wait on this instead of sleeping/polling for a deterministic + /// "every line the fixture will ever produce has been enqueued" signal. + /// + internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false); + + /// Dequeues the next queued line in FIFO order, or returns + /// if none is queued yet. Never blocks. + internal bool TryDequeue(out string line) => _queue.TryDequeue(out line!); + + private void ReadLoop() + { + try + { + while (!_stopRequested) + { + string? line = _input.ReadLine(); + if (line is null) + return; + _queue.Enqueue(line); + } + } + catch (ObjectDisposedException) + { + // The input was disposed out from under a pending read (process + // teardown racing the reader thread) — end the loop quietly, + // same as EOF. + } + catch (IOException) + { + // A redirected stream can fail mid-read (e.g. a broken pipe). + // Treat it the same as EOF rather than crashing the process. + } + finally + { + EndOfInput.Set(); + } + } + + /// Requests the read loop stop at its next opportunity. Does + /// not abort a already in progress — + /// the thread is background, so it cannot block process exit. + /// Deliberately does NOT dispose : the read + /// loop's own finally sets it from the reader thread, and racing + /// that against a Dispose() here (an unhandled + /// on a background thread + /// terminates the process) is worse than leaking one small + /// synchronization handle for the process's remaining lifetime. + /// + public void Dispose() + { + _stopRequested = true; + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs new file mode 100644 index 00000000..3e60e3a6 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs @@ -0,0 +1,111 @@ +using AcDream.Runtime; + +namespace AcDream.Headless.Hosting; + +/// +/// One presentation over the K2 bot event stream +/// () — the SAME typed events a headless +/// bot policy observes (HeadlessBotPolicy.cs) — rendered as plain +/// lines. Every write goes through , so a test can +/// assert on exactly what a real console would have printed without a +/// terminal. +/// +internal sealed class HeadlessConsoleRenderer : IRuntimeEventObserver +{ + private const string Reset = ""; + private const string Dim = ""; + + private readonly TextWriter _output; + private readonly bool _useColor; + + internal HeadlessConsoleRenderer(TextWriter output, bool useColor) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _useColor = useColor; + } + + public void OnChat(in RuntimeChatDelta delta) + { + string? line = HeadlessConsoleChatFormatter.Format(delta.Entry); + if (!string.IsNullOrEmpty(line)) + WriteLine(line, dim: false); + } + + /// + /// Retail's transient "interface text" (SpewBox, ClientLocal + /// type) never touches — + /// see RuntimeCommunicationState.AddText — so it never reaches + /// . HeadlessConsoleSpewBoxPump calls this + /// directly, once per console tick, for whatever text is newly visible + /// in the polled — the + /// SAME seam the graphical overlay's own SpewBox controller reads, so + /// server- and plugin-driven interface text prints here too, not only + /// the console's own submissions. Default weight (N5) — this is + /// player-visible interface text, not scheduling noise. + /// + internal void WriteInterfaceText(string text) => WriteLine(text, dim: false); + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + switch (delta.Current) + { + case RuntimeLifecycleState.InWorld: + WriteLine("entered world", dim: true); + break; + case RuntimeLifecycleState.Stopping: + WriteLine("disconnecting", dim: true); + break; + case RuntimeLifecycleState.Faulted: + WriteLine("session faulted", dim: true); + break; + } + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + if (delta.Status == RuntimeCommandStatus.Rejected) + { + WriteLine( + $"command rejected: {delta.Domain} {delta.Text}".TrimEnd(), + dim: true); + } + } + + public void OnPortal(in RuntimePortalDelta delta) + { + if (delta.Portal.IsMaterialized) + { + WriteLine( + $"portal -> cell 0x{delta.Portal.DestinationCell:X8}", + dim: true); + } + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + /// + /// N5 (2026-09-07 review round): only lifecycle/command/portal lines are + /// dimmed — scheduling and session-status noise, not player-visible + /// content. Chat and interface text print at the terminal's default + /// weight. + /// + private void WriteLine(string text, bool dim) + { + _output.WriteLine(_useColor && dim ? Dim + text + Reset : text); + _output.Flush(); + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs new file mode 100644 index 00000000..a901c89d --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs @@ -0,0 +1,63 @@ +using AcDream.Core.Chat; + +namespace AcDream.Headless.Hosting; + +/// +/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md): +/// polls on the console's own per-tick pump — the +/// SAME seam AcDream.App.UI.SpewBoxController.Tick drives for the +/// graphical overlay. Retail's transient "interface text" +/// (, routed by +/// RuntimeCommunicationState.AddText) never touches +/// RuntimeCommunicationState.Chat/RuntimeChatDelta, so it is +/// otherwise invisible to a console that only observes the chat event +/// stream — this is true for EVERY producer of that text (a bad-args +/// refusal from the console's own submit, but also a server-driven refusal +/// or a plugin's own interface-text write), not just the console's own +/// submissions. This replaces the earlier per-call +/// HeadlessConsoleChatFeedback decorator, which only ever saw text +/// produced by the console's own SubmitConsoleLine calls. +/// +internal sealed class HeadlessConsoleSpewBoxPump +{ + private readonly SpewBoxState _spewBox; + private readonly Func _nowSeconds; + private readonly Action _writeInterfaceText; + private SpewBoxEntry[] _lastSeen = []; + + internal HeadlessConsoleSpewBoxPump( + SpewBoxState spewBox, + Func nowSeconds, + Action writeInterfaceText) + { + _spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox)); + _nowSeconds = nowSeconds + ?? throw new ArgumentNullException(nameof(nowSeconds)); + _writeInterfaceText = writeInterfaceText + ?? throw new ArgumentNullException(nameof(writeInterfaceText)); + } + + /// + /// Drains any pending SpewBox text into the visible set (exactly + /// 's contract — the same drain + /// SpewBoxVM.Lines performs for the graphical overlay) and prints + /// any entry that was not part of the previous call's visible snapshot. + /// + /// + /// is newest-first + /// (retail's InsertItem(item, 0)); this walks it back-to-front so + /// newly-visible entries print in the order they were actually + /// enqueued, not newest-first. + /// + internal void Pump() + { + _spewBox.Tick(_nowSeconds()); + SpewBoxEntry[] current = _spewBox.Snapshot(); + for (int i = current.Length - 1; i >= 0; i--) + { + if (Array.IndexOf(_lastSeen, current[i]) < 0) + _writeInterfaceText(current[i].Text); + } + _lastSeen = current; + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 3a73be8b..323d8015 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable private readonly HeadlessDiagnosticWriter _diagnostics; private readonly HeadlessProcessContentOwner? _content; private readonly HeadlessProcessResourceSampler _resources; + /// + /// Headless console (docs/plans/2026-09-07-headless-console.md): always + /// created, cancelled only by /quit — linking it into the + /// scheduler's run token below costs nothing when the console is + /// disabled (it simply never fires) and keeps + /// free of a console-shaped branch. + /// + private readonly CancellationTokenSource _consoleQuitRequested = new(); + private readonly HeadlessConsoleController? _console; + private readonly IDisposable? _consoleRendererSubscription; private int _disposeIndex; private bool _disposed; @@ -26,7 +36,9 @@ internal sealed class HeadlessProcessHost : IDisposable ILiveSessionOperations? sessionOperations = null, TimeProvider? timeProvider = null, IHeadlessProcessContentFactory? contentFactory = null, - HeadlessDirectCredentials? directCredentials = null) + HeadlessDirectCredentials? directCredentials = null, + bool consoleEnabled = false, + bool standardOutputIsTerminal = false) { ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(paths); @@ -65,6 +77,8 @@ internal sealed class HeadlessProcessHost : IDisposable paths.VtankProfilesDirectory); HeadlessProcessContentOwner? content = null; HeadlessProcessResourceSampler? resources = null; + HeadlessConsoleController? console = null; + IDisposable? consoleRendererSubscription = null; // FA6: constructed unconditionally — cheap, and every non-gate // session simply never reads or writes it (see the coordinator's // own class doc). @@ -137,9 +151,62 @@ internal sealed class HeadlessProcessHost : IDisposable _resources = resources; _content = content; _disposeIndex = _sessions.Length - 1; + + // Headless console (docs/plans/2026-09-07-headless-console.md): + // "Multi-session. Out of scope for the first cut" — attach only + // to a single-session process. Constructed AFTER every + // session's credential resolution above (which may itself read + // a line from standardInput for a StandardInput-provider + // credential) so the console's own reader thread never races a + // password prompt for the same stream. + if (consoleEnabled && _sessions.Length == 1) + { + HeadlessSessionHost session = _sessions[0]; + var renderer = new HeadlessConsoleRenderer( + diagnostics, + useColor: standardOutputIsTerminal); + consoleRendererSubscription = + session.Runtime.Subscribe(renderer); + HeadlessConsoleController controller = new( + standardInput, + diagnostics, + session.SubmitConsoleLine, + () => BuildStatusText(session), + _consoleQuitRequested); + // S5 (2026-09-07 review round): poll the SAME SpewBoxState + // seam the graphical overlay's SpewBoxController.Tick reads + // (RuntimeCommunicationState.AddText's ClientLocal branch — + // it never touches Chat/RuntimeChatDelta) so server- and + // plugin-driven interface text prints too, not only the + // console's own submissions. Replaces the earlier per-call + // HeadlessConsoleChatFeedback decorator, which only saw text + // produced by THIS console's own SubmitConsoleLine calls. + var spewPump = new HeadlessConsoleSpewBoxPump( + session.Runtime.CommunicationOwner.SpewBox, + () => session.Runtime.Clock.SimulationTimeSeconds, + renderer.WriteInterfaceText); + session.ConsolePump = () => + { + controller.DrainDue(); + spewPump.Pump(); + }; + console = controller; + } + else if (consoleEnabled) + { + // S7 (2026-09-07 review round): a silent skip here read as + // "--console worked" to an operator with no way to tell + // otherwise — the launcher's multi-session mode is a + // legitimate, common configuration, so say so explicitly. + _diagnostics.Message("console", "single-session only"); + } + _console = console; + _consoleRendererSubscription = consoleRendererSubscription; } catch { + console?.Dispose(); + consoleRendererSubscription?.Dispose(); resources?.Dispose(); for (int index = sessions.Count - 1; index >= 0; index--) sessions[index].Dispose(); @@ -148,6 +215,29 @@ internal sealed class HeadlessProcessHost : IDisposable } } + /// + /// /status: generation, position (or "unknown" without a live + /// movement controller — a content-less host, or before the first + /// accepted placement), and the plugin-visible macro state this host + /// can actually observe today (loaded-plugin count — no plugin + /// currently reports a richer status string; see the plan's "if the + /// plugin reports one"). + /// + private static string BuildStatusText(HeadlessSessionHost session) + { + RuntimeMovementSnapshot movement = + session.Runtime.MovementOwner.Snapshot; + string position = movement.HasController + ? $"cell=0x{movement.Position.ObjCellId:X8} " + + $"local=({movement.Position.Frame.Origin.X:F2}," + + $"{movement.Position.Frame.Origin.Y:F2}," + + $"{movement.Position.Frame.Origin.Z:F2})" + : "unknown"; + return $"generation={session.Runtime.Generation.Value} " + + $"position={position} " + + $"plugins={session.Plugins.LoadedCount} loaded"; + } + internal HeadlessSessionHost Session => _sessions.Length == 1 ? _sessions[0] : throw new InvalidOperationException( @@ -249,12 +339,21 @@ internal sealed class HeadlessProcessHost : IDisposable _scheduler.CaptureSnapshot(), _content); + // Headless console: /quit cancels _consoleQuitRequested, which this + // linked token propagates into the scheduler's own wait loop — + // Run() returns normally (its loop condition simply goes false), + // the SAME graceful-exit path an external Ctrl+C/SIGTERM already + // takes. Linking costs nothing when the console never fires. + using CancellationTokenSource linkedQuit = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _consoleQuitRequested.Token); try { - _scheduler.Run(cancellationToken); + _scheduler.Run(linkedQuit.Token); } catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested) + when (linkedQuit.IsCancellationRequested) { } catch (Exception error) @@ -277,6 +376,9 @@ internal sealed class HeadlessProcessHost : IDisposable { if (_disposed) return; + _console?.Dispose(); + _consoleRendererSubscription?.Dispose(); + _consoleQuitRequested.Dispose(); while (_disposeIndex >= 0) { _sessions[_disposeIndex].Dispose(); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 0dbde856..4453cbbe 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -183,6 +183,25 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly IHeadlessBotPolicy _policy; private readonly IDisposable _policySubscription; private readonly HeadlessPluginSession _pluginSession; + /// + /// Headless console (docs/plans/2026-09-07-headless-console.md): the + /// SAME plugin-verb registry 's bus + /// forwards to (via TryHandlePluginCommand) and + /// 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 / + /// , never this field. + /// + private readonly AcDream.Core.Plugins.PluginCommandRegistry _pluginCommands; + /// + /// Headless console: the SAME retained bus LoginCommandSequence + /// submits through — see . One instance + /// for the host's whole lifetime; + /// attaches/detaches a fresh to it on + /// every (re)connect, exactly as it does today for login commands. + /// + 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 /// internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder; internal HeadlessPluginSession Plugins => _pluginSession; + /// Test seam (mirrors 's own + /// pattern): registers a plugin verb directly against the SAME registry + /// a real loaded plugin would use, without loading a plugin assembly. + /// + internal AcDream.Core.Plugins.PluginCommandRegistry PluginCommands => + _pluginCommands; internal string SessionId => _descriptor.Id; + /// + /// Headless console: invoked at the end of every so + /// console input drains ON the session tick, in order, never on the + /// reader thread. (every non-console host) costs + /// nothing extra per tick. + /// + internal Action? ConsolePump { get; set; } internal string ActiveCharacterName { get; private set; } = string.Empty; internal bool IsPolicyComplete => @@ -563,6 +597,31 @@ internal sealed class HeadlessSessionHost : IDisposable _pendingConfirmation = null; } + /// + /// The headless console's ONE entry point for a typed line — the exact + /// pipeline already submits through: + /// against this host's retained + /// . Dispatch order (matching + /// 's own class doc): retail's client- + /// command catalog first, then the local /help presentation + /// command, then the plugin-verb registry, then the retail unregistered- + /// channel-tag fallback, then an explicit server command, then plain + /// chat. Retail's transient interface text (bad-args refusals, unknown- + /// command text — never routed through + /// , see + /// RuntimeCommunicationState.AddText's ClientLocal branch) + /// lands in the shared + /// exactly like every other producer of that text; the console's own + /// per-tick pump polls it (see HeadlessConsoleSpewBoxPump) + /// instead of this call decorating its own feedback. + /// + internal SubmitOutcome SubmitConsoleLine(string line) => + ChatCommandRouter.Submit( + line, + new RuntimeChatCommandFeedback(Runtime.CommunicationOwner), + _chatCommandSurface, + ChatChannelKind.Say); + internal RuntimeSessionStartResult Start() { // Campaign LA slice LA1: "started" = session host start — the @@ -607,6 +666,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") diff --git a/src/AcDream.Headless/Program.cs b/src/AcDream.Headless/Program.cs index 4fae4031..0b08d42c 100644 --- a/src/AcDream.Headless/Program.cs +++ b/src/AcDream.Headless/Program.cs @@ -27,7 +27,9 @@ try Console.In, Console.Out, Console.Error, - cancellation.Token); + cancellation.Token, + standardInputIsTerminal: !Console.IsInputRedirected, + standardOutputIsTerminal: !Console.IsOutputRedirected); } finally { diff --git a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs index 4281ebe5..460913cb 100644 --- a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs @@ -107,10 +107,14 @@ public sealed class LaunchOptionsDocumentationTests } /// - /// The five flags that default ON. All are product/retail behaviors wearing an - /// A/B off-switch (=0 disables) — none is a diagnostic. FROZEN: - /// a diagnostic that activates without its env var set taxes every run - /// and every measurement silently, so growing this set fails. + /// The six flags that default ON. All are product/retail behaviors wearing an + /// A/B off-switch (=0 disables) — none is a diagnostic. + /// ACDREAM_HEADLESS_CONSOLE (added 2026-09-07, S1 fix round) is the + /// odd one out: its UNSET default is terminal-shaped, not unconditionally + /// on — but once it is SET AT ALL it reads the identical =0-disables + /// idiom this regex detects, so it belongs in this set on the same terms. + /// FROZEN: a diagnostic that activates without its env var set taxes every + /// run and every measurement silently, so growing this set fails. /// private static readonly IReadOnlySet DefaultOnBehaviorFlags = new HashSet(StringComparer.Ordinal) @@ -120,6 +124,7 @@ public sealed class LaunchOptionsDocumentationTests "ACDREAM_CAMERA_ALIGN_SLOPE", "ACDREAM_RETAIL_CLOSE_DEGRADES", "ACDREAM_RETAIL_UI", + "ACDREAM_HEADLESS_CONSOLE", }; /// @@ -134,7 +139,7 @@ public sealed class LaunchOptionsDocumentationTests RegexOptions.Compiled); [Fact] - public void OnlyTheFiveProductBehaviorFlagsDefaultOn() + public void OnlyTheSixProductBehaviorFlagsDefaultOn() { var defaultOn = new HashSet(StringComparer.Ordinal); foreach ((string path, _) in SourceFiles()) diff --git a/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs new file mode 100644 index 00000000..b36cea78 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs @@ -0,0 +1,854 @@ +using System.Buffers.Binary; +using System.Diagnostics; +using System.Net; +using System.Text; +using AcDream.Core.Chat; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Hosting; +using AcDream.Headless.Platform; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Chat; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Tests; + +/// +/// docs/plans/2026-09-07-headless-console.md — the headless interactive +/// console. Two layers: / +/// tested in isolation (no live +/// server, no ), then +/// tested against a real +/// host wired to — the same +/// no-network fixture pattern HeadlessSessionHostTests already uses +/// for LoginCommandSequence, proving the console reuses the EXACT +/// same pipeline rather than a second parser. +/// +public sealed class HeadlessConsoleTests +{ + // ── HeadlessConsoleOptions (typed option resolution) ───────────────── + + [Theory] + [InlineData(true, "0", false, true)] // CLI flag always wins, even over env "0" + [InlineData(false, "1", false, true)] // env var "1" wins over terminal default + [InlineData(false, "yes", false, true)] // any non-"0" env value enables (RETAIL_CLOSE_DEGRADES/RETAIL_UI idiom) + // S1 fix (2026-09-07 review round): ACDREAM_HEADLESS_CONSOLE=0 must + // disable the console even when stdin IS a real terminal — the earlier + // `== "1"` test let "0" silently fall through to the terminal-shaped + // default instead of acting as the documented A/B off-switch. + [InlineData(false, "0", true, false)] // env var "0" disables even when stdin is a terminal + [InlineData(false, "0", false, false)] // env var "0" disables when stdin is redirected too + [InlineData(false, null, true, true)] // no flag/env -> terminal-shaped default (on) + [InlineData(false, null, false, false)] // no flag/env -> terminal-shaped default (off) + public void ResolvePrefersFlagThenEnvironmentThenTerminalDefault( + bool commandLineFlag, + string? environmentValue, + bool standardInputIsTerminal, + bool expected) + { + bool resolved = HeadlessConsoleOptions.Resolve( + commandLineFlag, + _ => environmentValue, + standardInputIsTerminal); + + Assert.Equal(expected, resolved); + } + + [Fact] + public void CommandLineParsesTheBareConsoleFlag() + { + HeadlessCommandLine parsed = HeadlessCommandLine.Parse( + ["run", "--config", "bot.json", "--console"]); + + Assert.True(parsed.ConsoleEnabled); + Assert.Equal("bot.json", parsed.ConfigurationPath); + } + + [Fact] + public void CommandLineWithoutTheFlagDefaultsConsoleOff() + { + HeadlessCommandLine parsed = HeadlessCommandLine.Parse( + ["run", "--config", "bot.json"]); + + Assert.False(parsed.ConsoleEnabled); + } + + /// + /// N3: validate mode rejects --console outright (rather than + /// silently ignoring it) — validate never starts a session, so there is + /// nothing for the console to attach to. + /// + [Fact] + public void ValidateModeRejectsTheConsoleFlag() + { + Assert.Throws(() => + HeadlessCommandLine.Parse( + ["validate", "--config", "bot.json", "--console"])); + } + + // ── HeadlessConsoleInputReader: reader-thread/ordering ─────────────── + + /// + /// The required reader-thread test: lines produced by the background + /// thread are drained, in FIFO order, entirely on the CALLING thread. + /// The reader thread itself never runs anything beyond + /// ConcurrentQueue.Enqueue — there is no dispatch code it could + /// execute — so this also structurally proves "never executed on the + /// reader thread," not just orders the output. + /// + [Fact] + public void LinesQueuedByTheReaderThreadDrainInOrderOnTheCallingThread() + { + using var input = new System.IO.StringReader( + "one" + Environment.NewLine + + "two" + Environment.NewLine + + "three" + Environment.NewLine); + using var reader = new HeadlessConsoleInputReader(input); + + Assert.True( + reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)), + "the reader thread never reached EOF"); + + int callingThread = Environment.CurrentManagedThreadId; + var drained = new List(); + while (reader.TryDequeue(out string line)) + { + drained.Add(line); + // Proves the dequeue (and everything a caller does with the + // line) runs on THIS thread, not the reader thread. + Assert.Equal(callingThread, Environment.CurrentManagedThreadId); + } + + Assert.Equal(["one", "two", "three"], drained); + } + + /// + /// S2 (2026-09-07 review round): makes the "never runs on the reader + /// thread" pin FALSIFIABLE rather than merely structurally argued. The + /// prior test proves ordering but infers "never the reader thread" from + /// the reader loop's own code having nothing to dispatch — this test + /// records the ACTUAL thread id ran on + /// (via ) and asserts, from + /// inside the controller's own submit callback, that the executing + /// thread is neither that reader thread nor any other unexpected + /// thread — it must be exactly the thread that called + /// . + /// + [Fact] + public void SubmitRunsOnTheDrainCallersThreadNeverTheReaderThread() + { + using var fixture = new ThreadIdRecordingTextReader( + new System.IO.StringReader("hello" + Environment.NewLine)); + int? observedSubmitThreadId = null; + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + fixture, + TextWriter.Null, + line => + { + observedSubmitThreadId = Environment.CurrentManagedThreadId; + return SubmitOutcome.Sent; + }, + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + int drainCallerThreadId = Environment.CurrentManagedThreadId; + controller.DrainDue(); + + Assert.NotNull(fixture.ReadLineThreadId); + Assert.NotNull(observedSubmitThreadId); + Assert.NotEqual(fixture.ReadLineThreadId, observedSubmitThreadId); + Assert.Equal(drainCallerThreadId, observedSubmitThreadId); + } + + // ── HeadlessConsoleController: /quit, /status, dispatch ordering ───── + + [Fact] + public void ControllerDrainsEveryLineQueuedSinceTheLastTickInOrderOnOneCall() + { + // Simulates "input queued during a busy tick": every line is + // enqueued by the reader thread before DrainDue is ever called — + // one DrainDue call must still process all of them, in order. + using var input = new System.IO.StringReader( + "alpha" + Environment.NewLine + + "beta" + Environment.NewLine + + "gamma" + Environment.NewLine); + var handled = new List(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + TextWriter.Null, + line => + { + handled.Add(line); + return SubmitOutcome.Sent; + }, + () => string.Empty, + quit); + + Assert.True( + WaitForEndOfInput(controller), + "the reader thread never reached EOF"); + controller.DrainDue(); + + Assert.Equal(["alpha", "beta", "gamma"], handled); + Assert.Equal(3, controller.LastDrainCount); + + // A second drain with nothing queued does nothing — proves DrainDue + // does not re-process already-handled lines. + controller.DrainDue(); + Assert.Equal(["alpha", "beta", "gamma"], handled); + Assert.Equal(0, controller.LastDrainCount); + } + + [Fact] + public void QuitRequestsCancellationAndNeverReachesSubmit() + { + using var input = new System.IO.StringReader("/quit" + Environment.NewLine); + var submitted = new List(); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + line => + { + submitted.Add(line); + return SubmitOutcome.Sent; + }, + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.True(quit.IsCancellationRequested); + Assert.Empty(submitted); + Assert.Contains("quitting", output.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void StatusPrintsTheProvidedStatusTextAndNeverReachesSubmit() + { + using var input = new System.IO.StringReader("/status" + Environment.NewLine); + var submitted = new List(); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + line => + { + submitted.Add(line); + return SubmitOutcome.Sent; + }, + () => "generation=1 position=unknown plugins=0 loaded", + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.Empty(submitted); + Assert.Contains( + "generation=1 position=unknown plugins=0 loaded", + output.ToString()); + } + + /// + /// S4: and + /// get a visible console line — + /// matching LoginCommandSequence.DrainDue's own reporting for the + /// same two outcomes — instead of silently doing nothing. + /// + [Theory] + [InlineData(SubmitOutcome.UnknownCommand)] + [InlineData(SubmitOutcome.Dropped)] + public void UnknownOrDroppedOutcomePrintsAVisibleLine(SubmitOutcome outcome) + { + using var input = new System.IO.StringReader("garbage" + Environment.NewLine); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + _ => outcome, + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.Contains("garbage", output.ToString()); + Assert.Contains(outcome.ToString(), output.ToString()); + } + + /// + /// S4: a throwing submit callback (a console typo hitting a downstream + /// bug in a plugin verb handler, say) never escapes DrainDue — + /// it must never reach the scheduler's per-session quarantine catch and + /// fault the whole session over one bad console line. Mirrors + /// LoginCommandSequence.DrainDue's own try/catch. + /// + [Fact] + public void SubmitFailurePrintsALineAndNeverEscapesDrainDue() + { + using var input = new System.IO.StringReader("boom" + Environment.NewLine); + var output = new StringWriter(); + using var quit = new CancellationTokenSource(); + using var controller = new HeadlessConsoleController( + input, + output, + _ => throw new InvalidOperationException("fixture failure"), + () => string.Empty, + quit); + + Assert.True(WaitForEndOfInput(controller)); + controller.DrainDue(); + + Assert.Contains("fixture failure", output.ToString()); + } + + // ── HeadlessConsoleChatFormatter: channel-prefixed rendering ───────── + + [Theory] + [InlineData("Bob", 0x50000010u, "hi", "[Local] Bob: hi")] + [InlineData("", 0u, "hi", "[Local] You: hi")] + public void FormatsLocalSpeechWithTheLocalLabel( + string sender, uint senderGuid, string text, string expected) + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: senderGuid, + Kind: (int)ChatKind.LocalSpeech, + Sender: sender, + Text: text, + ChannelName: string.Empty); + + Assert.Equal(expected, HeadlessConsoleChatFormatter.Format(entry)); + } + + [Fact] + public void FormatsChannelBroadcastWithItsFriendlyName() + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: 0x50000010u, + Kind: (int)ChatKind.Channel, + Sender: "Bob", + Text: "group up", + ChannelName: "Fellowship"); + + Assert.Equal( + "[Fellowship] Bob: group up", + HeadlessConsoleChatFormatter.Format(entry)); + } + + [Theory] + [InlineData(0x50000010u, "Bob", "hi", "[Tell] Bob: hi")] + [InlineData(0u, "Bob", "hi", "[Tell] You -> Bob: hi")] + public void FormatsTellWithDirection( + uint senderGuid, string sender, string text, string expected) + { + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: senderGuid, + Kind: (int)ChatKind.Tell, + Sender: sender, + Text: text, + ChannelName: string.Empty); + + Assert.Equal(expected, HeadlessConsoleChatFormatter.Format(entry)); + } + + // ── HeadlessConsoleRenderer: N5 dim-weight rules ───────────────────── + + /// + /// N5: chat and interface text are player-visible content, not + /// scheduling noise — they must print at the terminal's default weight, + /// never dimmed, even when color is enabled. + /// + [Fact] + public void ChatAndInterfaceTextPrintAtDefaultWeightNeverDimmed() + { + var output = new StringWriter(); + var renderer = new HeadlessConsoleRenderer(output, useColor: true); + var entry = new RuntimeChatEntry( + Revision: 1, + SenderGuid: 0x50000010u, + Kind: (int)ChatKind.LocalSpeech, + Sender: "Bob", + Text: "hi", + ChannelName: string.Empty); + + renderer.OnChat(new RuntimeChatDelta(default, entry)); + renderer.WriteInterfaceText("Unknown command: /x"); + + string text = output.ToString(); + Assert.DoesNotContain("[2m", text); + Assert.Contains("[Local] Bob: hi", text); + Assert.Contains("Unknown command: /x", text); + } + + /// + /// N5: lifecycle, command, and portal lines are scheduling/session- + /// status noise, not player-visible content — dimmed when color is + /// enabled. + /// + [Fact] + public void LifecycleCommandAndPortalLinesAreDimmedWhenColorIsEnabled() + { + var output = new StringWriter(); + var renderer = new HeadlessConsoleRenderer(output, useColor: true); + + renderer.OnLifecycle(new RuntimeLifecycleDelta( + default, RuntimeLifecycleState.Starting, RuntimeLifecycleState.InWorld)); + renderer.OnCommand(new RuntimeCommandDelta( + default, RuntimeCommandDomain.Chat, 0, RuntimeCommandStatus.Rejected, Text: "boom")); + renderer.OnPortal(new RuntimePortalDelta( + default, + new RuntimePortalSnapshot( + Generation: 1, + RuntimePortalKind.Portal, + Readiness: new RuntimeDestinationReadiness( + 1, 0x12345678u, false, false, 0, true, true, true), + Materialized: true, + Completed: false, + Cancelled: false, + WorldViewportObserved: true, + WorldSimulationAvailable: true, + InvariantFailureCount: 0, + WaitCueShown: false, + PortalMaterializationCount: 1))); + + string[] lines = output.ToString() + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, lines.Length); + Assert.All(lines, line => Assert.Contains("[2m", line)); + } + + // ── HeadlessSessionHost.SubmitConsoleLine: the real dispatch pipeline ─ + + [Fact] + public void SlashSayProducesTheSameOutboundTalkActionTheGraphicalRouteSends() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + SubmitOutcome outcome = host.SubmitConsoleLine("/say hello"); + + Assert.Equal(SubmitOutcome.Sent, outcome); + byte[] body = Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + Assert.Equal("hello", TalkText(body)); + } + + [Fact] + public void PlainTextProducesTheSameOutboundTalkActionAsSlashSay() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + SubmitOutcome outcome = host.SubmitConsoleLine("hello"); + + Assert.Equal(SubmitOutcome.Sent, outcome); + byte[] body = Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + Assert.Equal("hello", TalkText(body)); + } + + [Fact] + public void PluginVerbReachesTheRegisteredPluginCommandWithoutTouchingTheWire() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + var received = new List(); + using IDisposable registration = host.PluginCommands.Register( + "vt", + command => received.Add(command)); + + SubmitOutcome outcome = host.SubmitConsoleLine("/vt start"); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + PluginCommand command = Assert.Single(received); + Assert.Equal("vt", command.Verb); + Assert.Equal("start", command.Arguments); + Assert.Empty(captured); + } + + /// + /// S5 rework: SubmitConsoleLine no longer takes a per-call + /// interface-text callback — retail's transient interface text + /// (ClientLocal) lands in the shared + /// exactly like every other + /// producer of that text (see RuntimeCommunicationState.AddText), + /// and the console's own per-tick pump polls it — proven directly here + /// against the real rather + /// than a decorator only this call site could see. + /// + [Fact] + public void UnknownVerbProducesTheSameInterfaceTextTheChatBoxShows() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret("fixture", "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + + // A bare "/" is retail's degenerate-prefix case (no letter verb) — + // ChatCommandRouter refuses it locally instead of sending it to the + // server or speech (ChatCommandRouterTests. + // DegeneratePrefix_UnknownCommand_ShowsRefusal_ViaInterfaceTextSeam + // pins the exact same text for the graphical route). + SubmitOutcome outcome = host.SubmitConsoleLine("/"); + + Assert.Equal(SubmitOutcome.UnknownCommand, outcome); + SpewBoxState spewBox = host.Runtime.CommunicationOwner.SpewBox; + spewBox.Tick(host.Runtime.Clock.SimulationTimeSeconds); + SpewBoxEntry entry = Assert.Single(spewBox.Snapshot()); + Assert.Contains("Unknown command:", entry.Text); + } + + // ── HeadlessConsoleSpewBoxPump: server/plugin-driven interface text ── + + /// + /// S5: the pump must surface interface text that never went through the + /// console at all — a stand-in for a server- or plugin-driven + /// ClientLocal write reaching RuntimeCommunicationState.AddText + /// directly, exactly the case the deleted per-call + /// HeadlessConsoleChatFeedback decorator could never see (it only + /// ever wrapped THIS console's own SubmitConsoleLine feedback). + /// + [Fact] + public void PumpPrintsInterfaceTextNotOriginatingFromTheConsole() + { + var spewBox = new SpewBoxState(); + var printed = new List(); + double now = 0d; + var pump = new HeadlessConsoleSpewBoxPump(spewBox, () => now, printed.Add); + + // Simulates a plugin's own Log/interface-text write, or a server- + // driven refusal — never called HeadlessConsoleController.Handle or + // HeadlessSessionHost.SubmitConsoleLine. + spewBox.Enqueue("[vt] navigation route loaded"); + + pump.Pump(); + + Assert.Equal(["[vt] navigation route loaded"], printed); + + // A second pump with nothing new enqueued must not reprint the + // still-visible entry. + now += 0.1d; + pump.Pump(); + Assert.Equal(["[vt] navigation route loaded"], printed); + } + + // ── HeadlessProcessHost: end-to-end console wiring ─────────────────── + + /// + /// S3: an end-to-end proof that a console line, read from a plain + /// , reaches the real session's + /// SubmitConsoleLine pipeline through the actual + /// wiring (background reader thread → + /// per-tick ConsolePumpChatCommandRouter.Submit → the + /// wire), and that /quit ends + /// through the SAME graceful path an external cancellation takes — + /// , not an error code. + /// + [Fact] + public async Task ConsoleLineReachesTheSessionAndQuitEndsTheProcessGracefully() + { + var captured = new List(); + var operations = new FixtureSessionOperations + { + GameActionCapture = body => captured.Add(body), + }; + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = [Descriptor()], + }; + using var diagnostics = new StringWriter(); + using var input = new System.IO.StringReader( + "hello" + Environment.NewLine + "/quit" + Environment.NewLine); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + input, + diagnostics, + operations, + new FakeTimeProvider(), + directCredentials: new HeadlessDirectCredentials("account", "password"), + consoleEnabled: true); + + HeadlessExitCode exitCode = await host.RunAsync(CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(HeadlessExitCode.Success, exitCode); + byte[] body = Assert.Single(captured); + Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); + Assert.Equal("hello", TalkText(body)); + } + + /// + /// S6: standardOutputIsTerminal is threaded in as a constructor + /// parameter, not read from the real System.Console inside + /// — proven by flipping only the + /// parameter (this test process's OWN stdout is redirected by the test + /// host either way) and observing the renderer's dim-vs-plain choice + /// follow it. + /// + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + public async Task StandardOutputIsTerminalParameterControlsColorNotTheRealConsole( + bool standardOutputIsTerminal, bool expectDimmed) + { + var operations = new FixtureSessionOperations(); + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = [Descriptor()], + }; + using var diagnostics = new StringWriter(); + using var input = new System.IO.StringReader("/quit" + Environment.NewLine); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + input, + diagnostics, + operations, + new FakeTimeProvider(), + directCredentials: new HeadlessDirectCredentials("account", "password"), + consoleEnabled: true, + standardOutputIsTerminal: standardOutputIsTerminal); + + await host.RunAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(10)); + + string text = diagnostics.ToString(); + Assert.Contains("entered world", text); + Assert.Equal(expectDimmed, text.Contains("[2m")); + } + + /// + /// S7: a multi-session process with --console must tell the + /// operator why the console never attached (the launcher's multi-session + /// mode is a legitimate, common configuration) instead of silently + /// doing nothing — see HeadlessDiagnosticWriter.Message's "console" + /// category. + /// + [Fact] + public void TwoSessionsWithConsoleFlagReportsSingleSessionOnly() + { + // StandardInput credentials (one line per session, consumed by + // HeadlessCredentialResolver BEFORE the console reader thread ever + // starts — see HeadlessProcessHost's own constructor comment) avoid + // needing an ACE-shaped Environment credential just to reach the + // console-wiring branch this test targets. + HeadlessSessionDescriptor StandardInputDescriptor(string id) => + Descriptor() with + { + Id = id, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.StandardInput, + Reference = "fixture", + }, + }; + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + StandardInputDescriptor("one"), + StandardInputDescriptor("two"), + ], + }; + var operations = new FixtureSessionOperations(); + using var diagnostics = new StringWriter(); + using var input = new System.IO.StringReader( + "password-one" + Environment.NewLine + + "password-two" + Environment.NewLine); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + input, + diagnostics, + operations, + new FakeTimeProvider(), + directCredentials: null, + consoleEnabled: true); + + Assert.Contains("single-session only", diagnostics.ToString()); + } + + private static bool WaitForEndOfInput(HeadlessConsoleController controller) => + controller.Reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)); + + private static uint ActionOpcode(byte[] body) => + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, sizeof(uint))); + + private static string TalkText(byte[] body) + { + ushort length = BinaryPrimitives.ReadUInt16LittleEndian( + body.AsSpan(12, sizeof(ushort))); + return Encoding.ASCII.GetString(body, 14, length); + } + + private static HeadlessSessionDescriptor Descriptor() => new() + { + Id = "console-bot", + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Character = new HeadlessCharacterSelector + { + Name = "headless", + }, + Policy = new HeadlessBotPolicyDescriptor + { + Id = "idle", + }, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.Environment, + Reference = "CONSOLE_BOT_PASSWORD", + }, + }; + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public Action? GameActionCapture { get; init; } + + public CharacterList.Parsed? Characters { get; init; } = new( + 0u, + [ + new CharacterList.Character(0x50000001u, "Other", 0u), + new CharacterList.Character(0x50000002u, "Headless", 0u), + ], + [], + 11, + "account", + true, + true); + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + var session = new WorldSession(endpoint); + session.GameActionCapture = GameActionCapture; + return session; + } + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed? GetCharacters(WorldSession session) => + Characters; + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => session.Dispose(); + } + + /// + /// S2: wraps a real and records the managed + /// thread id every call actually ran on — the + /// background reader thread's own id, since only + /// ever calls it. + /// + private sealed class ThreadIdRecordingTextReader(TextReader inner) + : TextReader + { + internal int? ReadLineThreadId { get; private set; } + + public override string? ReadLine() + { + ReadLineThreadId = Environment.CurrentManagedThreadId; + return inner.ReadLine(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + inner.Dispose(); + base.Dispose(disposing); + } + } + + /// + /// S3/S6/S7: a real-clock , distinct from + /// , for a + /// integration test that runs the actual scheduler loop on its own + /// dedicated thread. Real elapsed time (not a manually-stepped fake) is + /// deliberate here: owns its + /// own background thread, and stepping a manual clock from the test + /// thread while that thread's scheduler loop waits on a + /// armed from the SAME provider would race the two + /// threads for no benefit — the default 15 ms turn period already makes + /// these tests fast. + /// + private sealed class FakeTimeProvider : TimeProvider + { + public override long GetTimestamp() => Stopwatch.GetTimestamp(); + + public override long TimestampFrequency => Stopwatch.Frequency; + } +}