merge: headless console — interactive chat/command CLI for the bot host (review-closed)
Owner direction 2026-09-07. Reader thread → tick-drained queue, the same ChatCommandRouter.Submit the chat box uses, event-stream renderer, SpewBox pump, --console / ACDREAM_HEADLESS_CONSOLE (=0 disables). Opus review APPROVE-WITH-FIXES, 12-item fix round, narrow re-check MERGE-READY. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
8cb284d6f7
15 changed files with 1728 additions and 17 deletions
|
|
@ -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,
|
- **Everything diagnostic is OFF by default.** Every probe, dump, capture,
|
||||||
and measurement flag in this document is inert until its variable is
|
and measurement flag in this document is inert until its variable is
|
||||||
explicitly set — an unset environment runs zero diagnostics. Exactly
|
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_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and
|
||||||
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
|
`ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B
|
||||||
off-switch (`=0` disables the behavior for a comparison run), while
|
off-switch (`=0` disables the behavior for a comparison run), while
|
||||||
`ACDREAM_RETAIL_UI` is the product's only gameplay presentation and uses
|
`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
|
`LaunchOptionsDocumentationTests` — a new
|
||||||
default-on flag fails the build.
|
default-on flag fails the build.
|
||||||
- `=1` means the code tests for exactly the string `1`. Setting `true`,
|
- `=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` | `=<path>` | 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_DAT_DIR` | `=<path>` | 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_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` | `=<int>` | 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_FAR_RADIUS` | `=<int>` | 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_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` | `=<int>` | 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_MAX_COMPLETIONS_PER_FRAME` | `=<int>` | 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` | `=<int>` (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`) |
|
| `ACDREAM_MSAA_SAMPLES` | `=<int>` (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 <path>` | The versioned headless session-configuration document. Required. | — |
|
| `--config <path>` | The versioned headless session-configuration document. Required. | — |
|
||||||
| `--config-dir` / `--data-dir` / `--cache-dir` `<path>` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. |
|
| `--config-dir` / `--data-dir` / `--cache-dir` `<path>` | 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. |
|
| `-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. | — |
|
| `--help` / `-h` (or no args) | Prints usage, exits 0. | — |
|
||||||
|
|
||||||
### `AcDream.Launcher`
|
### `AcDream.Launcher`
|
||||||
|
|
|
||||||
|
|
@ -66,3 +66,158 @@ it or the lead may, it is not a visual gate.
|
||||||
|
|
||||||
## Ledger
|
## Ledger
|
||||||
- 2026-09-07 planned; implementer dispatched.
|
- 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 <path-to-a-one-session-config.json> `
|
||||||
|
-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.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ internal sealed record HeadlessCommandLine(
|
||||||
string Command,
|
string Command,
|
||||||
string ConfigurationPath,
|
string ConfigurationPath,
|
||||||
HeadlessPathOverrides Paths,
|
HeadlessPathOverrides Paths,
|
||||||
HeadlessDirectCredentials? DirectCredentials)
|
HeadlessDirectCredentials? DirectCredentials,
|
||||||
|
bool ConsoleEnabled = false)
|
||||||
{
|
{
|
||||||
internal static HeadlessCommandLine Parse(
|
internal static HeadlessCommandLine Parse(
|
||||||
IReadOnlyList<string> arguments)
|
IReadOnlyList<string> arguments)
|
||||||
|
|
@ -23,15 +24,26 @@ internal sealed record HeadlessCommandLine(
|
||||||
string? cacheDirectory = null;
|
string? cacheDirectory = null;
|
||||||
string? user = null;
|
string? user = null;
|
||||||
string? password = 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)
|
if (index + 1 >= arguments.Count)
|
||||||
{
|
{
|
||||||
throw new HeadlessCommandLineException(
|
throw new HeadlessCommandLineException(
|
||||||
"Every command option requires a value.");
|
"Every command option requires a value.");
|
||||||
}
|
}
|
||||||
|
|
||||||
string name = arguments[index];
|
|
||||||
string value = arguments[index + 1];
|
string value = arguments[index + 1];
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
|
|
@ -65,6 +77,7 @@ internal sealed record HeadlessCommandLine(
|
||||||
throw new HeadlessCommandLineException(
|
throw new HeadlessCommandLineException(
|
||||||
"Unknown command option.");
|
"Unknown command option.");
|
||||||
}
|
}
|
||||||
|
index += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configurationPath is null)
|
if (configurationPath is null)
|
||||||
|
|
@ -82,6 +95,14 @@ internal sealed record HeadlessCommandLine(
|
||||||
throw new HeadlessCommandLineException(
|
throw new HeadlessCommandLineException(
|
||||||
"Direct credentials are valid only for run mode.");
|
"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(
|
return new HeadlessCommandLine(
|
||||||
command,
|
command,
|
||||||
|
|
@ -92,7 +113,8 @@ internal sealed record HeadlessCommandLine(
|
||||||
cacheDirectory),
|
cacheDirectory),
|
||||||
user is null
|
user is null
|
||||||
? null
|
? null
|
||||||
: new HeadlessDirectCredentials(user, password!));
|
: new HeadlessDirectCredentials(user, password!),
|
||||||
|
console);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SetOnce(ref string? destination, string value)
|
private static void SetOnce(ref string? destination, string value)
|
||||||
|
|
|
||||||
53
src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs
Normal file
53
src/AcDream.Headless/Configuration/HeadlessConsoleOptions.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
namespace AcDream.Headless.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed resolution for the headless interactive console (docs/plans/
|
||||||
|
/// 2026-09-07-headless-console.md). Three inputs, first match wins:
|
||||||
|
/// the <c>--console</c> command-line flag, the
|
||||||
|
/// <c>ACDREAM_HEADLESS_CONSOLE</c> 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
|
||||||
|
/// <c>ReadLine</c> would never see input and would just sit idle). See
|
||||||
|
/// docs/launch-options.md for the documented row this owns.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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 —
|
||||||
|
/// <c>ACDREAM_HEADLESS_CONSOLE=0</c> must disable the console even when
|
||||||
|
/// stdin is a real terminal, matching the
|
||||||
|
/// <c>ACDREAM_RETAIL_CLOSE_DEGRADES</c> / <c>ACDREAM_RETAIL_UI</c>
|
||||||
|
/// convention (any value other than the literal string <c>"0"</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
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<string, string?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,7 +46,9 @@ internal static class HeadlessEntryPoint
|
||||||
TextReader standardInput,
|
TextReader standardInput,
|
||||||
TextWriter output,
|
TextWriter output,
|
||||||
TextWriter error,
|
TextWriter error,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
bool standardInputIsTerminal = false,
|
||||||
|
bool standardOutputIsTerminal = false)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(arguments);
|
ArgumentNullException.ThrowIfNull(arguments);
|
||||||
ArgumentNullException.ThrowIfNull(standardInput);
|
ArgumentNullException.ThrowIfNull(standardInput);
|
||||||
|
|
@ -74,13 +76,18 @@ internal static class HeadlessEntryPoint
|
||||||
configuredPaths.Merge(commandLine.Paths));
|
configuredPaths.Merge(commandLine.Paths));
|
||||||
if (commandLine.Command == "run")
|
if (commandLine.Command == "run")
|
||||||
{
|
{
|
||||||
|
bool consoleEnabled = HeadlessConsoleOptions.Resolve(
|
||||||
|
commandLine.ConsoleEnabled,
|
||||||
|
standardInputIsTerminal);
|
||||||
using var host = new HeadlessProcessHost(
|
using var host = new HeadlessProcessHost(
|
||||||
configuration,
|
configuration,
|
||||||
paths,
|
paths,
|
||||||
standardInput,
|
standardInput,
|
||||||
output,
|
output,
|
||||||
directCredentials:
|
directCredentials:
|
||||||
commandLine.DirectCredentials);
|
commandLine.DirectCredentials,
|
||||||
|
consoleEnabled: consoleEnabled,
|
||||||
|
standardOutputIsTerminal: standardOutputIsTerminal);
|
||||||
return (int)host.RunAsync(cancellationToken)
|
return (int)host.RunAsync(cancellationToken)
|
||||||
.GetAwaiter()
|
.GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
|
|
|
||||||
58
src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs
Normal file
58
src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Presentation for the console's rendered chat lines. A distinct, terminal-
|
||||||
|
/// shaped format from the graphical <c>ChatVM.FormatEntry</c> retail prose
|
||||||
|
/// (Headless cannot reference <c>AcDream.UI.Abstractions</c> — 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 (<see cref="RuntimeChatEntry.ChannelName"/>, "Tell", "Local") per
|
||||||
|
/// the plan's requirement, just not the same sentence template.
|
||||||
|
/// </summary>
|
||||||
|
internal static class HeadlessConsoleChatFormatter
|
||||||
|
{
|
||||||
|
/// <summary>Formats one chat event for the console, or
|
||||||
|
/// <see langword="null"/> when this kind renders nothing (there are
|
||||||
|
/// none today — kept for forward compatibility with a future silent
|
||||||
|
/// kind).</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
116
src/AcDream.Headless/Hosting/HeadlessConsoleController.cs
Normal file
116
src/AcDream.Headless/Hosting/HeadlessConsoleController.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
using AcDream.Runtime.Chat;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The console's own orchestration: owns the background reader
|
||||||
|
/// (<see cref="HeadlessConsoleInputReader"/>) and, once per session tick
|
||||||
|
/// (<see cref="DrainDue"/>), 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
|
||||||
|
/// <see cref="HeadlessConsoleInputReader"/>'s own doc).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>/quit</c> and <c>/status</c> are console-only controls (the plan's
|
||||||
|
/// "Control" section) — they never reach <see cref="ChatCommandRouter"/>,
|
||||||
|
/// matching retail's own client-local commands. Every other line goes
|
||||||
|
/// through <paramref name="submit"/>, which a production caller binds to
|
||||||
|
/// <c>HeadlessSessionHost.SubmitConsoleLine</c> — the exact
|
||||||
|
/// <see cref="ChatCommandRouter.Submit"/> pipeline (retail's client-command
|
||||||
|
/// catalog first, then local <c>/help</c>, then the plugin-verb registry,
|
||||||
|
/// then the retail unregistered-channel-tag fallback, then an explicit
|
||||||
|
/// server command, then plain chat) <c>LoginCommandSequence</c> and the
|
||||||
|
/// graphical chat box both already use.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class HeadlessConsoleController : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HeadlessConsoleInputReader _reader;
|
||||||
|
private readonly TextWriter _output;
|
||||||
|
private readonly Func<string, SubmitOutcome> _submit;
|
||||||
|
private readonly Func<string> _statusText;
|
||||||
|
private readonly CancellationTokenSource _quitRequested;
|
||||||
|
|
||||||
|
internal HeadlessConsoleController(
|
||||||
|
TextReader input,
|
||||||
|
TextWriter output,
|
||||||
|
Func<string, SubmitOutcome> submit,
|
||||||
|
Func<string> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of lines handled by the most recent
|
||||||
|
/// <see cref="DrainDue"/> call — a test seam for the reader-thread
|
||||||
|
/// ordering assertion.</summary>
|
||||||
|
internal int LastDrainCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Test seam: lets a bounded-fixture test wait for the
|
||||||
|
/// background reader thread to reach EOF before calling
|
||||||
|
/// <see cref="DrainDue"/>, instead of sleeping or polling.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
94
src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs
Normal file
94
src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads lines from a <see cref="TextReader"/> on one dedicated background
|
||||||
|
/// thread and hands them to whoever drains <see cref="TryDequeue"/>. 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 <see cref="TryDequeue"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="TextReader.ReadLine"/> has no cancellable overload, so a real
|
||||||
|
/// <c>Console.In</c> 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 <see cref="Dispose"/> 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; <see cref="EndOfInput"/> lets a test
|
||||||
|
/// wait for that deterministically instead of polling or sleeping.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class HeadlessConsoleInputReader : IDisposable
|
||||||
|
{
|
||||||
|
private readonly TextReader _input;
|
||||||
|
private readonly ConcurrentQueue<string> _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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.
|
||||||
|
/// </summary>
|
||||||
|
internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false);
|
||||||
|
|
||||||
|
/// <summary>Dequeues the next queued line in FIFO order, or returns
|
||||||
|
/// <see langword="false"/> if none is queued yet. Never blocks.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Requests the read loop stop at its next opportunity. Does
|
||||||
|
/// not abort a <see cref="TextReader.ReadLine"/> already in progress —
|
||||||
|
/// the thread is background, so it cannot block process exit.
|
||||||
|
/// Deliberately does NOT dispose <see cref="EndOfInput"/>: the read
|
||||||
|
/// loop's own <c>finally</c> sets it from the reader thread, and racing
|
||||||
|
/// that against a Dispose() here (an unhandled
|
||||||
|
/// <see cref="ObjectDisposedException"/> on a background thread
|
||||||
|
/// terminates the process) is worse than leaking one small
|
||||||
|
/// synchronization handle for the process's remaining lifetime.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_stopRequested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs
Normal file
111
src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
using AcDream.Runtime;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One presentation over the K2 bot event stream
|
||||||
|
/// (<see cref="IRuntimeEventObserver"/>) — the SAME typed events a headless
|
||||||
|
/// bot policy observes (<c>HeadlessBotPolicy.cs</c>) — rendered as plain
|
||||||
|
/// lines. Every write goes through <see cref="WriteLine"/>, so a test can
|
||||||
|
/// assert on exactly what a real console would have printed without a
|
||||||
|
/// terminal.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HeadlessConsoleRenderer : IRuntimeEventObserver
|
||||||
|
{
|
||||||
|
private const string Reset = "[0m";
|
||||||
|
private const string Dim = "[2m";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's transient "interface text" (SpewBox, <c>ClientLocal</c>
|
||||||
|
/// type) never touches <see cref="RuntimeCommunicationState.Chat"/> —
|
||||||
|
/// see <c>RuntimeCommunicationState.AddText</c> — so it never reaches
|
||||||
|
/// <see cref="OnChat"/>. <c>HeadlessConsoleSpewBoxPump</c> calls this
|
||||||
|
/// directly, once per console tick, for whatever text is newly visible
|
||||||
|
/// in the polled <see cref="AcDream.Core.Chat.SpewBoxState"/> — 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.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void WriteLine(string text, bool dim)
|
||||||
|
{
|
||||||
|
_output.WriteLine(_useColor && dim ? Dim + text + Reset : text);
|
||||||
|
_output.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs
Normal file
63
src/AcDream.Headless/Hosting/HeadlessConsoleSpewBoxPump.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
using AcDream.Core.Chat;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S5 (2026-09-07 review round, docs/plans/2026-09-07-headless-console.md):
|
||||||
|
/// polls <see cref="SpewBoxState"/> on the console's own per-tick pump — the
|
||||||
|
/// SAME seam <c>AcDream.App.UI.SpewBoxController.Tick</c> drives for the
|
||||||
|
/// graphical overlay. Retail's transient "interface text"
|
||||||
|
/// (<see cref="RetailLogTextType.ClientLocal"/>, routed by
|
||||||
|
/// <c>RuntimeCommunicationState.AddText</c>) never touches
|
||||||
|
/// <c>RuntimeCommunicationState.Chat</c>/<c>RuntimeChatDelta</c>, 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
|
||||||
|
/// <c>HeadlessConsoleChatFeedback</c> decorator, which only ever saw text
|
||||||
|
/// produced by the console's own <c>SubmitConsoleLine</c> calls.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HeadlessConsoleSpewBoxPump
|
||||||
|
{
|
||||||
|
private readonly SpewBoxState _spewBox;
|
||||||
|
private readonly Func<double> _nowSeconds;
|
||||||
|
private readonly Action<string> _writeInterfaceText;
|
||||||
|
private SpewBoxEntry[] _lastSeen = [];
|
||||||
|
|
||||||
|
internal HeadlessConsoleSpewBoxPump(
|
||||||
|
SpewBoxState spewBox,
|
||||||
|
Func<double> nowSeconds,
|
||||||
|
Action<string> writeInterfaceText)
|
||||||
|
{
|
||||||
|
_spewBox = spewBox ?? throw new ArgumentNullException(nameof(spewBox));
|
||||||
|
_nowSeconds = nowSeconds
|
||||||
|
?? throw new ArgumentNullException(nameof(nowSeconds));
|
||||||
|
_writeInterfaceText = writeInterfaceText
|
||||||
|
?? throw new ArgumentNullException(nameof(writeInterfaceText));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drains any pending SpewBox text into the visible set (exactly
|
||||||
|
/// <see cref="SpewBoxState.Tick"/>'s contract — the same drain
|
||||||
|
/// <c>SpewBoxVM.Lines</c> performs for the graphical overlay) and prints
|
||||||
|
/// any entry that was not part of the previous call's visible snapshot.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="SpewBoxState.Snapshot"/> is newest-first
|
||||||
|
/// (retail's <c>InsertItem(item, 0)</c>); this walks it back-to-front so
|
||||||
|
/// newly-visible entries print in the order they were actually
|
||||||
|
/// enqueued, not newest-first.
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||||
private readonly HeadlessProcessContentOwner? _content;
|
private readonly HeadlessProcessContentOwner? _content;
|
||||||
private readonly HeadlessProcessResourceSampler _resources;
|
private readonly HeadlessProcessResourceSampler _resources;
|
||||||
|
/// <summary>
|
||||||
|
/// Headless console (docs/plans/2026-09-07-headless-console.md): always
|
||||||
|
/// created, cancelled only by <c>/quit</c> — linking it into the
|
||||||
|
/// scheduler's run token below costs nothing when the console is
|
||||||
|
/// disabled (it simply never fires) and keeps <see cref="RunOnUpdateThread"/>
|
||||||
|
/// free of a console-shaped branch.
|
||||||
|
/// </summary>
|
||||||
|
private readonly CancellationTokenSource _consoleQuitRequested = new();
|
||||||
|
private readonly HeadlessConsoleController? _console;
|
||||||
|
private readonly IDisposable? _consoleRendererSubscription;
|
||||||
private int _disposeIndex;
|
private int _disposeIndex;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -26,7 +36,9 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
ILiveSessionOperations? sessionOperations = null,
|
ILiveSessionOperations? sessionOperations = null,
|
||||||
TimeProvider? timeProvider = null,
|
TimeProvider? timeProvider = null,
|
||||||
IHeadlessProcessContentFactory? contentFactory = null,
|
IHeadlessProcessContentFactory? contentFactory = null,
|
||||||
HeadlessDirectCredentials? directCredentials = null)
|
HeadlessDirectCredentials? directCredentials = null,
|
||||||
|
bool consoleEnabled = false,
|
||||||
|
bool standardOutputIsTerminal = false)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(configuration);
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
@ -65,6 +77,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
paths.VtankProfilesDirectory);
|
paths.VtankProfilesDirectory);
|
||||||
HeadlessProcessContentOwner? content = null;
|
HeadlessProcessContentOwner? content = null;
|
||||||
HeadlessProcessResourceSampler? resources = null;
|
HeadlessProcessResourceSampler? resources = null;
|
||||||
|
HeadlessConsoleController? console = null;
|
||||||
|
IDisposable? consoleRendererSubscription = null;
|
||||||
// FA6: constructed unconditionally — cheap, and every non-gate
|
// FA6: constructed unconditionally — cheap, and every non-gate
|
||||||
// session simply never reads or writes it (see the coordinator's
|
// session simply never reads or writes it (see the coordinator's
|
||||||
// own class doc).
|
// own class doc).
|
||||||
|
|
@ -137,9 +151,62 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
_resources = resources;
|
_resources = resources;
|
||||||
_content = content;
|
_content = content;
|
||||||
_disposeIndex = _sessions.Length - 1;
|
_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
|
catch
|
||||||
{
|
{
|
||||||
|
console?.Dispose();
|
||||||
|
consoleRendererSubscription?.Dispose();
|
||||||
resources?.Dispose();
|
resources?.Dispose();
|
||||||
for (int index = sessions.Count - 1; index >= 0; index--)
|
for (int index = sessions.Count - 1; index >= 0; index--)
|
||||||
sessions[index].Dispose();
|
sessions[index].Dispose();
|
||||||
|
|
@ -148,6 +215,29 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>/status</c>: 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").
|
||||||
|
/// </summary>
|
||||||
|
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
|
internal HeadlessSessionHost Session => _sessions.Length == 1
|
||||||
? _sessions[0]
|
? _sessions[0]
|
||||||
: throw new InvalidOperationException(
|
: throw new InvalidOperationException(
|
||||||
|
|
@ -249,12 +339,21 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
_scheduler.CaptureSnapshot(),
|
_scheduler.CaptureSnapshot(),
|
||||||
_content);
|
_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
|
try
|
||||||
{
|
{
|
||||||
_scheduler.Run(cancellationToken);
|
_scheduler.Run(linkedQuit.Token);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
when (cancellationToken.IsCancellationRequested)
|
when (linkedQuit.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
|
|
@ -277,6 +376,9 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
return;
|
return;
|
||||||
|
_console?.Dispose();
|
||||||
|
_consoleRendererSubscription?.Dispose();
|
||||||
|
_consoleQuitRequested.Dispose();
|
||||||
while (_disposeIndex >= 0)
|
while (_disposeIndex >= 0)
|
||||||
{
|
{
|
||||||
_sessions[_disposeIndex].Dispose();
|
_sessions[_disposeIndex].Dispose();
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,25 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
private readonly IHeadlessBotPolicy _policy;
|
private readonly IHeadlessBotPolicy _policy;
|
||||||
private readonly IDisposable _policySubscription;
|
private readonly IDisposable _policySubscription;
|
||||||
private readonly HeadlessPluginSession _pluginSession;
|
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 LiveSessionHost _liveSession;
|
||||||
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
private readonly RuntimeLocalPlayerFrameController _localPlayerFrame;
|
||||||
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease?
|
||||||
|
|
@ -453,6 +472,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
Runtime = runtime;
|
Runtime = runtime;
|
||||||
Commands = commands;
|
Commands = commands;
|
||||||
_liveSession = liveSession;
|
_liveSession = liveSession;
|
||||||
|
_pluginCommands = pluginCommands;
|
||||||
|
_chatCommandSurface = chatCommandSurface;
|
||||||
_statusWriter = statusWriter;
|
_statusWriter = statusWriter;
|
||||||
_localPlayerFrame =
|
_localPlayerFrame =
|
||||||
runtime.CreateLocalPlayerFrameController(
|
runtime.CreateLocalPlayerFrameController(
|
||||||
|
|
@ -521,7 +542,20 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
|
internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder;
|
||||||
internal HeadlessPluginSession Plugins => _pluginSession;
|
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;
|
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; } =
|
internal string ActiveCharacterName { get; private set; } =
|
||||||
string.Empty;
|
string.Empty;
|
||||||
internal bool IsPolicyComplete =>
|
internal bool IsPolicyComplete =>
|
||||||
|
|
@ -563,6 +597,31 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_pendingConfirmation = null;
|
_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"/>. Dispatch order (matching
|
||||||
|
/// <see cref="ChatCommandRouter"/>'s own class doc): retail's client-
|
||||||
|
/// command catalog first, then the local <c>/help</c> 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 cref="AcDream.Runtime.RuntimeChatDelta"/>, see
|
||||||
|
/// <c>RuntimeCommunicationState.AddText</c>'s <c>ClientLocal</c> branch)
|
||||||
|
/// lands in the shared <see cref="RuntimeCommunicationState.SpewBox"/>
|
||||||
|
/// exactly like every other producer of that text; the console's own
|
||||||
|
/// per-tick pump polls it (see <c>HeadlessConsoleSpewBoxPump</c>)
|
||||||
|
/// instead of this call decorating its own feedback.
|
||||||
|
/// </summary>
|
||||||
|
internal SubmitOutcome SubmitConsoleLine(string line) =>
|
||||||
|
ChatCommandRouter.Submit(
|
||||||
|
line,
|
||||||
|
new RuntimeChatCommandFeedback(Runtime.CommunicationOwner),
|
||||||
|
_chatCommandSurface,
|
||||||
|
ChatChannelKind.Say);
|
||||||
|
|
||||||
internal RuntimeSessionStartResult Start()
|
internal RuntimeSessionStartResult Start()
|
||||||
{
|
{
|
||||||
// Campaign LA slice LA1: "started" = session host start — the
|
// Campaign LA slice LA1: "started" = session host start — the
|
||||||
|
|
@ -607,6 +666,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_localPlayerFrame.RunPostNetworkCommandPhase();
|
_localPlayerFrame.RunPostNetworkCommandPhase();
|
||||||
Runtime.ActionOwner.CombatAttack.Tick();
|
Runtime.ActionOwner.CombatAttack.Tick();
|
||||||
_policy.Tick(Runtime, Commands);
|
_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")
|
internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped")
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ try
|
||||||
Console.In,
|
Console.In,
|
||||||
Console.Out,
|
Console.Out,
|
||||||
Console.Error,
|
Console.Error,
|
||||||
cancellation.Token);
|
cancellation.Token,
|
||||||
|
standardInputIsTerminal: !Console.IsInputRedirected,
|
||||||
|
standardOutputIsTerminal: !Console.IsOutputRedirected);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -107,10 +107,14 @@ public sealed class LaunchOptionsDocumentationTests
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The five flags that default ON. All are product/retail behaviors wearing an
|
/// The six flags that default ON. All are product/retail behaviors wearing an
|
||||||
/// A/B off-switch (<c>=0</c> disables) — none is a diagnostic. FROZEN:
|
/// A/B off-switch (<c>=0</c> disables) — none is a diagnostic.
|
||||||
/// a diagnostic that activates without its env var set taxes every run
|
/// <c>ACDREAM_HEADLESS_CONSOLE</c> (added 2026-09-07, S1 fix round) is the
|
||||||
/// and every measurement silently, so growing this set fails.
|
/// odd one out: its UNSET default is terminal-shaped, not unconditionally
|
||||||
|
/// on — but once it is SET AT ALL it reads the identical <c>=0</c>-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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly IReadOnlySet<string> DefaultOnBehaviorFlags =
|
private static readonly IReadOnlySet<string> DefaultOnBehaviorFlags =
|
||||||
new HashSet<string>(StringComparer.Ordinal)
|
new HashSet<string>(StringComparer.Ordinal)
|
||||||
|
|
@ -120,6 +124,7 @@ public sealed class LaunchOptionsDocumentationTests
|
||||||
"ACDREAM_CAMERA_ALIGN_SLOPE",
|
"ACDREAM_CAMERA_ALIGN_SLOPE",
|
||||||
"ACDREAM_RETAIL_CLOSE_DEGRADES",
|
"ACDREAM_RETAIL_CLOSE_DEGRADES",
|
||||||
"ACDREAM_RETAIL_UI",
|
"ACDREAM_RETAIL_UI",
|
||||||
|
"ACDREAM_HEADLESS_CONSOLE",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -134,7 +139,7 @@ public sealed class LaunchOptionsDocumentationTests
|
||||||
RegexOptions.Compiled);
|
RegexOptions.Compiled);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OnlyTheFiveProductBehaviorFlagsDefaultOn()
|
public void OnlyTheSixProductBehaviorFlagsDefaultOn()
|
||||||
{
|
{
|
||||||
var defaultOn = new HashSet<string>(StringComparer.Ordinal);
|
var defaultOn = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach ((string path, _) in SourceFiles())
|
foreach ((string path, _) in SourceFiles())
|
||||||
|
|
|
||||||
854
tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
Normal file
854
tests/AcDream.Headless.Tests/HeadlessConsoleTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// docs/plans/2026-09-07-headless-console.md — the headless interactive
|
||||||
|
/// console. Two layers: <see cref="HeadlessConsoleInputReader"/> /
|
||||||
|
/// <see cref="HeadlessConsoleController"/> tested in isolation (no live
|
||||||
|
/// server, no <see cref="HeadlessSessionHost"/>), then
|
||||||
|
/// <see cref="HeadlessSessionHost.SubmitConsoleLine"/> tested against a real
|
||||||
|
/// host wired to <see cref="FixtureSessionOperations"/> — the same
|
||||||
|
/// no-network fixture pattern <c>HeadlessSessionHostTests</c> already uses
|
||||||
|
/// for <c>LoginCommandSequence</c>, proving the console reuses the EXACT
|
||||||
|
/// same pipeline rather than a second parser.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// N3: validate mode rejects <c>--console</c> outright (rather than
|
||||||
|
/// silently ignoring it) — validate never starts a session, so there is
|
||||||
|
/// nothing for the console to attach to.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ValidateModeRejectsTheConsoleFlag()
|
||||||
|
{
|
||||||
|
Assert.Throws<HeadlessCommandLineException>(() =>
|
||||||
|
HeadlessCommandLine.Parse(
|
||||||
|
["validate", "--config", "bot.json", "--console"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HeadlessConsoleInputReader: reader-thread/ordering ───────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>ConcurrentQueue.Enqueue</c> — there is no dispatch code it could
|
||||||
|
/// execute — so this also structurally proves "never executed on the
|
||||||
|
/// reader thread," not just orders the output.
|
||||||
|
/// </summary>
|
||||||
|
[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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="TextReader.ReadLine"/> ran on
|
||||||
|
/// (via <see cref="ThreadIdRecordingTextReader"/>) 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
|
||||||
|
/// <see cref="HeadlessConsoleController.DrainDue"/>.
|
||||||
|
/// </summary>
|
||||||
|
[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<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S4: <see cref="SubmitOutcome.UnknownCommand"/> and
|
||||||
|
/// <see cref="SubmitOutcome.Dropped"/> get a visible console line —
|
||||||
|
/// matching <c>LoginCommandSequence.DrainDue</c>'s own reporting for the
|
||||||
|
/// same two outcomes — instead of silently doing nothing.
|
||||||
|
/// </summary>
|
||||||
|
[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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S4: a throwing submit callback (a console typo hitting a downstream
|
||||||
|
/// bug in a plugin verb handler, say) never escapes <c>DrainDue</c> —
|
||||||
|
/// it must never reach the scheduler's per-session quarantine catch and
|
||||||
|
/// fault the whole session over one bad console line. Mirrors
|
||||||
|
/// <c>LoginCommandSequence.DrainDue</c>'s own try/catch.
|
||||||
|
/// </summary>
|
||||||
|
[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 ─────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// N5: lifecycle, command, and portal lines are scheduling/session-
|
||||||
|
/// status noise, not player-visible content — dimmed when color is
|
||||||
|
/// enabled.
|
||||||
|
/// </summary>
|
||||||
|
[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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<byte[]>();
|
||||||
|
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<PluginCommand>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S5 rework: <c>SubmitConsoleLine</c> no longer takes a per-call
|
||||||
|
/// interface-text callback — retail's transient interface text
|
||||||
|
/// (<c>ClientLocal</c>) lands in the shared
|
||||||
|
/// <see cref="AcDream.Core.Chat.SpewBoxState"/> exactly like every other
|
||||||
|
/// producer of that text (see <c>RuntimeCommunicationState.AddText</c>),
|
||||||
|
/// and the console's own per-tick pump polls it — proven directly here
|
||||||
|
/// against the real <see cref="AcDream.Core.Chat.SpewBoxState"/> rather
|
||||||
|
/// than a decorator only this call site could see.
|
||||||
|
/// </summary>
|
||||||
|
[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 ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S5: the pump must surface interface text that never went through the
|
||||||
|
/// console at all — a stand-in for a server- or plugin-driven
|
||||||
|
/// <c>ClientLocal</c> write reaching <c>RuntimeCommunicationState.AddText</c>
|
||||||
|
/// directly, exactly the case the deleted per-call
|
||||||
|
/// <c>HeadlessConsoleChatFeedback</c> decorator could never see (it only
|
||||||
|
/// ever wrapped THIS console's own <c>SubmitConsoleLine</c> feedback).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PumpPrintsInterfaceTextNotOriginatingFromTheConsole()
|
||||||
|
{
|
||||||
|
var spewBox = new SpewBoxState();
|
||||||
|
var printed = new List<string>();
|
||||||
|
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 ───────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S3: an end-to-end proof that a console line, read from a plain
|
||||||
|
/// <see cref="StringReader"/>, reaches the real session's
|
||||||
|
/// <c>SubmitConsoleLine</c> pipeline through the actual
|
||||||
|
/// <see cref="HeadlessProcessHost"/> wiring (background reader thread →
|
||||||
|
/// per-tick <c>ConsolePump</c> → <c>ChatCommandRouter.Submit</c> → the
|
||||||
|
/// wire), and that <c>/quit</c> ends <see cref="HeadlessProcessHost.RunAsync"/>
|
||||||
|
/// through the SAME graceful path an external cancellation takes —
|
||||||
|
/// <see cref="HeadlessExitCode.Success"/>, not an error code.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConsoleLineReachesTheSessionAndQuitEndsTheProcessGracefully()
|
||||||
|
{
|
||||||
|
var captured = new List<byte[]>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S6: <c>standardOutputIsTerminal</c> is threaded in as a constructor
|
||||||
|
/// parameter, not read from the real <c>System.Console</c> inside
|
||||||
|
/// <see cref="HeadlessProcessHost"/> — 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.
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S7: a multi-session process with <c>--console</c> 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 <c>HeadlessDiagnosticWriter.Message</c>'s "console"
|
||||||
|
/// category.
|
||||||
|
/// </summary>
|
||||||
|
[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<byte[]>? 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S2: wraps a real <see cref="TextReader"/> and records the managed
|
||||||
|
/// thread id every <see cref="ReadLine"/> call actually ran on — the
|
||||||
|
/// background reader thread's own id, since only
|
||||||
|
/// <see cref="HeadlessConsoleInputReader"/> ever calls it.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S3/S6/S7: a real-clock <see cref="TimeProvider"/>, distinct from
|
||||||
|
/// <see cref="TimeProvider.System"/>, for a <see cref="HeadlessProcessHost"/>
|
||||||
|
/// integration test that runs the actual scheduler loop on its own
|
||||||
|
/// dedicated thread. Real elapsed time (not a manually-stepped fake) is
|
||||||
|
/// deliberate here: <see cref="HeadlessProcessHost.RunAsync"/> owns its
|
||||||
|
/// own background thread, and stepping a manual clock from the test
|
||||||
|
/// thread while that thread's scheduler loop waits on a
|
||||||
|
/// <see cref="ITimer"/> armed from the SAME provider would race the two
|
||||||
|
/// threads for no benefit — the default 15 ms turn period already makes
|
||||||
|
/// these tests fast.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
public override long GetTimestamp() => Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
|
public override long TimestampFrequency => Stopwatch.Frequency;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue