From c6019424675201b5c2ebbb7429d40c93d071bb03 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:33:00 +0200 Subject: [PATCH] =?UTF-8?q?wip:=20Campaign=20LA=20LA2=20probe=20mode=20+?= =?UTF-8?q?=20idle=20policy=20=E2=80=94=20INCOMPLETE,=20stopped=20mid-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,