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,