diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index c904885e..ae9586e1 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -478,7 +478,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. |---|---|---|---|---| | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | | LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers | -| LA2 | — | | | | +| LA2 | implementation complete; automated gates **GREEN**; Opus dual-lens review pending | `c6019424` + completion (this commit) | pending | Probe is roster-before-selection with graceful pre-world teardown and exit 0; normal play remains strict selector + `idle` policy. Release build green; Runtime 1,632/1,632 and Headless 141/141 on both Windows and Ubuntu/WSL | | LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge | | LA4 | — | | | | | LA5 | — | | | | diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index fbdbdbe9..1ad3a913 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -167,9 +167,11 @@ internal sealed class HeadlessBotPolicyDescriptor /// The pinned launch-contract schema defines exactly two states for a /// session — ABSENT (mapped to , meaning "play") or /// the literal string "probe" — so is the only -/// member; there is no explicit "play" spelling. +/// member; there is no explicit "play" spelling. This deliberately uses +/// 's global camel-case, +/// string-only enum converter; a per-enum converter with its default options +/// would accidentally accept numeric 0 as a second probe spelling. /// -[JsonConverter(typeof(JsonStringEnumConverter))] internal enum HeadlessSessionMode { Probe, diff --git a/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs b/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs new file mode 100644 index 00000000..2ea2f73a --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs @@ -0,0 +1,77 @@ +using System.Reflection; +using AcDream.Headless.Policies; +using AcDream.Runtime; + +namespace AcDream.Headless.Tests; + +public sealed class HeadlessBotPolicyTests +{ + /// + /// Campaign LA slice LA2: idle is a deliberately passive, + /// non-terminal policy. It must neither inspect Runtime state nor reach + /// any command surface, and no event can make it complete on its own. + /// The process scheduler therefore keeps the play session alive until + /// external cancellation/stop drives the host's ordinary teardown path. + /// + [Fact] + public void IdlePolicyIsPassiveAndNeverCompletesAutonomously() + { + var policy = new IdleHeadlessBotPolicy(); + IGameRuntimeView view = CreateNoTouchProxy( + out InvocationCountingProxy viewCalls); + IGameRuntimeCommands commands = + CreateNoTouchProxy( + out InvocationCountingProxy commandCalls); + + for (int index = 0; index < 3; index++) + policy.Tick(view, commands); + + RuntimeLifecycleDelta lifecycle = default; + RuntimeCommandDelta command = default; + RuntimeEntityDelta entity = default; + RuntimeInventoryDelta inventory = default; + RuntimeChatDelta chat = default; + RuntimeMovementDelta movement = default; + RuntimePortalDelta portal = default; + RuntimeCombatDelta combat = default; + policy.OnLifecycle(in lifecycle); + policy.OnCommand(in command); + policy.OnEntity(in entity); + policy.OnInventory(in inventory); + policy.OnChat(in chat); + policy.OnMovement(in movement); + policy.OnPortal(in portal); + policy.OnCombat(in combat); + + Assert.False(policy.IsComplete); + Assert.Equal(0, viewCalls.InvocationCount); + Assert.Equal(0, commandCalls.InvocationCount); + + policy.Dispose(); + policy.Dispose(); + Assert.False(policy.IsComplete); + } + + private static T CreateNoTouchProxy( + out InvocationCountingProxy proxy) + where T : class + { + T value = DispatchProxy.Create(); + proxy = (InvocationCountingProxy)(object)value; + return value; + } + + public class InvocationCountingProxy : DispatchProxy + { + public int InvocationCount { get; private set; } + + protected override object? Invoke( + MethodInfo? targetMethod, + object?[]? args) + { + InvocationCount++; + throw new InvalidOperationException( + $"Idle policy unexpectedly invoked {targetMethod?.Name}."); + } + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs index 9c21273c..6902fccc 100644 --- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs @@ -237,6 +237,38 @@ public sealed class HeadlessConfigurationLoaderTests Assert.Null(session.Policy); } + /// + /// The pinned v1 contract has one named mode value: "probe". + /// In particular, the enum's underlying numeric zero must not become an + /// accidental second spelling through an enum converter configured to + /// allow integers. + /// + [Theory] + [InlineData("\"play\"")] + [InlineData("0")] + public void SessionModeRejectsEveryValueOtherThanTheNamedProbeMode( + string modeJson) + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + $$""" + { + "version": 1, + "sessions": [ + { + "id": "unsupported-mode", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": {{modeJson}}, + "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" } + } + ] + } + """); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + [Fact] public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 332a9933..f568feb2 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Collections.Immutable; +using System.Diagnostics; using System.Net; using System.Numerics; using System.Reflection; @@ -315,41 +316,122 @@ public sealed class HeadlessSessionHostTests Assert.False(playSession.IsFaulted); } + /// + /// Campaign LA slice LA2: the configured idle policy follows the + /// normal play shape (selector + policy, mode absent), enters world, and + /// remains non-terminal through real scheduler turns until cancellation. + /// Cancellation stops the process loop; the owning host's ordinary + /// disposal transaction then performs graceful session teardown. Status + /// events must describe those boundaries truthfully and remain exactly + /// once even when disposal is repeated. + /// [Fact] - public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() + public async Task IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce() { - var configuration = new HeadlessConfiguration + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl"); + try { - Version = 1, - Sessions = - [ - Descriptor( - HeadlessCredentialProviderKind.StandardInput, - "stdin-bot"), - ], - }; - HeadlessPathSet paths = HeadlessPathSet.Resolve( - new HeadlessPathOverrides()); - using var diagnostics = new StringWriter(); - var operations = new FixtureSessionOperations(); - using var host = new HeadlessProcessHost( - configuration, - paths, - new System.IO.StringReader( - "process-password" + Environment.NewLine), - diagnostics, - operations); - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + Descriptor( + HeadlessCredentialProviderKind.StandardInput, + "stdin-bot", + statusFile: statusPath), + ], + }; + HeadlessPathSet paths = HeadlessPathSet.Resolve( + new HeadlessPathOverrides()); + using var diagnostics = new StringWriter(); + var operations = new FixtureSessionOperations(); + using var host = new HeadlessProcessHost( + configuration, + paths, + new System.IO.StringReader( + "process-password" + Environment.NewLine), + diagnostics, + operations); + using var cancellation = new CancellationTokenSource(); - HeadlessExitCode result = - await host.RunAsync(cancellation.Token); + Task run = host.RunAsync(cancellation.Token); + var timeout = Stopwatch.StartNew(); + while (operations.TickCallCount < 3 + && !run.IsCompleted + && timeout.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(5); + } - Assert.Equal(HeadlessExitCode.Success, result); - Assert.True(host.Session.Runtime.Session.IsInWorld); - Assert.DoesNotContain( - "process-password", - diagnostics.ToString()); + Assert.True( + operations.TickCallCount >= 3, + $"Expected at least 3 idle scheduler turns, observed {operations.TickCallCount}."); + Assert.False(run.IsCompleted); + Assert.Equal(1, operations.EnterWorldCallCount); + Assert.Equal("Headless", host.Session.ActiveCharacterName); + Assert.True(host.Session.Runtime.Session.IsInWorld); + Assert.False(host.Session.IsPolicyComplete); + Assert.Equal( + ["started", "connected", "characterList", "enteredWorld"], + ReadStatusEventNames(statusPath)); + + cancellation.Cancel(); + HeadlessExitCode result = await run.WaitAsync( + TimeSpan.FromSeconds(10)); + + Assert.Equal(HeadlessExitCode.Success, result); + // RunAsync owns scheduling, not the host lifetime. The session + // remains honestly connected until its owner disposes it. + Assert.True(host.Session.Runtime.Session.IsInWorld); + Assert.Equal( + ["started", "connected", "characterList", "enteredWorld"], + ReadStatusEventNames(statusPath)); + + host.Dispose(); + host.Dispose(); + + Assert.True(host.Session.Runtime.CaptureOwnership().IsConverged); + Assert.Equal(1, operations.DisposedSessionCount); + string[] lines = File.ReadAllLines(statusPath); + string[] eventNames = ReadStatusEventNames(statusPath); + Assert.Equal( + [ + "started", "connected", "characterList", "enteredWorld", + "disconnected", "exited", + ], + eventNames); + + using JsonDocument disconnected = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "disconnected")]); + Assert.Equal( + "stopped", + disconnected.RootElement.GetProperty("reason").GetString()); + + using JsonDocument exited = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "exited")]); + JsonElement exit = exited.RootElement; + Assert.Equal(0, exit.GetProperty("code").GetInt32()); + string? exitReason = exit.GetProperty("reason").GetString(); + Assert.False(string.IsNullOrWhiteSpace(exitReason)); + Assert.NotEqual("fault", exitReason); + Assert.NotEqual("probe", exitReason); + Assert.DoesNotContain( + "process-password", + File.ReadAllText(statusPath), + StringComparison.Ordinal); + Assert.DoesNotContain( + "process-password", + diagnostics.ToString(), + StringComparison.Ordinal); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } } [Fact] @@ -2276,6 +2358,15 @@ public sealed class HeadlessSessionHostTests StatusFile = statusFile, }; + private static string[] ReadStatusEventNames(string path) => + File.ReadAllLines(path) + .Select(static line => + { + using JsonDocument document = JsonDocument.Parse(line); + return document.RootElement.GetProperty("e").GetString()!; + }) + .ToArray(); + private static void HydrateGroundedPlayer(GameRuntime runtime) { const uint player = 0x50000002u; @@ -2917,11 +3008,17 @@ public sealed class HeadlessSessionHostTests private sealed class FixtureSessionOperations : ILiveSessionOperations { + private int _enterWorldCallCount; + private int _tickCallCount; + public List Sessions { get; } = []; public int CreatedSessionCount { get; private set; } public int DisposedSessionCount { get; private set; } public string? LastUser { get; private set; } public string? LastPassword { get; private set; } + public int EnterWorldCallCount => + Volatile.Read(ref _enterWorldCallCount); + public int TickCallCount => Volatile.Read(ref _tickCallCount); public IPEndPoint ResolveEndpoint(string host, int port) => new(IPAddress.Loopback, port); @@ -2963,19 +3060,16 @@ public sealed class HeadlessSessionHostTests true, true); - /// Campaign LA slice LA2: lets a probe test assert the - /// live-session controller never reached EnterWorld. - public int EnterWorldCallCount { get; private set; } - public void EnterWorld( WorldSession session, int activeCharacterIndex) { - EnterWorldCallCount++; + Interlocked.Increment(ref _enterWorldCallCount); } public void Tick(WorldSession session) { + Interlocked.Increment(ref _tickCallCount); } public void DisposeSession(WorldSession session)