using System.Buffers.Binary; 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.Plugin.Abstractions; using AcDream.Runtime; using AcDream.Runtime.Chat; using AcDream.Runtime.Session; namespace AcDream.Headless.Tests; /// /// docs/plans/2026-09-07-headless-console.md — the headless interactive /// console. Two layers: / /// tested in isolation (no live /// server, no ), then /// tested against a real /// host wired to — the same /// no-network fixture pattern HeadlessSessionHostTests already uses /// for LoginCommandSequence, proving the console reuses the EXACT /// same pipeline rather than a second parser. /// public sealed class HeadlessConsoleTests { // ── HeadlessConsoleOptions (typed option resolution) ───────────────── [Theory] [InlineData(true, "0", false, true)] // CLI flag always wins [InlineData(false, "1", false, true)] // env var wins over terminal default [InlineData(false, "0", true, true)] // env var "0" does not disable the terminal default [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.Console); Assert.Equal("bot.json", parsed.ConfigurationPath); } [Fact] public void CommandLineWithoutTheFlagDefaultsConsoleOff() { HeadlessCommandLine parsed = HeadlessCommandLine.Parse( ["run", "--config", "bot.json"]); Assert.False(parsed.Console); } // ── HeadlessConsoleInputReader: reader-thread/ordering ─────────────── /// /// The required reader-thread test: lines produced by the background /// thread are drained, in FIFO order, entirely on the CALLING thread. /// The reader thread itself never runs anything beyond /// ConcurrentQueue.Enqueue — there is no dispatch code it could /// execute — so this also structurally proves "never executed on the /// reader thread," not just orders the output. /// [Fact] public void LinesQueuedByTheReaderThreadDrainInOrderOnTheCallingThread() { using var input = new System.IO.StringReader( "one" + Environment.NewLine + "two" + Environment.NewLine + "three" + Environment.NewLine); using var reader = new HeadlessConsoleInputReader(input); Assert.True( reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)), "the reader thread never reached EOF"); int callingThread = Environment.CurrentManagedThreadId; var drained = new List(); while (reader.TryDequeue(out string line)) { drained.Add(line); // Proves the dequeue (and everything a caller does with the // line) runs on THIS thread, not the reader thread. Assert.Equal(callingThread, Environment.CurrentManagedThreadId); } Assert.Equal(["one", "two", "three"], drained); } // ── HeadlessConsoleController: /quit, /status, dispatch ordering ───── [Fact] public void ControllerDrainsEveryLineQueuedSinceTheLastTickInOrderOnOneCall() { // Simulates "input queued during a busy tick": every line is // enqueued by the reader thread before DrainDue is ever called — // one DrainDue call must still process all of them, in order. using var input = new System.IO.StringReader( "alpha" + Environment.NewLine + "beta" + Environment.NewLine + "gamma" + Environment.NewLine); var handled = new List(); using var quit = new CancellationTokenSource(); using var controller = new HeadlessConsoleController( input, TextWriter.Null, line => { handled.Add(line); return SubmitOutcome.Sent; }, () => string.Empty, quit); Assert.True( WaitForEndOfInput(controller), "the reader thread never reached EOF"); controller.DrainDue(); Assert.Equal(["alpha", "beta", "gamma"], handled); Assert.Equal(3, controller.LastDrainCount); // A second drain with nothing queued does nothing — proves DrainDue // does not re-process already-handled lines. controller.DrainDue(); Assert.Equal(["alpha", "beta", "gamma"], handled); Assert.Equal(0, controller.LastDrainCount); } [Fact] public void QuitRequestsCancellationAndNeverReachesSubmit() { using var input = new System.IO.StringReader("/quit" + Environment.NewLine); var submitted = new List(); var output = new StringWriter(); using var quit = new CancellationTokenSource(); using var controller = new HeadlessConsoleController( input, output, line => { submitted.Add(line); return SubmitOutcome.Sent; }, () => string.Empty, quit); Assert.True(WaitForEndOfInput(controller)); controller.DrainDue(); Assert.True(quit.IsCancellationRequested); Assert.Empty(submitted); Assert.Contains("quitting", output.ToString(), StringComparison.OrdinalIgnoreCase); } [Fact] public void StatusPrintsTheProvidedStatusTextAndNeverReachesSubmit() { using var input = new System.IO.StringReader("/status" + Environment.NewLine); var submitted = new List(); var output = new StringWriter(); using var quit = new CancellationTokenSource(); using var controller = new HeadlessConsoleController( input, output, line => { submitted.Add(line); return SubmitOutcome.Sent; }, () => "generation=1 position=unknown plugins=0 loaded", quit); Assert.True(WaitForEndOfInput(controller)); controller.DrainDue(); Assert.Empty(submitted); Assert.Contains( "generation=1 position=unknown plugins=0 loaded", output.ToString()); } // ── 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)); } // ── HeadlessSessionHost.SubmitConsoleLine: the real dispatch pipeline ─ [Fact] public void SlashSayProducesTheSameOutboundTalkActionTheGraphicalRouteSends() { var captured = new List(); var operations = new FixtureSessionOperations { GameActionCapture = body => captured.Add(body), }; using var credential = new HeadlessCredentialSecret("fixture", "password"); using var host = new HeadlessSessionHost( Descriptor(), credential, new HeadlessDiagnosticWriter(TextWriter.Null), operations); Assert.Equal( RuntimeSessionStartStatus.Connected, host.Start().Status); SubmitOutcome outcome = host.SubmitConsoleLine("/say hello", _ => { }); Assert.Equal(SubmitOutcome.Sent, outcome); byte[] body = Assert.Single(captured); Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); Assert.Equal("hello", TalkText(body)); } [Fact] public void PlainTextProducesTheSameOutboundTalkActionAsSlashSay() { var captured = new List(); var operations = new FixtureSessionOperations { GameActionCapture = body => captured.Add(body), }; using var credential = new HeadlessCredentialSecret("fixture", "password"); using var host = new HeadlessSessionHost( Descriptor(), credential, new HeadlessDiagnosticWriter(TextWriter.Null), operations); Assert.Equal( RuntimeSessionStartStatus.Connected, host.Start().Status); SubmitOutcome outcome = host.SubmitConsoleLine("hello", _ => { }); Assert.Equal(SubmitOutcome.Sent, outcome); byte[] body = Assert.Single(captured); Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body)); Assert.Equal("hello", TalkText(body)); } [Fact] public void PluginVerbReachesTheRegisteredPluginCommandWithoutTouchingTheWire() { var captured = new List(); var operations = new FixtureSessionOperations { GameActionCapture = body => captured.Add(body), }; using var credential = new HeadlessCredentialSecret("fixture", "password"); using var host = new HeadlessSessionHost( Descriptor(), credential, new HeadlessDiagnosticWriter(TextWriter.Null), operations); Assert.Equal( RuntimeSessionStartStatus.Connected, host.Start().Status); var received = new List(); using IDisposable registration = host.PluginCommands.Register( "vt", command => received.Add(command)); SubmitOutcome outcome = host.SubmitConsoleLine("/vt start", _ => { }); Assert.Equal(SubmitOutcome.ClientHandled, outcome); PluginCommand command = Assert.Single(received); Assert.Equal("vt", command.Verb); Assert.Equal("start", command.Arguments); Assert.Empty(captured); } [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); var interfaceText = new List(); // 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( "/", interfaceText.Add); Assert.Equal(SubmitOutcome.UnknownCommand, outcome); string text = Assert.Single(interfaceText); Assert.Contains("Unknown command:", text); } private static bool WaitForEndOfInput(HeadlessConsoleController controller) => controller.Reader.EndOfInput.Wait(TimeSpan.FromSeconds(5)); private static uint ActionOpcode(byte[] body) => BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, sizeof(uint))); private static string TalkText(byte[] body) { ushort length = BinaryPrimitives.ReadUInt16LittleEndian( body.AsSpan(12, sizeof(ushort))); return Encoding.ASCII.GetString(body, 14, length); } private static HeadlessSessionDescriptor Descriptor() => new() { Id = "console-bot", Endpoint = new HeadlessEndpointDescriptor { Host = "127.0.0.1", Port = 9000, }, Account = "account", Character = new HeadlessCharacterSelector { Name = "headless", }, Policy = new HeadlessBotPolicyDescriptor { Id = "idle", }, Credential = new HeadlessCredentialReference { Provider = HeadlessCredentialProviderKind.Environment, Reference = "CONSOLE_BOT_PASSWORD", }, }; private sealed class FixtureSessionOperations : ILiveSessionOperations { public Action? GameActionCapture { get; init; } public CharacterList.Parsed? Characters { get; init; } = new( 0u, [ new CharacterList.Character(0x50000001u, "Other", 0u), new CharacterList.Character(0x50000002u, "Headless", 0u), ], [], 11, "account", true, true); public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); public WorldSession CreateSession(IPEndPoint endpoint) { var session = new WorldSession(endpoint); session.GameActionCapture = GameActionCapture; return session; } public void Connect(WorldSession session, string user, string password) { } public CharacterList.Parsed? GetCharacters(WorldSession session) => Characters; public void EnterWorld(WorldSession session, int activeCharacterIndex) { } public void Tick(WorldSession session) { } public void DisposeSession(WorldSession session) => session.Dispose(); } }