diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index 1e3270b9..bf5fbf20 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -488,7 +488,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 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1–F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. | -| LA2 | — | | | | +| LA2 | **DONE 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Release builds green on Windows and Ubuntu/WSL; Runtime 1,632/1,632 and Headless 149/149 on both. Repeated live ACE probe remains the LA11 user gate. | | LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. | | LA4 | — | | | | | LA5 | — | | | | diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 42fe7e67..fdae1225 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -46,11 +46,34 @@ internal sealed record HeadlessSessionDescriptor [JsonRequired] public string Account { get; init; } = string.Empty; - [JsonRequired] - public HeadlessCharacterSelector Character { get; init; } = new(); + /// + /// 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). + /// / 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 +163,21 @@ 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. 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. +/// +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..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,6 +193,83 @@ internal static class HeadlessConfigurationLoader $"Session '{session.Id}' requires a non-empty account."); } + ValidateModeShape(session, sessionElement); + + 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, + 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 (hasCharacter) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' has mode \"probe\" and must omit " + + "'character' — a probe never selects a character."); + } + if (hasPolicy) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' has mode \"probe\" and must omit " + + "'policy' — a probe never drives a bot policy."); + } + return; + } + + if (hasMode) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' is normal play and must omit 'mode'."); + } + if (session.Character is null) { throw new HeadlessConfigurationException( @@ -206,16 +294,29 @@ 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)) + /// + /// 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 '{session.Id}' requires a credential reference."); + $"Session '{sessionId}' field '{propertyName}' cannot be null; " + + "supply a value when allowed or omit the field."); } - - 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 b8b7406e..0f6340a1 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 @@ -319,7 +328,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 +375,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, @@ -454,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() => @@ -637,11 +660,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. + // 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 ? "runtime-fault" : "graceful"); + exitCode, + exitReason); _disposeStage++; _disposed = true; break; @@ -652,6 +680,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) @@ -722,7 +777,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; @@ -969,12 +1025,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) @@ -993,6 +1056,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..935de09d 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,21 @@ public sealed class LiveSessionController return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); } + // Campaign LA slice LA2: the probe short-circuit lands here — + // 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"); + 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/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 56d16c33..25636cb4 100644 --- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs @@ -208,6 +208,339 @@ 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); + } + + /// + /// 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)); + } + + /// + /// 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() + { + 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 c4fc0fb2..1834cb35 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; @@ -218,17 +219,217 @@ 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 async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() + 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 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 + /// 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, - "stdin-bot"), + "play-password"), ], }; HeadlessPathSet paths = HeadlessPathSet.Resolve( @@ -239,20 +440,144 @@ public sealed class HeadlessSessionHostTests configuration, paths, new System.IO.StringReader( - "process-password" + Environment.NewLine), + "probe-password" + Environment.NewLine + + "play-password" + Environment.NewLine), diagnostics, operations); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); - HeadlessExitCode result = - await host.RunAsync(cancellation.Token); + HeadlessExitCode result = await host.RunAsync(cancellation.Token); Assert.Equal(HeadlessExitCode.Success, result); - Assert.True(host.Session.Runtime.Session.IsInWorld); - Assert.DoesNotContain( - "process-password", - diagnostics.ToString()); + 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); + } + + /// + /// 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 IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce() + { + string statusPath = Path.Combine( + Path.GetTempPath(), + $"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl"); + try + { + 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(); + + 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.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] @@ -2153,6 +2478,41 @@ 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 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; @@ -2794,11 +3154,34 @@ 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 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); @@ -2820,34 +3203,19 @@ 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, int activeCharacterIndex) { + Interlocked.Increment(ref _enterWorldCallCount); } public void Tick(WorldSession session) { + Interlocked.Increment(ref _tickCallCount); } public void DisposeSession(WorldSession session) diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs index 5a8a3aa1..44e32af9 100644 --- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -32,8 +32,12 @@ public sealed class SessionConfigurationSharedFixtureTests Assert.Equal("composer.example", session.Endpoint.Host); Assert.Equal(9010, session.Endpoint.Port); Assert.Equal("composer-account", session.Account); - Assert.Equal(0x50000001u, session.Character.Id); - Assert.Equal("idle", session.Policy.Id); + HeadlessCharacterSelector character = + Assert.IsType(session.Character); + HeadlessBotPolicyDescriptor policy = + Assert.IsType(session.Policy); + Assert.Equal(0x50000001u, character.Id); + Assert.Equal("idle", policy.Id); Assert.Equal( HeadlessCredentialProviderKind.StandardInput, session.Credential.Provider); @@ -73,8 +77,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.StandardInput, session.Credential.Provider); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index 6a3f49bf..10b4e18f 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -389,6 +389,96 @@ 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); + } + + /// + /// 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_ProbeWithoutCharacterListIsNonSuccessAndTearsDownGracefully() + { + 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.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] [InlineData("index")] [InlineData("id")] @@ -1141,14 +1231,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,