From c6019424675201b5c2ebbb7429d40c93d071bb03 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:33:00 +0200 Subject: [PATCH 1/3] =?UTF-8?q?wip:=20Campaign=20LA=20LA2=20probe=20mode?= =?UTF-8?q?=20+=20idle=20policy=20=E2=80=94=20INCOMPLETE,=20stopped=20mid-?= =?UTF-8?q?task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent was stopped for token budget. Landed here: probe flag through LiveSessionConnectOptions + the StartCore short-circuit, the mode field with JsonRequired-to-semantic-validation move, host exit-code mapping, and 34 passing tests including 3 new probe tests (agent last reported green before the stop). NOT DONE: the idle-policy unit tests (next step), full-suite verification, and the WSL run. Build/test state UNVERIFIED at this commit. Next session: finish idle policy tests, run Runtime+Headless Release suites Windows and WSL, then dispatch the Opus dual-lens review. Co-Authored-By: Claude Fable 5 --- .../Configuration/HeadlessConfiguration.cs | 43 +++- .../HeadlessConfigurationLoader.cs | 61 +++++- .../Hosting/HeadlessProcessHost.cs | 16 ++ .../Hosting/HeadlessSessionHost.cs | 60 ++++-- .../Policies/HeadlessBotPolicy.cs | 79 +++++++ src/AcDream.Runtime/GameRuntimeCommands.cs | 8 + .../Session/LiveSessionContracts.cs | 18 +- .../Session/LiveSessionController.cs | 26 +++ .../Session/LiveSessionHost.cs | 2 + .../HeadlessConfigurationLoaderTests.cs | 202 ++++++++++++++++++ .../HeadlessEntryPointTests.cs | 70 ++++++ .../HeadlessSessionHostTests.cs | 195 +++++++++++++++++ .../SessionConfigurationSharedFixtureTests.cs | 4 +- .../Session/LiveSessionControllerTests.cs | 81 ++++++- 14 files changed, 830 insertions(+), 35 deletions(-) diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 42fe7e67..fbdbdbe9 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -46,11 +46,33 @@ internal sealed record HeadlessSessionDescriptor [JsonRequired] public string Account { get; init; } = string.Empty; - [JsonRequired] - public HeadlessCharacterSelector Character { get; init; } = new(); + /// + /// Campaign LA slice LA2: ABSENT () for normal play + /// sessions; for the LA2 probe + /// (connect → characterList → graceful disconnect, never EnterWorld) — + /// the pinned launch-contract schema's mode field + /// (docs/plans/2026-08-14-launcher-campaign.md LA1/LA2). + /// / requiredness depends on + /// this value, which is why their requiredness lives in + /// 's semantic validation rather + /// than a [JsonRequired] attribute — that attribute fires during + /// deserialization, before can be inspected at all. + /// + public HeadlessSessionMode? Mode { get; init; } - [JsonRequired] - public HeadlessBotPolicyDescriptor Policy { get; init; } = new(); + /// + /// Required for play sessions ( absent); MUST be + /// omitted for probe sessions () — + /// the pinned contract keeps the shape unambiguous by forbidding a probe + /// session from also declaring a selector. Enforced by + /// , not + /// [JsonRequired] (see this record's own doc on ). + /// + public HeadlessCharacterSelector? Character { get; init; } + + /// Same mode-dependent requiredness as : + /// required for play sessions, forbidden for probe sessions. + public HeadlessBotPolicyDescriptor? Policy { get; init; } [JsonRequired] public HeadlessCredentialReference Credential { get; init; } = new(); @@ -140,6 +162,19 @@ internal sealed class HeadlessBotPolicyDescriptor public HeadlessBotPolicyRole? Role { get; init; } } +/// +/// Campaign LA slice LA2: see . +/// 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. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum HeadlessSessionMode +{ + Probe, +} + /// See . [JsonConverter(typeof(JsonStringEnumConverter))] internal enum HeadlessBotPolicyRole diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 016b79c2..4c83699c 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -182,6 +182,57 @@ internal static class HeadlessConfigurationLoader $"Session '{session.Id}' requires a non-empty account."); } + ValidateModeShape(session); + + if (session.Credential is null + || string.IsNullOrWhiteSpace(session.Credential.Reference)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' requires a credential reference."); + } + + ValidateCharacterOptions(session); + ValidateLaunchContractFields(session); + } + + /// + /// Campaign LA slice LA2: mode-dependent requiredness for + /// / + /// — this REPLACES the + /// former `[JsonRequired]` attributes on both properties (which fired + /// unconditionally at deserialize time, before a probe session's + /// omission could ever be distinguished from a play session's mistake). + /// A play session (mode absent) keeps EXACTLY today's strictness: a + /// missing/malformed character selector or a missing policy id still + /// fails load, just via + /// naming the field instead of a raw citing + /// "missing required properties" — same exit code (3, + /// HeadlessExitCode.ConfigurationError) either way, more specific + /// text now (an accepted improvement, not a contract change). A probe + /// session (mode "probe") must OMIT both fields entirely — the pinned + /// contract keeps the shape unambiguous by rejecting a probe session + /// that also declares a selector or a policy, rather than silently + /// ignoring them. + /// + private static void ValidateModeShape(HeadlessSessionDescriptor session) + { + if (session.Mode == HeadlessSessionMode.Probe) + { + if (session.Character is not null) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' has mode \"probe\" and must omit " + + "'character' — a probe never selects a character."); + } + if (session.Policy is not null) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' has mode \"probe\" and must omit " + + "'policy' — a probe never drives a bot policy."); + } + return; + } + if (session.Character is null) { throw new HeadlessConfigurationException( @@ -206,16 +257,6 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( $"Session '{session.Id}' requires a non-empty policy id."); } - - if (session.Credential is null - || string.IsNullOrWhiteSpace(session.Credential.Reference)) - { - throw new HeadlessConfigurationException( - $"Session '{session.Id}' requires a credential reference."); - } - - ValidateCharacterOptions(session); - ValidateLaunchContractFields(session); } /// diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 4d4d1b83..5d38edb0 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -200,6 +200,22 @@ internal sealed class HeadlessProcessHost : IDisposable foreach (HeadlessSessionHost session in _sessions) { RuntimeSessionStartResult started = session.Start(); + // Campaign LA slice LA2: ProbeComplete is a SUCCESS variant, not + // a connection failure — the session already connected, reported + // its roster, and gracefully disconnected before EnterWorld (see + // LiveSessionController's probe short-circuit). Continue to the + // next configured session instead of returning ConnectionError, + // so a probe session sharing a process with play sessions never + // tears the others down. ProbeHeadlessBotPolicy already reports + // IsComplete, so the scheduler below skips this session entirely. + if (started.Status == RuntimeSessionStartStatus.ProbeComplete) + { + _diagnostics.Lifecycle( + session.SessionId, + "probed", + session.Runtime); + continue; + } if (started.Status != RuntimeSessionStartStatus.Connected) { if (started.Error is { } error) diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index ea3a56d4..0a17e8f5 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -319,7 +319,7 @@ internal sealed class HeadlessSessionHost : IDisposable // doc). Gating on role keeps two sessions // writing the SAME field from ever racing — // only one role ever writes it. - if (descriptor.Policy.Role + if (descriptor.Policy?.Role == HeadlessBotPolicyRole.Recruit && gateCoordinator is not null) { @@ -366,13 +366,24 @@ internal sealed class HeadlessSessionHost : IDisposable hostLease = runtime.AcquireHostLease( $"headless:{descriptor.Id}"); + // Campaign LA slice LA2: a probe session's descriptor carries no + // `policy` at all (the loader rejects the opposite pairing) — a + // probe never reaches TrySelectCharacter/EnterWorld, so there is + // no policy id to switch on. ProbeHeadlessBotPolicy reports + // IsComplete unconditionally so HeadlessProcessScheduler treats + // this session as already finished the instant it is + // constructed, letting the scheduler's Run() loop return + // immediately for a probe-only process instead of waiting for + // SIGINT. policy = policyOverride - ?? HeadlessBotPolicyFactory.Create( - descriptor.Policy, - runtime, - () => _pendingConfirmation, - RespondToConfirmation, - gateCoordinator); + ?? (descriptor.Mode == HeadlessSessionMode.Probe + ? new ProbeHeadlessBotPolicy() + : HeadlessBotPolicyFactory.Create( + descriptor.Policy!, + runtime, + () => _pendingConfirmation, + RespondToConfirmation, + gateCoordinator)); policySubscription = runtime.Subscribe(policy); diagnostics.Lifecycle( descriptor.Id, @@ -636,11 +647,18 @@ internal sealed class HeadlessSessionHost : IDisposable _stoppedGeneration); // Campaign LA slice LA1: "exited" = terminal — the sole // point every disposal path (graceful and post- - // quarantine) converges on. + // quarantine) converges on. LA2: a probe session that + // never faulted reports reason "probe" here instead of + // "disposed" — the pinned contract's exit event for a + // successful probe. _statusWriter.Exited( _descriptor.Id, _faulted ? 1 : 0, - _faulted ? "fault" : "disposed"); + _faulted + ? "fault" + : _descriptor.Mode == HeadlessSessionMode.Probe + ? "probe" + : "disposed"); _disposeStage++; _disposed = true; break; @@ -717,7 +735,8 @@ internal sealed class HeadlessSessionHost : IDisposable _descriptor.Endpoint.Port, _descriptor.Account, password, - MapCharacterSelector(_descriptor.Character)); + MapCharacterSelector(_descriptor.Character), + Probe: _descriptor.Mode == HeadlessSessionMode.Probe); LiveSessionStartResult result = _liveSession.Start(options); if (result.Selection is { } selection) _accountName = selection.AccountName; @@ -964,12 +983,19 @@ internal sealed class HeadlessSessionHost : IDisposable return declared; } - private static LiveSessionCharacterSelector MapCharacterSelector( - HeadlessCharacterSelector selector) => - new( - selector.Index, - selector.Id, - selector.Name); + /// Campaign LA slice LA2: for a probe + /// session (the loader guarantees Character is omitted whenever + /// Mode is ) — a probe + /// never reaches TrySelectCharacter, so "no selector configured" + /// is the correct, harmless mapping. + private static LiveSessionCharacterSelector? MapCharacterSelector( + HeadlessCharacterSelector? selector) => + selector is null + ? null + : new( + selector.Index, + selector.Id, + selector.Name); private RuntimeSessionStartResult Convert( LiveSessionStartResult result) @@ -988,6 +1014,8 @@ internal sealed class HeadlessSessionHost : IDisposable RuntimeSessionStartStatus.Deferred, LiveSessionStartStatus.Failed => RuntimeSessionStartStatus.Failed, + LiveSessionStartStatus.ProbeComplete => + RuntimeSessionStartStatus.ProbeComplete, _ => throw new ArgumentOutOfRangeException( nameof(result), result.Status, diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs index 90ee240c..f35c16a4 100644 --- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -100,6 +100,21 @@ internal static class HeadlessBotPolicyFactory } } +/// +/// Campaign LA slice LA2: the "idle" consumer policy id — the session enters +/// world (unchanged start/select/EnterWorld +/// path) and then does nothing actively: no chat, no movement, no combat. +/// is permanently , so +/// keeps ticking the session +/// (harmlessly — and every delta handler below are no-ops) +/// until the process is stopped (SIGINT/cancellation) or disposed; teardown +/// then rides 's existing graceful +/// stop/logout path — the same mechanism K4's endurance gate already proved. +/// No is required. This class +/// predates LA2 (introduced at K1 as dev/test scaffolding); LA2 formalizes it +/// as the documented headless "just sit in world" play policy and adds +/// focused coverage in HeadlessBotPolicyTests. +/// internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy { public bool IsComplete => false; @@ -149,6 +164,70 @@ internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy } } +/// +/// Campaign LA slice LA2: the policy substituted (never selected via +/// — a probe session's +/// descriptor carries no policy id at all) for a +/// session. +/// is from construction, BEFORE +/// even runs, so +/// never dispatches a tick to this +/// session — a probe session's +/// is already gracefully torn down by +/// 's probe +/// short-circuit by the time the scheduler would otherwise look at it, and a +/// single-session probe process's Run() loop returns immediately +/// instead of waiting for SIGINT. +/// +internal sealed class ProbeHeadlessBotPolicy : IHeadlessBotPolicy +{ + public bool IsComplete => true; + + public void Tick( + IGameRuntimeView view, + IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + public void Dispose() + { + } +} + /// /// Explicit connected-gate policy: wait for the local player, issue one /// harmless local-speech command and one lifestone recall, reconnect after diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index f20db0f2..db177ed1 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -27,6 +27,14 @@ public enum RuntimeSessionStartStatus Failed, Inactive, StaleGeneration, + /// + /// Campaign LA slice LA2: mirrors + /// — a probe + /// session connected, reported its roster, and gracefully disconnected + /// before EnterWorld. A SUCCESS outcome for the headless process host's + /// exit-code mapping, not a failure. + /// + ProbeComplete, } public readonly record struct RuntimeSessionStartResult( diff --git a/src/AcDream.Runtime/Session/LiveSessionContracts.cs b/src/AcDream.Runtime/Session/LiveSessionContracts.cs index 49ca27d2..e8f81d4b 100644 --- a/src/AcDream.Runtime/Session/LiveSessionContracts.cs +++ b/src/AcDream.Runtime/Session/LiveSessionContracts.cs @@ -13,7 +13,23 @@ public sealed record LiveSessionConnectOptions( int Port, string User, string Password, - LiveSessionCharacterSelector? Character = null); + LiveSessionCharacterSelector? Character = null, + /// + /// Campaign LA slice LA2: short-circuits + /// 's connect transaction right after + /// the roster report (before TrySelectCharacter/ + /// ApplySelectedCharacter/EnterWorld) — connect, receive + /// CharacterList, report the roster, gracefully disconnect via the + /// same StopCore teardown the + /// path already uses, and return + /// . The pinned launch + /// contract (docs/plans/2026-08-14-launcher-campaign.md LA1's + /// mode field) requires a probe session to omit both + /// and its policy entirely, but the controller + /// itself does not enforce that pairing — the headless config loader + /// does, before a is ever built. + /// + bool Probe = false); public interface IRuntimeLiveSessionFramePhase { diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 1ecd5d00..ee4fdfbb 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -13,6 +13,14 @@ public enum LiveSessionStartStatus Connected, Deferred, Failed, + /// + /// Campaign LA slice LA2: a + /// session connected, received (and reported) the character roster, and + /// gracefully disconnected BEFORE selection/EnterWorld — deliberately a + /// SUCCESS variant of the early-exit shape + /// (same StopCore teardown), not a failure. + /// + ProbeComplete, } public readonly record struct LiveSessionOwnershipSnapshot( @@ -647,6 +655,24 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } + // Campaign LA slice LA2: the probe short-circuit lands here — + // right after the roster report, before TrySelectCharacter ever + // runs — so a probe session never reaches selection, + // ApplySelectedCharacter, or EnterWorld. This mirrors the + // NoCharacters early-exit immediately below (same StopCore + // teardown), the deliberate difference being the returned status + // is a SUCCESS, not a failure. Non-probe callers (options.Probe + // is false by default) fall straight through to the unchanged + // selection/enter path below — byte-identical to pre-LA2 + // behavior. + if (options.Probe) + { + Console.WriteLine( + "live: probe complete — disconnecting before EnterWorld"); + StopCore(); + return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete); + } + if (characters is null || !TrySelectCharacter( characters, diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 95e8f553..5ceb04e2 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -316,6 +316,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands RuntimeSessionStartStatus.Deferred, LiveSessionStartStatus.Failed => RuntimeSessionStartStatus.Failed, + LiveSessionStartStatus.ProbeComplete => + RuntimeSessionStartStatus.ProbeComplete, _ => throw new ArgumentOutOfRangeException( nameof(result), result.Status, diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs index 56d16c33..9c21273c 100644 --- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs @@ -208,6 +208,208 @@ public sealed class HeadlessConfigurationLoaderTests Assert.Empty(declared!); } + // ── Campaign LA slice LA2: probe-mode `mode` field shape validation ── + + [Fact] + public void ProbeSessionOmittingCharacterAndPolicyLoads() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "probe-session", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": "probe", + "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" } + } + ] + } + """); + + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(file.Path); + + HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!; + Assert.Null(session.Character); + Assert.Null(session.Policy); + } + + [Fact] + public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "probe-with-character", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": "probe", + "character": { "index": 0 }, + "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains("probe", exception.Message, StringComparison.Ordinal); + Assert.Contains("character", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void ProbeSessionDeclaringPolicyFailsLoadNamingTheField() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "probe-with-policy", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": "probe", + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains("probe", exception.Message, StringComparison.Ordinal); + Assert.Contains("policy", exception.Message, StringComparison.Ordinal); + } + + /// + /// A play session (mode absent) missing `character` must still fail — + /// the LA2 change moved this requiredness from `[JsonRequired]` (a raw + /// at deserialize time) to + /// 's semantic + /// check (a naming the + /// missing field). Exit-code parity (both map to + /// HeadlessExitCode.ConfigurationError) is proven at + /// HeadlessEntryPointTests; this test pins the loader-level + /// exception type/message. + /// + [Fact] + public void PlaySessionMissingCharacterStillFailsLoad() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "play-missing-character", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "PLAY_PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains( + "requires a character selector", + exception.Message, + StringComparison.Ordinal); + } + + /// Same parity claim as + /// for the + /// `policy` field. + [Fact] + public void PlaySessionMissingPolicyStillFailsLoad() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "play-missing-policy", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0 }, + "credential": { "provider": "environment", "reference": "PLAY_PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains( + "requires a non-empty policy id", + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void PlaySessionKeepsTodaysStrictCharacterSelectorAndPolicyValidation() + { + // Unrelated to `mode` — proves the LA2 refactor of ValidateSession + // did not loosen the existing selector-shape/policy-id checks for + // ordinary play sessions (mode absent). + using TemporaryConfiguration badSelector = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "bad-selector", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0, "name": "Two" }, + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "A" } + } + ] + } + """); + using TemporaryConfiguration blankPolicy = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "blank-policy", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "character": { "index": 0 }, + "policy": { "id": "" }, + "credential": { "provider": "environment", "reference": "B" } + } + ] + } + """); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(badSelector.Path)); + Assert.Throws( + () => HeadlessConfigurationLoader.Load(blankPolicy.Path)); + } + private static string ConfigurationWith(params string[] sessions) => $$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}"""; diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs index 02ffca6e..41dcdf16 100644 --- a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs @@ -191,6 +191,76 @@ public sealed class HeadlessEntryPointTests Assert.Contains(expected, error.ToString()); } + /// + /// Campaign LA slice LA2: before this change, an omitted `character` or + /// `policy` field failed deserialization itself with a raw + /// ("missing required + /// properties") — this test pins that the LA2 move to semantic + /// validation ( + /// naming the exact missing field) preserves the SAME exit code + /// (3, HeadlessExitCode.ConfigurationError) end to end through + /// . + /// The message text change (generic → field-naming) is a deliberate, + /// accepted improvement, not a contract break. + /// + [Theory] + [InlineData( + """ + {"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","policy":{"id":"idle"},"credential":{"provider":"environment","reference":"X"}}]} + """, + "character selector")] + [InlineData( + """ + {"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","character":{"index":0},"credential":{"provider":"environment","reference":"X"}}]} + """, + "policy id")] + public void PlaySessionMissingCharacterOrPolicyKeepsConfigurationErrorExitCode( + string json, + string expectedMessageFragment) + { + using var file = TemporaryConfiguration.Create(json); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = HeadlessEntryPoint.Run( + ["validate", "--config", file.Path], + output, + error); + + Assert.Equal((int)HeadlessExitCode.ConfigurationError, exitCode); + Assert.Contains( + expectedMessageFragment, + error.ToString(), + StringComparison.OrdinalIgnoreCase); + Assert.Equal(string.Empty, output.ToString()); + } + + /// + /// Campaign LA slice LA2: a valid probe-mode session (mode "probe", + /// character/policy both omitted) passes `validate` — the launcher's + /// "refresh characters" flow only needs the process to accept the + /// document, not to run it. + /// + [Fact] + public void ValidateAcceptsProbeSessionOmittingCharacterAndPolicy() + { + using var file = TemporaryConfiguration.Create( + """ + {"version":1,"sessions":[{"id":"probe","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","mode":"probe","credential":{"provider":"environment","reference":"X"}}]} + """); + using var output = new StringWriter(); + using var error = new StringWriter(); + + int exitCode = HeadlessEntryPoint.Run( + ["validate", "--config", file.Path], + output, + error); + + Assert.Equal((int)HeadlessExitCode.Success, exitCode); + Assert.Contains("1 session(s)", output.ToString()); + Assert.Equal(string.Empty, error.ToString()); + } + [Fact] public void UnknownCommandReturnsUsageError() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 5c9871f5..332a9933 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -151,6 +151,170 @@ public sealed class HeadlessSessionHostTests // writer is a permanent no-op with no configured path. } + /// + /// Campaign LA slice LA2: a probe-mode session's status stream reports + /// started/connected/characterList and then converges straight to + /// exited(reason:"probe", code:0) — never enteredWorld — and the + /// underlying operations fake proves EnterWorld was literally never + /// called (not merely that no wire message happened to arrive). + /// + [Fact] + public void ProbeSessionEmitsRosterThenExitsSuccessfullyWithoutEnteringWorld() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-probe-status-{Guid.NewGuid():N}.jsonl"); + try + { + var operations = new FixtureSessionOperations(); + using var diagnosticsOutput = new StringWriter(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + ProbeDescriptor(statusFile: statusPath), + credential, + new HeadlessDiagnosticWriter(diagnosticsOutput), + operations); + + RuntimeSessionStartResult started = host.Start(); + Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, started.Status); + Assert.Equal(0, operations.EnterWorldCallCount); + Assert.False(host.Runtime.Session.IsInWorld); + + host.Dispose(); + + Assert.Equal(0, operations.EnterWorldCallCount); + Assert.True(host.Runtime.CaptureOwnership().IsConverged); + + string[] lines = File.ReadAllLines(statusPath); + string[] eventNames = lines + .Select(line => JsonDocument.Parse(line) + .RootElement.GetProperty("e").GetString()!) + .ToArray(); + Assert.DoesNotContain("enteredWorld", eventNames); + Assert.Contains("characterList", eventNames); + Assert.Contains("exited", eventNames); + Assert.True( + Array.IndexOf(eventNames, "characterList") + < Array.IndexOf(eventNames, "exited"), + "characterList must land before the terminal exited event."); + + using JsonDocument exitedDoc = JsonDocument.Parse( + lines[Array.IndexOf(eventNames, "exited")]); + Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32()); + Assert.Equal( + "probe", + exitedDoc.RootElement.GetProperty("reason").GetString()); + + string contents = File.ReadAllText(statusPath); + Assert.DoesNotContain("password", contents, StringComparison.Ordinal); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } + } + + /// + /// Campaign LA slice LA2: + /// maps a ProbeComplete start to + /// (0) rather than — a + /// single-session probe-only process must exit cleanly and promptly + /// without ever needing SIGINT/cancellation, because + /// ProbeHeadlessBotPolicy reports IsComplete immediately. + /// + [Fact] + public async Task ProcessHostMapsProbeCompleteStartToSuccessExitCode() + { + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + ProbeDescriptor( + provider: HeadlessCredentialProviderKind.StandardInput, + credentialReference: "probe-password"), + ], + }; + 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("probe-password" + Environment.NewLine), + diagnostics, + operations); + // Deliberately NOT cancelled — a probe-only process must return on + // its own; a hang here would mean the scheduler never recognized + // the probe session as already complete. + using var cancellation = new CancellationTokenSource( + TimeSpan.FromSeconds(10)); + + HeadlessExitCode result = await host.RunAsync(cancellation.Token); + + Assert.Equal(HeadlessExitCode.Success, result); + Assert.Equal(0, operations.EnterWorldCallCount); + Assert.False(cancellation.IsCancellationRequested); + } + + /// + /// Campaign LA slice LA2: a probe session completing must not tear down + /// a sibling play session sharing the same process — the process exit + /// code is 0 only once every configured session has succeeded (the + /// probe counts as success the instant it completes; the play session + /// keeps running until cancellation). + /// + [Fact] + public async Task ProbeSessionSharingAProcessDoesNotTearDownASiblingPlaySession() + { + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + ProbeDescriptor( + "probe-sibling", + provider: HeadlessCredentialProviderKind.StandardInput, + credentialReference: "probe-password"), + Descriptor( + HeadlessCredentialProviderKind.StandardInput, + "play-password"), + ], + }; + 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( + "probe-password" + Environment.NewLine + + "play-password" + Environment.NewLine), + diagnostics, + operations); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + HeadlessExitCode result = await host.RunAsync(cancellation.Token); + + Assert.Equal(HeadlessExitCode.Success, result); + Assert.Equal(2, host.Sessions.Count); + HeadlessSessionHost probeSession = Assert.Single( + host.Sessions, + s => s.SessionId == "probe-sibling"); + HeadlessSessionHost playSession = Assert.Single( + host.Sessions, + s => s.SessionId == "bot"); + Assert.False(probeSession.Runtime.Session.IsInWorld); + Assert.True(playSession.Runtime.Session.IsInWorld); + Assert.False(playSession.IsFaulted); + } + [Fact] public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() { @@ -2086,6 +2250,32 @@ public sealed class HeadlessSessionHostTests StatusFile = statusFile, }; + /// Campaign LA slice LA2: a probe-mode descriptor — mode + /// "probe", Character/Policy both omitted per the pinned + /// contract shape enforces. + private static HeadlessSessionDescriptor ProbeDescriptor( + string id = "probe-bot", + HeadlessCredentialProviderKind provider = + HeadlessCredentialProviderKind.Environment, + string credentialReference = "PROBE_PASSWORD", + string? statusFile = null) => new() + { + Id = id, + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Mode = HeadlessSessionMode.Probe, + Credential = new HeadlessCredentialReference + { + Provider = provider, + Reference = credentialReference, + }, + StatusFile = statusFile, + }; + private static void HydrateGroundedPlayer(GameRuntime runtime) { const uint player = 0x50000002u; @@ -2773,10 +2963,15 @@ 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++; } public void Tick(WorldSession session) diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs index 631a6c1d..3db166b6 100644 --- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -28,8 +28,8 @@ public sealed class SessionConfigurationSharedFixtureTests Assert.Equal("127.0.0.1", session.Endpoint.Host); Assert.Equal(9000, session.Endpoint.Port); Assert.Equal("sharedaccount", session.Account); - Assert.Equal("SharedToon", session.Character.Name); - Assert.Equal("idle", session.Policy.Id); + Assert.Equal("SharedToon", session.Character!.Name); + Assert.Equal("idle", session.Policy!.Id); Assert.Equal( HeadlessCredentialProviderKind.Environment, session.Credential.Provider); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 6a3f49bf..b4ff6a25 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -389,6 +389,81 @@ public sealed class LiveSessionControllerTests Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); } + /// + /// Campaign LA slice LA2: the probe short-circuit — connect, receive + /// CharacterList, report the roster, then gracefully disconnect via the + /// SAME StopCore teardown + /// exercises, returning + /// instead of ever reaching TrySelectCharacter/ApplySelectedCharacter/ + /// EnterWorld. Asserted directly against the operations fake: + /// stays zero and "enter:*"/ + /// "selected"/"activate"/"entered" never appear in the call trace. + /// + [Fact] + public void Start_ProbeReportsRosterThenGracefullyDisconnectsWithoutSelectionOrEnterWorld() + { + var calls = new List(); + var operations = new TestOperations(calls); + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start( + LiveOptions(probe: true), + host); + + Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status); + Assert.Null(result.Selection); + Assert.Equal( + [ + "reset", "resolve", "create", "bind", "report-connecting", + "connect", "report-connected", "roster", "deactivate", + "detach-events", "dispose-session", "detach-session", "reset", + ], + calls); + Assert.DoesNotContain("selected", calls); + Assert.DoesNotContain("activate", calls); + Assert.DoesNotContain("entered", calls); + Assert.Equal(0, operations.EnterWorldCount); + LiveSessionRosterReport roster = Assert.Single(host.Rosters); + Assert.Equal("Canonical", roster.AccountName); + Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + + // The same 4-stage graceful teardown the NoCharacters path uses — + // the probe's scope fully converges without requiring + // controller.Dispose(). + LiveSessionOwnershipSnapshot ownership = controller.CaptureOwnership(); + Assert.Equal(RuntimeTeardownStage.Complete, ownership.LastTeardownStages); + Assert.False(ownership.HasActiveSession); + Assert.False(ownership.HasRetiredSession); + Assert.False(ownership.HasPendingOperation); + } + + /// + /// The probe short-circuit fires even when the server never returns a + /// CharacterList at all (GetCharacters returns null) — a probe is a + /// connectivity check, not itself a character-selection operation, so it + /// must not fall through to the NoCharacters path. + /// + [Fact] + public void Start_ProbeWithoutCharacterListStillCompletesGracefully() + { + var calls = new List(); + var operations = new TestOperations(calls) { Characters = null }; + var host = new TestHost(calls); + var controller = new LiveSessionController(operations); + + LiveSessionStartResult result = controller.Start( + LiveOptions(probe: true), + host); + + Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status); + Assert.Empty(host.Rosters); + Assert.Equal(0, operations.EnterWorldCount); + Assert.False(controller.IsInWorld); + } + [Theory] [InlineData("index")] [InlineData("id")] @@ -1141,14 +1216,16 @@ public sealed class LiveSessionControllerTests private static LiveSessionConnectOptions LiveOptions( bool live = true, string? user = "user", - LiveSessionCharacterSelector? selector = null) => + LiveSessionCharacterSelector? selector = null, + bool probe = false) => new( live, "127.0.0.1", 9000, user ?? string.Empty, "password", - selector); + selector, + probe); private static CharacterList.Parsed AvailableCharacters() => new( 0u, From 000ea979d5219c21beef06ac8fcd65e510ad71d2 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:49:51 +0200 Subject: [PATCH 2/3] test: Campaign LA finish LA2 probe and idle gates Prove idle play remains passive and live until cancellation, then converges through one truthful status teardown. Keep probe mode string-only so numeric enum aliases cannot expand the pinned v1 contract, and record the Windows/WSL gates. --- docs/plans/2026-08-14-launcher-campaign.md | 2 +- .../Configuration/HeadlessConfiguration.cs | 6 +- .../HeadlessBotPolicyTests.cs | 77 ++++++++ .../HeadlessConfigurationLoaderTests.cs | 32 ++++ .../HeadlessSessionHostTests.cs | 164 ++++++++++++++---- 5 files changed, 243 insertions(+), 38 deletions(-) create mode 100644 tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs 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) From 1c5e66c05b73e9e0e41ae7a5549a568ba374c6e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 17:20:02 +0200 Subject: [PATCH 3/3] fix(launcher): Campaign LA close LA2 review findings --- docs/plans/2026-08-14-launcher-campaign.md | 2 +- .../Configuration/HeadlessConfiguration.cs | 5 +- .../HeadlessConfigurationLoader.cs | 78 ++++++++++-- .../Hosting/HeadlessSessionHost.cs | 59 +++++++-- .../Session/LiveSessionController.cs | 17 ++- .../HeadlessConfigurationLoaderTests.cs | 99 +++++++++++++++ .../HeadlessSessionHostTests.cs | 117 +++++++++++++++--- .../Session/LiveSessionControllerTests.cs | 27 +++- 8 files changed, 346 insertions(+), 58 deletions(-) diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index ae9586e1..264a209b 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 | 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 | +| LA2 | review fix round complete; automated gates **GREEN**; narrow re-review pending | `c6019424`, `000ea979` + review fixes (this commit) | Opus dual-lens FIX FIRST; all 3 findings fixed, narrow re-review pending | ProbeComplete now requires a reported roster and is still before selection/EnterWorld; terminal status derives from the actual start outcome and matches failed-start process code 5; conditional config fields distinguish omission from explicit null without weakening strict JSON shape/type checks. Release build green on Windows and Ubuntu/WSL; Runtime 1,632/1,632 and Headless 149/149 on both | | 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 1ad3a913..fdae1225 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -47,8 +47,9 @@ internal sealed record HeadlessSessionDescriptor public string Account { get; init; } = string.Empty; /// - /// Campaign LA slice LA2: ABSENT () for normal play - /// sessions; for the LA2 probe + /// Campaign LA slice LA2: the JSON field is ABSENT for normal play + /// sessions (explicit JSON null is invalid); + /// for the LA2 probe /// (connect → characterList → graceful disconnect, never EnterWorld) — /// the pinned launch-contract schema's mode field /// (docs/plans/2026-08-14-launcher-campaign.md LA1/LA2). diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 4c83699c..27686c1e 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -97,10 +97,15 @@ internal static class HeadlessConfigurationLoader string fullPath = Path.GetFullPath(path); using FileStream stream = File.OpenRead(fullPath); + using JsonDocument document = JsonDocument.Parse( + stream, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + }); HeadlessConfiguration? configuration = - JsonSerializer.Deserialize( - stream, - Options); + document.RootElement.Deserialize(Options); if (configuration is null) { @@ -123,9 +128,12 @@ internal static class HeadlessConfigurationLoader ValidateContent(configuration.Process?.Content); + JsonElement sessionsElement = + document.RootElement.GetProperty("sessions"); var sessionIds = new HashSet(StringComparer.Ordinal); var credentialReferences = new HashSet( StringComparer.Ordinal); + int sessionIndex = 0; foreach (HeadlessSessionDescriptor? session in configuration.Sessions) { if (session is null @@ -141,7 +149,7 @@ internal static class HeadlessConfigurationLoader $"Duplicate session id '{session.Id}'."); } - ValidateSession(session); + ValidateSession(session, sessionsElement[sessionIndex]); string credentialKey = $"{session.Credential.Provider}:{session.Credential.Reference}"; if (!credentialReferences.Add(credentialKey)) @@ -149,6 +157,7 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( $"Credential reference for session '{session.Id}' is already in use."); } + sessionIndex++; } return configuration; @@ -166,7 +175,9 @@ internal static class HeadlessConfigurationLoader } } - private static void ValidateSession(HeadlessSessionDescriptor session) + private static void ValidateSession( + HeadlessSessionDescriptor session, + JsonElement sessionElement) { if (session.Endpoint is null || string.IsNullOrWhiteSpace(session.Endpoint.Host) @@ -182,7 +193,7 @@ internal static class HeadlessConfigurationLoader $"Session '{session.Id}' requires a non-empty account."); } - ValidateModeShape(session); + ValidateModeShape(session, sessionElement); if (session.Credential is null || string.IsNullOrWhiteSpace(session.Credential.Reference)) @@ -214,17 +225,37 @@ internal static class HeadlessConfigurationLoader /// that also declares a selector or a policy, rather than silently /// ignoring them. /// - private static void ValidateModeShape(HeadlessSessionDescriptor session) + private static void ValidateModeShape( + HeadlessSessionDescriptor session, + JsonElement sessionElement) { + bool hasMode = sessionElement.TryGetProperty( + "mode", + out JsonElement modeElement); + bool hasCharacter = sessionElement.TryGetProperty( + "character", + out JsonElement characterElement); + bool hasPolicy = sessionElement.TryGetProperty( + "policy", + out JsonElement policyElement); + + RejectExplicitNull(session.Id, "mode", hasMode, modeElement); + RejectExplicitNull( + session.Id, + "character", + hasCharacter, + characterElement); + RejectExplicitNull(session.Id, "policy", hasPolicy, policyElement); + if (session.Mode == HeadlessSessionMode.Probe) { - if (session.Character is not null) + if (hasCharacter) { throw new HeadlessConfigurationException( $"Session '{session.Id}' has mode \"probe\" and must omit " + "'character' — a probe never selects a character."); } - if (session.Policy is not null) + if (hasPolicy) { throw new HeadlessConfigurationException( $"Session '{session.Id}' has mode \"probe\" and must omit " @@ -233,6 +264,12 @@ internal static class HeadlessConfigurationLoader return; } + if (hasMode) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' is normal play and must omit 'mode'."); + } + if (session.Character is null) { throw new HeadlessConfigurationException( @@ -259,6 +296,29 @@ internal static class HeadlessConfigurationLoader } } + /// + /// Campaign LA LA2 review fix: the pinned launch contract distinguishes + /// an omitted conditional field from a field explicitly authored as JSON + /// null. Nullable CLR properties cannot retain that distinction, so + /// validation also consumes the already-parsed strict JSON shape. The + /// typed serializer still owns unknown-member, enum, and value-type + /// enforcement; this check adds presence semantics without weakening any + /// of those gates. + /// + private static void RejectExplicitNull( + string sessionId, + string propertyName, + bool isPresent, + JsonElement value) + { + if (isPresent && value.ValueKind == JsonValueKind.Null) + { + throw new HeadlessConfigurationException( + $"Session '{sessionId}' field '{propertyName}' cannot be null; " + + "supply a value when allowed or omit the field."); + } + } + /// /// Campaign LA slice LA1: validates the four new optional per-session /// fields shared with the App session-config reader (see diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 0a17e8f5..392d6166 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -120,6 +120,15 @@ internal sealed class HeadlessSessionHost : IDisposable /// rework of the shared-stdout diagnostics writer. /// private readonly SessionStatusWriter _statusWriter; + /// + /// Campaign LA LA2 review fix: the actual result returned by the process + /// start attempt. A configured probe mode is only intent; terminal status + /// may claim reason:"probe" after this records + /// . Any other + /// non-connected result maps to the same connection-error code returned by + /// . + /// + private RuntimeSessionStartStatus? _startOutcome; /// Guards 's disconnected status event /// so a Stop() on a session that never actually reached Connected (e.g. /// disposing a fresh, never-started host) does not report a spurious @@ -465,7 +474,10 @@ internal sealed class HeadlessSessionHost : IDisposable // Campaign LA slice LA1: "started" = session host start — the // earliest point this session actually attempts to connect. _statusWriter.Started(_descriptor.Id); - return Commands.Session.Start(Runtime.Generation); + RuntimeSessionStartResult result = + Commands.Session.Start(Runtime.Generation); + _startOutcome = result.Status; + return result; } internal RuntimeSessionStartResult Reconnect() => @@ -647,18 +659,16 @@ internal sealed class HeadlessSessionHost : IDisposable _stoppedGeneration); // Campaign LA slice LA1: "exited" = terminal — the sole // point every disposal path (graceful and post- - // quarantine) converges on. LA2: a probe session that - // never faulted reports reason "probe" here instead of - // "disposed" — the pinned contract's exit event for a - // successful probe. + // quarantine) converges on. LA2: only an actual + // ProbeComplete start outcome reports reason "probe"; + // configured probe intent cannot turn a failed start into + // a successful terminal event. + (int exitCode, string exitReason) = + ResolveTerminalStatus(); _statusWriter.Exited( _descriptor.Id, - _faulted ? 1 : 0, - _faulted - ? "fault" - : _descriptor.Mode == HeadlessSessionMode.Probe - ? "probe" - : "disposed"); + exitCode, + exitReason); _disposeStage++; _disposed = true; break; @@ -669,6 +679,33 @@ internal sealed class HeadlessSessionHost : IDisposable } } + /// + /// Produces the same terminal classification the owning process host uses. + /// Descriptor mode never participates: only an observed ProbeComplete may + /// report a successful probe. The surrounding disposal stage and LA1's + /// terminal/idempotent make this event + /// exact-once even when disposal is retried. + /// + private (int Code, string Reason) ResolveTerminalStatus() + { + if (_faulted) + { + return ( + (int)HeadlessExitCode.RuntimeError, + "runtime-fault"); + } + + return _startOutcome switch + { + RuntimeSessionStartStatus.ProbeComplete => + ((int)HeadlessExitCode.Success, "probe"), + null or RuntimeSessionStartStatus.Connected => + ((int)HeadlessExitCode.Success, "graceful"), + _ => + ((int)HeadlessExitCode.ConnectionError, "connection-error"), + }; + } + private RuntimeSessionStartResult StartCore( RuntimeGenerationToken expectedGeneration, bool reconnect) diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index ee4fdfbb..935de09d 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -656,16 +656,13 @@ public sealed class LiveSessionController } // Campaign LA slice LA2: the probe short-circuit lands here — - // right after the roster report, before TrySelectCharacter ever - // runs — so a probe session never reaches selection, - // ApplySelectedCharacter, or EnterWorld. This mirrors the - // NoCharacters early-exit immediately below (same StopCore - // teardown), the deliberate difference being the returned status - // is a SUCCESS, not a failure. Non-probe callers (options.Probe - // is false by default) fall straight through to the unchanged - // selection/enter path below — byte-identical to pre-LA2 - // behavior. - if (options.Probe) + // only after a real CharacterList was returned and its roster was + // reported, before TrySelectCharacter ever runs. A missing + // CharacterList falls through to the existing NoCharacters + // non-success path below; connectivity by itself is not a + // successful character-roster probe. Non-probe callers continue + // through the unchanged selection/enter path. + if (options.Probe && characters is not null) { Console.WriteLine( "live: probe complete — disconnecting before EnterWorld"); diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs index 6902fccc..25636cb4 100644 --- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs @@ -269,6 +269,105 @@ public sealed class HeadlessConfigurationLoaderTests () => HeadlessConfigurationLoader.Load(file.Path)); } + /// + /// Campaign LA LA2 review fix: the pinned contract is presence-aware. + /// Normal play omits mode and supplies character/policy; probe supplies + /// mode and omits character/policy. JSON null is not another spelling of + /// omission for any of those conditional fields. + /// + [Theory] + [InlineData(false, "mode")] + [InlineData(false, "character")] + [InlineData(false, "policy")] + [InlineData(true, "character")] + [InlineData(true, "policy")] + public void ConditionalSessionFieldsRejectExplicitJsonNull( + bool probe, + string nullField) + { + string mode = probe + ? "\"mode\":\"probe\"," + : nullField == "mode" + ? "\"mode\":null," + : string.Empty; + string character = nullField == "character" + ? "\"character\":null," + : probe + ? string.Empty + : "\"character\":{\"index\":0},"; + string policy = nullField == "policy" + ? "\"policy\":null," + : probe + ? string.Empty + : "\"policy\":{\"id\":\"idle\"},"; + using TemporaryConfiguration file = TemporaryConfiguration.Create( + $$""" + { + "version": 1, + "sessions": [ + { + "id": "explicit-null", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + {{mode}} + {{character}} + {{policy}} + "credential": { "provider": "environment", "reference": "PASSWORD" } + } + ] + } + """); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains( + $"'{nullField}'", + exception.Message, + StringComparison.Ordinal); + Assert.Contains( + "cannot be null", + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void PresenceAwareValidationKeepsUnmappedMemberRejection() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + ConfigurationWith(Session( + "bot", + "PASSWORD", + "\"notAContractField\":true"))); + + Assert.Throws( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + + [Fact] + public void PresenceAwareValidationKeepsTypedValueRejection() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + """ + { + "version": 1, + "sessions": [ + { + "id": "wrong-type", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "account", + "mode": { "value": "probe" }, + "credential": { "provider": "environment", "reference": "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 f568feb2..8ec13904 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -262,6 +262,85 @@ public sealed class HeadlessSessionHostTests Assert.False(cancellation.IsCancellationRequested); } + /// + /// Campaign LA LA2 review fix: configured probe intent is not proof of a + /// completed probe. If the connected session produces no CharacterList, + /// Runtime returns NoCharacters, the process returns ConnectionError, and + /// the sole terminal status event reports that same non-success instead of + /// the former false code-0/reason-probe pair. + /// + [Fact] + public async Task ProbeWithoutRosterReportsTheProcessConnectionErrorExactlyOnce() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-probe-no-roster-{Guid.NewGuid():N}.jsonl"); + try + { + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = + [ + ProbeDescriptor( + provider: HeadlessCredentialProviderKind.StandardInput, + credentialReference: "probe-password", + statusFile: statusPath), + ], + }; + var operations = new FixtureSessionOperations + { + Characters = null, + }; + using var diagnostics = new StringWriter(); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + new System.IO.StringReader( + "probe-password" + Environment.NewLine), + diagnostics, + operations); + + HeadlessExitCode result = await host.RunAsync( + CancellationToken.None); + + Assert.Equal(HeadlessExitCode.ConnectionError, result); + Assert.Equal(0, operations.EnterWorldCallCount); + Assert.Equal(1, operations.DisposedSessionCount); + + host.Dispose(); + host.Dispose(); + + string[] lines = File.ReadAllLines(statusPath); + JsonElement[] events = lines + .Select(static line => + JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + Assert.Equal( + ["started", "connected", "disconnected", "exited"], + events.Select(static item => + item.GetProperty("e").GetString())); + Assert.DoesNotContain( + events, + static item => + item.GetProperty("e").GetString() == "characterList"); + JsonElement exited = Assert.Single( + events, + static item => item.GetProperty("e").GetString() == "exited"); + Assert.Equal( + (int)result, + exited.GetProperty("code").GetInt32()); + Assert.Equal( + "connection-error", + exited.GetProperty("reason").GetString()); + } + finally + { + if (File.Exists(statusPath)) + File.Delete(statusPath); + } + } + /// /// Campaign LA slice LA2: a probe session completing must not tear down /// a sibling play session sharing the same process — the process exit @@ -3019,6 +3098,23 @@ public sealed class HeadlessSessionHostTests public int EnterWorldCallCount => Volatile.Read(ref _enterWorldCallCount); public int TickCallCount => Volatile.Read(ref _tickCallCount); + 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); @@ -3040,25 +3136,8 @@ public sealed class HeadlessSessionHostTests LastPassword = password; } - public CharacterList.Parsed GetCharacters( - WorldSession session) => - new( - 0u, - [ - new CharacterList.Character( - 0x50000001u, - "Other", - 0u), - new CharacterList.Character( - 0x50000002u, - "Headless", - 0u), - ], - [], - 11, - "account", - true, - true); + public CharacterList.Parsed? GetCharacters( + WorldSession session) => Characters; public void EnterWorld( WorldSession session, diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index b4ff6a25..10b4e18f 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -441,13 +441,13 @@ public sealed class LiveSessionControllerTests } /// - /// The probe short-circuit fires even when the server never returns a - /// CharacterList at all (GetCharacters returns null) — a probe is a - /// connectivity check, not itself a character-selection operation, so it - /// must not fall through to the NoCharacters path. + /// Campaign LA LA2 review fix: ProbeComplete proves a real CharacterList + /// was received and reported, not merely that the socket connected. A + /// missing roster follows the existing NoCharacters non-success path and + /// still drains the exact pre-world teardown transaction. /// [Fact] - public void Start_ProbeWithoutCharacterListStillCompletesGracefully() + public void Start_ProbeWithoutCharacterListIsNonSuccessAndTearsDownGracefully() { var calls = new List(); var operations = new TestOperations(calls) { Characters = null }; @@ -458,10 +458,25 @@ public sealed class LiveSessionControllerTests LiveOptions(probe: true), host); - Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status); + Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status); Assert.Empty(host.Rosters); + Assert.Equal( + [ + "reset", "resolve", "create", "bind", "report-connecting", + "connect", "report-connected", "deactivate", + "detach-events", "dispose-session", "detach-session", "reset", + ], + calls); Assert.Equal(0, operations.EnterWorldCount); Assert.False(controller.IsInWorld); + Assert.Null(controller.CurrentSession); + Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); + + LiveSessionOwnershipSnapshot ownership = controller.CaptureOwnership(); + Assert.Equal(RuntimeTeardownStage.Complete, ownership.LastTeardownStages); + Assert.False(ownership.HasActiveSession); + Assert.False(ownership.HasRetiredSession); + Assert.False(ownership.HasPendingOperation); } [Theory]