diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
index 42fe7e67..fbdbdbe9 100644
--- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
+++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
@@ -46,11 +46,33 @@ internal sealed record HeadlessSessionDescriptor
[JsonRequired]
public string Account { get; init; } = string.Empty;
- [JsonRequired]
- public HeadlessCharacterSelector Character { get; init; } = new();
+ ///
+ /// Campaign LA slice LA2: ABSENT () for normal play
+ /// sessions; for the LA2 probe
+ /// (connect → characterList → graceful disconnect, never EnterWorld) —
+ /// the pinned launch-contract schema's mode field
+ /// (docs/plans/2026-08-14-launcher-campaign.md LA1/LA2).
+ /// / requiredness depends on
+ /// this value, which is why their requiredness lives in
+ /// 's semantic validation rather
+ /// than a [JsonRequired] attribute — that attribute fires during
+ /// deserialization, before can be inspected at all.
+ ///
+ public HeadlessSessionMode? Mode { get; init; }
- [JsonRequired]
- public HeadlessBotPolicyDescriptor Policy { get; init; } = new();
+ ///
+ /// Required for play sessions ( absent); MUST be
+ /// omitted for probe sessions () —
+ /// the pinned contract keeps the shape unambiguous by forbidding a probe
+ /// session from also declaring a selector. Enforced by
+ /// , not
+ /// [JsonRequired] (see this record's own doc on ).
+ ///
+ public HeadlessCharacterSelector? Character { get; init; }
+
+ /// Same mode-dependent requiredness as :
+ /// required for play sessions, forbidden for probe sessions.
+ public HeadlessBotPolicyDescriptor? Policy { get; init; }
[JsonRequired]
public HeadlessCredentialReference Credential { get; init; } = new();
@@ -140,6 +162,19 @@ internal sealed class HeadlessBotPolicyDescriptor
public HeadlessBotPolicyRole? Role { get; init; }
}
+///
+/// Campaign LA slice LA2: see .
+/// The pinned launch-contract schema defines exactly two states for a
+/// session — ABSENT (mapped to , meaning "play") or
+/// the literal string "probe" — so is the only
+/// member; there is no explicit "play" spelling.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+internal enum HeadlessSessionMode
+{
+ Probe,
+}
+
/// See .
[JsonConverter(typeof(JsonStringEnumConverter))]
internal enum HeadlessBotPolicyRole
diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs
index 016b79c2..4c83699c 100644
--- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs
+++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs
@@ -182,6 +182,57 @@ internal static class HeadlessConfigurationLoader
$"Session '{session.Id}' requires a non-empty account.");
}
+ ValidateModeShape(session);
+
+ if (session.Credential is null
+ || string.IsNullOrWhiteSpace(session.Credential.Reference))
+ {
+ throw new HeadlessConfigurationException(
+ $"Session '{session.Id}' requires a credential reference.");
+ }
+
+ ValidateCharacterOptions(session);
+ ValidateLaunchContractFields(session);
+ }
+
+ ///
+ /// Campaign LA slice LA2: mode-dependent requiredness for
+ /// /
+ /// — this REPLACES the
+ /// former `[JsonRequired]` attributes on both properties (which fired
+ /// unconditionally at deserialize time, before a probe session's
+ /// omission could ever be distinguished from a play session's mistake).
+ /// A play session (mode absent) keeps EXACTLY today's strictness: a
+ /// missing/malformed character selector or a missing policy id still
+ /// fails load, just via
+ /// naming the field instead of a raw citing
+ /// "missing required properties" — same exit code (3,
+ /// HeadlessExitCode.ConfigurationError) either way, more specific
+ /// text now (an accepted improvement, not a contract change). A probe
+ /// session (mode "probe") must OMIT both fields entirely — the pinned
+ /// contract keeps the shape unambiguous by rejecting a probe session
+ /// that also declares a selector or a policy, rather than silently
+ /// ignoring them.
+ ///
+ private static void ValidateModeShape(HeadlessSessionDescriptor session)
+ {
+ if (session.Mode == HeadlessSessionMode.Probe)
+ {
+ if (session.Character is not null)
+ {
+ throw new HeadlessConfigurationException(
+ $"Session '{session.Id}' has mode \"probe\" and must omit "
+ + "'character' — a probe never selects a character.");
+ }
+ if (session.Policy is not null)
+ {
+ throw new HeadlessConfigurationException(
+ $"Session '{session.Id}' has mode \"probe\" and must omit "
+ + "'policy' — a probe never drives a bot policy.");
+ }
+ return;
+ }
+
if (session.Character is null)
{
throw new HeadlessConfigurationException(
@@ -206,16 +257,6 @@ internal static class HeadlessConfigurationLoader
throw new HeadlessConfigurationException(
$"Session '{session.Id}' requires a non-empty policy id.");
}
-
- if (session.Credential is null
- || string.IsNullOrWhiteSpace(session.Credential.Reference))
- {
- throw new HeadlessConfigurationException(
- $"Session '{session.Id}' requires a credential reference.");
- }
-
- ValidateCharacterOptions(session);
- ValidateLaunchContractFields(session);
}
///
diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
index 4d4d1b83..5d38edb0 100644
--- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs
@@ -200,6 +200,22 @@ internal sealed class HeadlessProcessHost : IDisposable
foreach (HeadlessSessionHost session in _sessions)
{
RuntimeSessionStartResult started = session.Start();
+ // Campaign LA slice LA2: ProbeComplete is a SUCCESS variant, not
+ // a connection failure — the session already connected, reported
+ // its roster, and gracefully disconnected before EnterWorld (see
+ // LiveSessionController's probe short-circuit). Continue to the
+ // next configured session instead of returning ConnectionError,
+ // so a probe session sharing a process with play sessions never
+ // tears the others down. ProbeHeadlessBotPolicy already reports
+ // IsComplete, so the scheduler below skips this session entirely.
+ if (started.Status == RuntimeSessionStartStatus.ProbeComplete)
+ {
+ _diagnostics.Lifecycle(
+ session.SessionId,
+ "probed",
+ session.Runtime);
+ continue;
+ }
if (started.Status != RuntimeSessionStartStatus.Connected)
{
if (started.Error is { } error)
diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index ea3a56d4..0a17e8f5 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -319,7 +319,7 @@ internal sealed class HeadlessSessionHost : IDisposable
// doc). Gating on role keeps two sessions
// writing the SAME field from ever racing —
// only one role ever writes it.
- if (descriptor.Policy.Role
+ if (descriptor.Policy?.Role
== HeadlessBotPolicyRole.Recruit
&& gateCoordinator is not null)
{
@@ -366,13 +366,24 @@ internal sealed class HeadlessSessionHost : IDisposable
hostLease = runtime.AcquireHostLease(
$"headless:{descriptor.Id}");
+ // Campaign LA slice LA2: a probe session's descriptor carries no
+ // `policy` at all (the loader rejects the opposite pairing) — a
+ // probe never reaches TrySelectCharacter/EnterWorld, so there is
+ // no policy id to switch on. ProbeHeadlessBotPolicy reports
+ // IsComplete unconditionally so HeadlessProcessScheduler treats
+ // this session as already finished the instant it is
+ // constructed, letting the scheduler's Run() loop return
+ // immediately for a probe-only process instead of waiting for
+ // SIGINT.
policy = policyOverride
- ?? HeadlessBotPolicyFactory.Create(
- descriptor.Policy,
- runtime,
- () => _pendingConfirmation,
- RespondToConfirmation,
- gateCoordinator);
+ ?? (descriptor.Mode == HeadlessSessionMode.Probe
+ ? new ProbeHeadlessBotPolicy()
+ : HeadlessBotPolicyFactory.Create(
+ descriptor.Policy!,
+ runtime,
+ () => _pendingConfirmation,
+ RespondToConfirmation,
+ gateCoordinator));
policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle(
descriptor.Id,
@@ -636,11 +647,18 @@ internal sealed class HeadlessSessionHost : IDisposable
_stoppedGeneration);
// Campaign LA slice LA1: "exited" = terminal — the sole
// point every disposal path (graceful and post-
- // quarantine) converges on.
+ // quarantine) converges on. LA2: a probe session that
+ // never faulted reports reason "probe" here instead of
+ // "disposed" — the pinned contract's exit event for a
+ // successful probe.
_statusWriter.Exited(
_descriptor.Id,
_faulted ? 1 : 0,
- _faulted ? "fault" : "disposed");
+ _faulted
+ ? "fault"
+ : _descriptor.Mode == HeadlessSessionMode.Probe
+ ? "probe"
+ : "disposed");
_disposeStage++;
_disposed = true;
break;
@@ -717,7 +735,8 @@ internal sealed class HeadlessSessionHost : IDisposable
_descriptor.Endpoint.Port,
_descriptor.Account,
password,
- MapCharacterSelector(_descriptor.Character));
+ MapCharacterSelector(_descriptor.Character),
+ Probe: _descriptor.Mode == HeadlessSessionMode.Probe);
LiveSessionStartResult result = _liveSession.Start(options);
if (result.Selection is { } selection)
_accountName = selection.AccountName;
@@ -964,12 +983,19 @@ internal sealed class HeadlessSessionHost : IDisposable
return declared;
}
- private static LiveSessionCharacterSelector MapCharacterSelector(
- HeadlessCharacterSelector selector) =>
- new(
- selector.Index,
- selector.Id,
- selector.Name);
+ /// Campaign LA slice LA2: for a probe
+ /// session (the loader guarantees Character is omitted whenever
+ /// Mode is ) — a probe
+ /// never reaches TrySelectCharacter, so "no selector configured"
+ /// is the correct, harmless mapping.
+ private static LiveSessionCharacterSelector? MapCharacterSelector(
+ HeadlessCharacterSelector? selector) =>
+ selector is null
+ ? null
+ : new(
+ selector.Index,
+ selector.Id,
+ selector.Name);
private RuntimeSessionStartResult Convert(
LiveSessionStartResult result)
@@ -988,6 +1014,8 @@ internal sealed class HeadlessSessionHost : IDisposable
RuntimeSessionStartStatus.Deferred,
LiveSessionStartStatus.Failed =>
RuntimeSessionStartStatus.Failed,
+ LiveSessionStartStatus.ProbeComplete =>
+ RuntimeSessionStartStatus.ProbeComplete,
_ => throw new ArgumentOutOfRangeException(
nameof(result),
result.Status,
diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
index 90ee240c..f35c16a4 100644
--- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
+++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
@@ -100,6 +100,21 @@ internal static class HeadlessBotPolicyFactory
}
}
+///
+/// Campaign LA slice LA2: the "idle" consumer policy id — the session enters
+/// world (unchanged start/select/EnterWorld
+/// path) and then does nothing actively: no chat, no movement, no combat.
+/// is permanently , so
+/// keeps ticking the session
+/// (harmlessly — and every delta handler below are no-ops)
+/// until the process is stopped (SIGINT/cancellation) or disposed; teardown
+/// then rides 's existing graceful
+/// stop/logout path — the same mechanism K4's endurance gate already proved.
+/// No is required. This class
+/// predates LA2 (introduced at K1 as dev/test scaffolding); LA2 formalizes it
+/// as the documented headless "just sit in world" play policy and adds
+/// focused coverage in HeadlessBotPolicyTests.
+///
internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy
{
public bool IsComplete => false;
@@ -149,6 +164,70 @@ internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy
}
}
+///
+/// Campaign LA slice LA2: the policy substituted (never selected via
+/// — a probe session's
+/// descriptor carries no policy id at all) for a
+/// session.
+/// is from construction, BEFORE
+/// even runs, so
+/// never dispatches a tick to this
+/// session — a probe session's
+/// is already gracefully torn down by
+/// 's probe
+/// short-circuit by the time the scheduler would otherwise look at it, and a
+/// single-session probe process's Run() loop returns immediately
+/// instead of waiting for SIGINT.
+///
+internal sealed class ProbeHeadlessBotPolicy : IHeadlessBotPolicy
+{
+ public bool IsComplete => true;
+
+ public void Tick(
+ IGameRuntimeView view,
+ IGameRuntimeCommands commands)
+ {
+ ArgumentNullException.ThrowIfNull(view);
+ ArgumentNullException.ThrowIfNull(commands);
+ }
+
+ public void OnLifecycle(in RuntimeLifecycleDelta delta)
+ {
+ }
+
+ public void OnCommand(in RuntimeCommandDelta delta)
+ {
+ }
+
+ public void OnEntity(in RuntimeEntityDelta delta)
+ {
+ }
+
+ public void OnInventory(in RuntimeInventoryDelta delta)
+ {
+ }
+
+ public void OnChat(in RuntimeChatDelta delta)
+ {
+ }
+
+ public void OnMovement(in RuntimeMovementDelta delta)
+ {
+ }
+
+ public void OnPortal(in RuntimePortalDelta delta)
+ {
+ }
+
+ public void OnCombat(in RuntimeCombatDelta delta)
+ {
+ }
+
+ public void Dispose()
+ {
+ }
+}
+
///
/// Explicit connected-gate policy: wait for the local player, issue one
/// harmless local-speech command and one lifestone recall, reconnect after
diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs
index f20db0f2..db177ed1 100644
--- a/src/AcDream.Runtime/GameRuntimeCommands.cs
+++ b/src/AcDream.Runtime/GameRuntimeCommands.cs
@@ -27,6 +27,14 @@ public enum RuntimeSessionStartStatus
Failed,
Inactive,
StaleGeneration,
+ ///
+ /// Campaign LA slice LA2: mirrors
+ /// — a probe
+ /// session connected, reported its roster, and gracefully disconnected
+ /// before EnterWorld. A SUCCESS outcome for the headless process host's
+ /// exit-code mapping, not a failure.
+ ///
+ ProbeComplete,
}
public readonly record struct RuntimeSessionStartResult(
diff --git a/src/AcDream.Runtime/Session/LiveSessionContracts.cs b/src/AcDream.Runtime/Session/LiveSessionContracts.cs
index 49ca27d2..e8f81d4b 100644
--- a/src/AcDream.Runtime/Session/LiveSessionContracts.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionContracts.cs
@@ -13,7 +13,23 @@ public sealed record LiveSessionConnectOptions(
int Port,
string User,
string Password,
- LiveSessionCharacterSelector? Character = null);
+ LiveSessionCharacterSelector? Character = null,
+ ///
+ /// Campaign LA slice LA2: short-circuits
+ /// 's connect transaction right after
+ /// the roster report (before TrySelectCharacter/
+ /// ApplySelectedCharacter/EnterWorld) — connect, receive
+ /// CharacterList, report the roster, gracefully disconnect via the
+ /// same StopCore teardown the
+ /// path already uses, and return
+ /// . The pinned launch
+ /// contract (docs/plans/2026-08-14-launcher-campaign.md LA1's
+ /// mode field) requires a probe session to omit both
+ /// and its policy entirely, but the controller
+ /// itself does not enforce that pairing — the headless config loader
+ /// does, before a is ever built.
+ ///
+ bool Probe = false);
public interface IRuntimeLiveSessionFramePhase
{
diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs
index 1ecd5d00..ee4fdfbb 100644
--- a/src/AcDream.Runtime/Session/LiveSessionController.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionController.cs
@@ -13,6 +13,14 @@ public enum LiveSessionStartStatus
Connected,
Deferred,
Failed,
+ ///
+ /// Campaign LA slice LA2: a
+ /// session connected, received (and reported) the character roster, and
+ /// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
+ /// SUCCESS variant of the early-exit shape
+ /// (same StopCore teardown), not a failure.
+ ///
+ ProbeComplete,
}
public readonly record struct LiveSessionOwnershipSnapshot(
@@ -647,6 +655,24 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
+ // Campaign LA slice LA2: the probe short-circuit lands here —
+ // right after the roster report, before TrySelectCharacter ever
+ // runs — so a probe session never reaches selection,
+ // ApplySelectedCharacter, or EnterWorld. This mirrors the
+ // NoCharacters early-exit immediately below (same StopCore
+ // teardown), the deliberate difference being the returned status
+ // is a SUCCESS, not a failure. Non-probe callers (options.Probe
+ // is false by default) fall straight through to the unchanged
+ // selection/enter path below — byte-identical to pre-LA2
+ // behavior.
+ if (options.Probe)
+ {
+ Console.WriteLine(
+ "live: probe complete — disconnecting before EnterWorld");
+ StopCore();
+ return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete);
+ }
+
if (characters is null
|| !TrySelectCharacter(
characters,
diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs
index 95e8f553..5ceb04e2 100644
--- a/src/AcDream.Runtime/Session/LiveSessionHost.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs
@@ -316,6 +316,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
RuntimeSessionStartStatus.Deferred,
LiveSessionStartStatus.Failed =>
RuntimeSessionStartStatus.Failed,
+ LiveSessionStartStatus.ProbeComplete =>
+ RuntimeSessionStartStatus.ProbeComplete,
_ => throw new ArgumentOutOfRangeException(
nameof(result),
result.Status,
diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs
index 56d16c33..9c21273c 100644
--- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs
@@ -208,6 +208,208 @@ public sealed class HeadlessConfigurationLoaderTests
Assert.Empty(declared!);
}
+ // ── Campaign LA slice LA2: probe-mode `mode` field shape validation ──
+
+ [Fact]
+ public void ProbeSessionOmittingCharacterAndPolicyLoads()
+ {
+ using TemporaryConfiguration file = TemporaryConfiguration.Create(
+ """
+ {
+ "version": 1,
+ "sessions": [
+ {
+ "id": "probe-session",
+ "endpoint": { "host": "127.0.0.1", "port": 9000 },
+ "account": "account",
+ "mode": "probe",
+ "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
+ }
+ ]
+ }
+ """);
+
+ HeadlessConfiguration configuration =
+ HeadlessConfigurationLoader.Load(file.Path);
+
+ HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
+ Assert.Null(session.Character);
+ Assert.Null(session.Policy);
+ }
+
+ [Fact]
+ public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
+ {
+ using TemporaryConfiguration file = TemporaryConfiguration.Create(
+ """
+ {
+ "version": 1,
+ "sessions": [
+ {
+ "id": "probe-with-character",
+ "endpoint": { "host": "127.0.0.1", "port": 9000 },
+ "account": "account",
+ "mode": "probe",
+ "character": { "index": 0 },
+ "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
+ }
+ ]
+ }
+ """);
+
+ HeadlessConfigurationException exception = Assert.Throws<
+ HeadlessConfigurationException>(
+ () => HeadlessConfigurationLoader.Load(file.Path));
+
+ Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("character", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ProbeSessionDeclaringPolicyFailsLoadNamingTheField()
+ {
+ using TemporaryConfiguration file = TemporaryConfiguration.Create(
+ """
+ {
+ "version": 1,
+ "sessions": [
+ {
+ "id": "probe-with-policy",
+ "endpoint": { "host": "127.0.0.1", "port": 9000 },
+ "account": "account",
+ "mode": "probe",
+ "policy": { "id": "idle" },
+ "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
+ }
+ ]
+ }
+ """);
+
+ HeadlessConfigurationException exception = Assert.Throws<
+ HeadlessConfigurationException>(
+ () => HeadlessConfigurationLoader.Load(file.Path));
+
+ Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("policy", exception.Message, StringComparison.Ordinal);
+ }
+
+ ///
+ /// A play session (mode absent) missing `character` must still fail —
+ /// the LA2 change moved this requiredness from `[JsonRequired]` (a raw
+ /// at deserialize time) to
+ /// 's semantic
+ /// check (a naming the
+ /// missing field). Exit-code parity (both map to
+ /// HeadlessExitCode.ConfigurationError) is proven at
+ /// HeadlessEntryPointTests; this test pins the loader-level
+ /// exception type/message.
+ ///
+ [Fact]
+ public void PlaySessionMissingCharacterStillFailsLoad()
+ {
+ using TemporaryConfiguration file = TemporaryConfiguration.Create(
+ """
+ {
+ "version": 1,
+ "sessions": [
+ {
+ "id": "play-missing-character",
+ "endpoint": { "host": "127.0.0.1", "port": 9000 },
+ "account": "account",
+ "policy": { "id": "idle" },
+ "credential": { "provider": "environment", "reference": "PLAY_PASSWORD" }
+ }
+ ]
+ }
+ """);
+
+ HeadlessConfigurationException exception = Assert.Throws<
+ HeadlessConfigurationException>(
+ () => HeadlessConfigurationLoader.Load(file.Path));
+
+ Assert.Contains(
+ "requires a character selector",
+ exception.Message,
+ StringComparison.Ordinal);
+ }
+
+ /// Same parity claim as
+ /// for the
+ /// `policy` field.
+ [Fact]
+ public void PlaySessionMissingPolicyStillFailsLoad()
+ {
+ using TemporaryConfiguration file = TemporaryConfiguration.Create(
+ """
+ {
+ "version": 1,
+ "sessions": [
+ {
+ "id": "play-missing-policy",
+ "endpoint": { "host": "127.0.0.1", "port": 9000 },
+ "account": "account",
+ "character": { "index": 0 },
+ "credential": { "provider": "environment", "reference": "PLAY_PASSWORD" }
+ }
+ ]
+ }
+ """);
+
+ HeadlessConfigurationException exception = Assert.Throws<
+ HeadlessConfigurationException>(
+ () => HeadlessConfigurationLoader.Load(file.Path));
+
+ Assert.Contains(
+ "requires a non-empty policy id",
+ exception.Message,
+ StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void PlaySessionKeepsTodaysStrictCharacterSelectorAndPolicyValidation()
+ {
+ // Unrelated to `mode` — proves the LA2 refactor of ValidateSession
+ // did not loosen the existing selector-shape/policy-id checks for
+ // ordinary play sessions (mode absent).
+ using TemporaryConfiguration badSelector = TemporaryConfiguration.Create(
+ """
+ {
+ "version": 1,
+ "sessions": [
+ {
+ "id": "bad-selector",
+ "endpoint": { "host": "127.0.0.1", "port": 9000 },
+ "account": "account",
+ "character": { "index": 0, "name": "Two" },
+ "policy": { "id": "idle" },
+ "credential": { "provider": "environment", "reference": "A" }
+ }
+ ]
+ }
+ """);
+ using TemporaryConfiguration blankPolicy = TemporaryConfiguration.Create(
+ """
+ {
+ "version": 1,
+ "sessions": [
+ {
+ "id": "blank-policy",
+ "endpoint": { "host": "127.0.0.1", "port": 9000 },
+ "account": "account",
+ "character": { "index": 0 },
+ "policy": { "id": "" },
+ "credential": { "provider": "environment", "reference": "B" }
+ }
+ ]
+ }
+ """);
+
+ Assert.Throws(
+ () => HeadlessConfigurationLoader.Load(badSelector.Path));
+ Assert.Throws(
+ () => HeadlessConfigurationLoader.Load(blankPolicy.Path));
+ }
+
private static string ConfigurationWith(params string[] sessions) =>
$$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}""";
diff --git a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
index 02ffca6e..41dcdf16 100644
--- a/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessEntryPointTests.cs
@@ -191,6 +191,76 @@ public sealed class HeadlessEntryPointTests
Assert.Contains(expected, error.ToString());
}
+ ///
+ /// Campaign LA slice LA2: before this change, an omitted `character` or
+ /// `policy` field failed deserialization itself with a raw
+ /// ("missing required
+ /// properties") — this test pins that the LA2 move to semantic
+ /// validation (
+ /// naming the exact missing field) preserves the SAME exit code
+ /// (3, HeadlessExitCode.ConfigurationError) end to end through
+ /// .
+ /// The message text change (generic → field-naming) is a deliberate,
+ /// accepted improvement, not a contract break.
+ ///
+ [Theory]
+ [InlineData(
+ """
+ {"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","policy":{"id":"idle"},"credential":{"provider":"environment","reference":"X"}}]}
+ """,
+ "character selector")]
+ [InlineData(
+ """
+ {"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","character":{"index":0},"credential":{"provider":"environment","reference":"X"}}]}
+ """,
+ "policy id")]
+ public void PlaySessionMissingCharacterOrPolicyKeepsConfigurationErrorExitCode(
+ string json,
+ string expectedMessageFragment)
+ {
+ using var file = TemporaryConfiguration.Create(json);
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = HeadlessEntryPoint.Run(
+ ["validate", "--config", file.Path],
+ output,
+ error);
+
+ Assert.Equal((int)HeadlessExitCode.ConfigurationError, exitCode);
+ Assert.Contains(
+ expectedMessageFragment,
+ error.ToString(),
+ StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(string.Empty, output.ToString());
+ }
+
+ ///
+ /// Campaign LA slice LA2: a valid probe-mode session (mode "probe",
+ /// character/policy both omitted) passes `validate` — the launcher's
+ /// "refresh characters" flow only needs the process to accept the
+ /// document, not to run it.
+ ///
+ [Fact]
+ public void ValidateAcceptsProbeSessionOmittingCharacterAndPolicy()
+ {
+ using var file = TemporaryConfiguration.Create(
+ """
+ {"version":1,"sessions":[{"id":"probe","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","mode":"probe","credential":{"provider":"environment","reference":"X"}}]}
+ """);
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = HeadlessEntryPoint.Run(
+ ["validate", "--config", file.Path],
+ output,
+ error);
+
+ Assert.Equal((int)HeadlessExitCode.Success, exitCode);
+ Assert.Contains("1 session(s)", output.ToString());
+ Assert.Equal(string.Empty, error.ToString());
+ }
+
[Fact]
public void UnknownCommandReturnsUsageError()
{
diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
index 5c9871f5..332a9933 100644
--- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
@@ -151,6 +151,170 @@ public sealed class HeadlessSessionHostTests
// writer is a permanent no-op with no configured path.
}
+ ///
+ /// Campaign LA slice LA2: a probe-mode session's status stream reports
+ /// started/connected/characterList and then converges straight to
+ /// exited(reason:"probe", code:0) — never enteredWorld — and the
+ /// underlying operations fake proves EnterWorld was literally never
+ /// called (not merely that no wire message happened to arrive).
+ ///
+ [Fact]
+ public void ProbeSessionEmitsRosterThenExitsSuccessfullyWithoutEnteringWorld()
+ {
+ string statusPath = Path.Combine(
+ Path.GetTempPath(),
+ $"acdream-headless-probe-status-{Guid.NewGuid():N}.jsonl");
+ try
+ {
+ var operations = new FixtureSessionOperations();
+ using var diagnosticsOutput = new StringWriter();
+ using var credential = new HeadlessCredentialSecret(
+ "fixture",
+ "password");
+ using var host = new HeadlessSessionHost(
+ ProbeDescriptor(statusFile: statusPath),
+ credential,
+ new HeadlessDiagnosticWriter(diagnosticsOutput),
+ operations);
+
+ RuntimeSessionStartResult started = host.Start();
+ Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, started.Status);
+ Assert.Equal(0, operations.EnterWorldCallCount);
+ Assert.False(host.Runtime.Session.IsInWorld);
+
+ host.Dispose();
+
+ Assert.Equal(0, operations.EnterWorldCallCount);
+ Assert.True(host.Runtime.CaptureOwnership().IsConverged);
+
+ string[] lines = File.ReadAllLines(statusPath);
+ string[] eventNames = lines
+ .Select(line => JsonDocument.Parse(line)
+ .RootElement.GetProperty("e").GetString()!)
+ .ToArray();
+ Assert.DoesNotContain("enteredWorld", eventNames);
+ Assert.Contains("characterList", eventNames);
+ Assert.Contains("exited", eventNames);
+ Assert.True(
+ Array.IndexOf(eventNames, "characterList")
+ < Array.IndexOf(eventNames, "exited"),
+ "characterList must land before the terminal exited event.");
+
+ using JsonDocument exitedDoc = JsonDocument.Parse(
+ lines[Array.IndexOf(eventNames, "exited")]);
+ Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
+ Assert.Equal(
+ "probe",
+ exitedDoc.RootElement.GetProperty("reason").GetString());
+
+ string contents = File.ReadAllText(statusPath);
+ Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
+ }
+ finally
+ {
+ if (File.Exists(statusPath))
+ File.Delete(statusPath);
+ }
+ }
+
+ ///
+ /// Campaign LA slice LA2:
+ /// maps a ProbeComplete start to
+ /// (0) rather than — a
+ /// single-session probe-only process must exit cleanly and promptly
+ /// without ever needing SIGINT/cancellation, because
+ /// ProbeHeadlessBotPolicy reports IsComplete immediately.
+ ///
+ [Fact]
+ public async Task ProcessHostMapsProbeCompleteStartToSuccessExitCode()
+ {
+ var configuration = new HeadlessConfiguration
+ {
+ Version = 1,
+ Sessions =
+ [
+ ProbeDescriptor(
+ provider: HeadlessCredentialProviderKind.StandardInput,
+ credentialReference: "probe-password"),
+ ],
+ };
+ HeadlessPathSet paths = HeadlessPathSet.Resolve(
+ new HeadlessPathOverrides());
+ using var diagnostics = new StringWriter();
+ var operations = new FixtureSessionOperations();
+ using var host = new HeadlessProcessHost(
+ configuration,
+ paths,
+ new System.IO.StringReader("probe-password" + Environment.NewLine),
+ diagnostics,
+ operations);
+ // Deliberately NOT cancelled — a probe-only process must return on
+ // its own; a hang here would mean the scheduler never recognized
+ // the probe session as already complete.
+ using var cancellation = new CancellationTokenSource(
+ TimeSpan.FromSeconds(10));
+
+ HeadlessExitCode result = await host.RunAsync(cancellation.Token);
+
+ Assert.Equal(HeadlessExitCode.Success, result);
+ Assert.Equal(0, operations.EnterWorldCallCount);
+ Assert.False(cancellation.IsCancellationRequested);
+ }
+
+ ///
+ /// Campaign LA slice LA2: a probe session completing must not tear down
+ /// a sibling play session sharing the same process — the process exit
+ /// code is 0 only once every configured session has succeeded (the
+ /// probe counts as success the instant it completes; the play session
+ /// keeps running until cancellation).
+ ///
+ [Fact]
+ public async Task ProbeSessionSharingAProcessDoesNotTearDownASiblingPlaySession()
+ {
+ var configuration = new HeadlessConfiguration
+ {
+ Version = 1,
+ Sessions =
+ [
+ ProbeDescriptor(
+ "probe-sibling",
+ provider: HeadlessCredentialProviderKind.StandardInput,
+ credentialReference: "probe-password"),
+ Descriptor(
+ HeadlessCredentialProviderKind.StandardInput,
+ "play-password"),
+ ],
+ };
+ HeadlessPathSet paths = HeadlessPathSet.Resolve(
+ new HeadlessPathOverrides());
+ using var diagnostics = new StringWriter();
+ var operations = new FixtureSessionOperations();
+ using var host = new HeadlessProcessHost(
+ configuration,
+ paths,
+ new System.IO.StringReader(
+ "probe-password" + Environment.NewLine
+ + "play-password" + Environment.NewLine),
+ diagnostics,
+ operations);
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ HeadlessExitCode result = await host.RunAsync(cancellation.Token);
+
+ Assert.Equal(HeadlessExitCode.Success, result);
+ Assert.Equal(2, host.Sessions.Count);
+ HeadlessSessionHost probeSession = Assert.Single(
+ host.Sessions,
+ s => s.SessionId == "probe-sibling");
+ HeadlessSessionHost playSession = Assert.Single(
+ host.Sessions,
+ s => s.SessionId == "bot");
+ Assert.False(probeSession.Runtime.Session.IsInWorld);
+ Assert.True(playSession.Runtime.Session.IsInWorld);
+ Assert.False(playSession.IsFaulted);
+ }
+
[Fact]
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
{
@@ -2086,6 +2250,32 @@ public sealed class HeadlessSessionHostTests
StatusFile = statusFile,
};
+ /// Campaign LA slice LA2: a probe-mode descriptor — mode
+ /// "probe", Character/Policy both omitted per the pinned
+ /// contract shape enforces.
+ private static HeadlessSessionDescriptor ProbeDescriptor(
+ string id = "probe-bot",
+ HeadlessCredentialProviderKind provider =
+ HeadlessCredentialProviderKind.Environment,
+ string credentialReference = "PROBE_PASSWORD",
+ string? statusFile = null) => new()
+ {
+ Id = id,
+ Endpoint = new HeadlessEndpointDescriptor
+ {
+ Host = "127.0.0.1",
+ Port = 9000,
+ },
+ Account = "account",
+ Mode = HeadlessSessionMode.Probe,
+ Credential = new HeadlessCredentialReference
+ {
+ Provider = provider,
+ Reference = credentialReference,
+ },
+ StatusFile = statusFile,
+ };
+
private static void HydrateGroundedPlayer(GameRuntime runtime)
{
const uint player = 0x50000002u;
@@ -2773,10 +2963,15 @@ public sealed class HeadlessSessionHostTests
true,
true);
+ /// Campaign LA slice LA2: lets a probe test assert the
+ /// live-session controller never reached EnterWorld.
+ public int EnterWorldCallCount { get; private set; }
+
public void EnterWorld(
WorldSession session,
int activeCharacterIndex)
{
+ EnterWorldCallCount++;
}
public void Tick(WorldSession session)
diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs
index 631a6c1d..3db166b6 100644
--- a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs
+++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs
@@ -28,8 +28,8 @@ public sealed class SessionConfigurationSharedFixtureTests
Assert.Equal("127.0.0.1", session.Endpoint.Host);
Assert.Equal(9000, session.Endpoint.Port);
Assert.Equal("sharedaccount", session.Account);
- Assert.Equal("SharedToon", session.Character.Name);
- Assert.Equal("idle", session.Policy.Id);
+ Assert.Equal("SharedToon", session.Character!.Name);
+ Assert.Equal("idle", session.Policy!.Id);
Assert.Equal(
HeadlessCredentialProviderKind.Environment,
session.Credential.Provider);
diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
index 6a3f49bf..b4ff6a25 100644
--- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
+++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
@@ -389,6 +389,81 @@ public sealed class LiveSessionControllerTests
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
}
+ ///
+ /// Campaign LA slice LA2: the probe short-circuit — connect, receive
+ /// CharacterList, report the roster, then gracefully disconnect via the
+ /// SAME StopCore teardown
+ /// exercises, returning
+ /// instead of ever reaching TrySelectCharacter/ApplySelectedCharacter/
+ /// EnterWorld. Asserted directly against the operations fake:
+ /// stays zero and "enter:*"/
+ /// "selected"/"activate"/"entered" never appear in the call trace.
+ ///
+ [Fact]
+ public void Start_ProbeReportsRosterThenGracefullyDisconnectsWithoutSelectionOrEnterWorld()
+ {
+ var calls = new List();
+ var operations = new TestOperations(calls);
+ var host = new TestHost(calls);
+ var controller = new LiveSessionController(operations);
+
+ LiveSessionStartResult result = controller.Start(
+ LiveOptions(probe: true),
+ host);
+
+ Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status);
+ Assert.Null(result.Selection);
+ Assert.Equal(
+ [
+ "reset", "resolve", "create", "bind", "report-connecting",
+ "connect", "report-connected", "roster", "deactivate",
+ "detach-events", "dispose-session", "detach-session", "reset",
+ ],
+ calls);
+ Assert.DoesNotContain("selected", calls);
+ Assert.DoesNotContain("activate", calls);
+ Assert.DoesNotContain("entered", calls);
+ Assert.Equal(0, operations.EnterWorldCount);
+ LiveSessionRosterReport roster = Assert.Single(host.Rosters);
+ Assert.Equal("Canonical", roster.AccountName);
+ Assert.False(controller.IsInWorld);
+ Assert.Null(controller.CurrentSession);
+ Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
+
+ // The same 4-stage graceful teardown the NoCharacters path uses —
+ // the probe's scope fully converges without requiring
+ // controller.Dispose().
+ LiveSessionOwnershipSnapshot ownership = controller.CaptureOwnership();
+ Assert.Equal(RuntimeTeardownStage.Complete, ownership.LastTeardownStages);
+ Assert.False(ownership.HasActiveSession);
+ Assert.False(ownership.HasRetiredSession);
+ Assert.False(ownership.HasPendingOperation);
+ }
+
+ ///
+ /// The probe short-circuit fires even when the server never returns a
+ /// CharacterList at all (GetCharacters returns null) — a probe is a
+ /// connectivity check, not itself a character-selection operation, so it
+ /// must not fall through to the NoCharacters path.
+ ///
+ [Fact]
+ public void Start_ProbeWithoutCharacterListStillCompletesGracefully()
+ {
+ var calls = new List();
+ var operations = new TestOperations(calls) { Characters = null };
+ var host = new TestHost(calls);
+ var controller = new LiveSessionController(operations);
+
+ LiveSessionStartResult result = controller.Start(
+ LiveOptions(probe: true),
+ host);
+
+ Assert.Equal(LiveSessionStartStatus.ProbeComplete, result.Status);
+ Assert.Empty(host.Rosters);
+ Assert.Equal(0, operations.EnterWorldCount);
+ Assert.False(controller.IsInWorld);
+ }
+
[Theory]
[InlineData("index")]
[InlineData("id")]
@@ -1141,14 +1216,16 @@ public sealed class LiveSessionControllerTests
private static LiveSessionConnectOptions LiveOptions(
bool live = true,
string? user = "user",
- LiveSessionCharacterSelector? selector = null) =>
+ LiveSessionCharacterSelector? selector = null,
+ bool probe = false) =>
new(
live,
"127.0.0.1",
9000,
user ?? string.Empty,
"password",
- selector);
+ selector,
+ probe);
private static CharacterList.Parsed AvailableCharacters() => new(
0u,