wip: Campaign LA LA2 probe mode + idle policy — INCOMPLETE, stopped mid-task

Agent was stopped for token budget. Landed here: probe flag through
LiveSessionConnectOptions + the StartCore short-circuit, the mode field
with JsonRequired-to-semantic-validation move, host exit-code mapping,
and 34 passing tests including 3 new probe tests (agent last reported
green before the stop). NOT DONE: the idle-policy unit tests (next
step), full-suite verification, and the WSL run.

Build/test state UNVERIFIED at this commit. Next session: finish idle
policy tests, run Runtime+Headless Release suites Windows and WSL, then
dispatch the Opus dual-lens review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 16:33:00 +02:00
parent 498f1c1182
commit c601942467
14 changed files with 830 additions and 35 deletions

View file

@ -46,11 +46,33 @@ internal sealed record HeadlessSessionDescriptor
[JsonRequired] [JsonRequired]
public string Account { get; init; } = string.Empty; public string Account { get; init; } = string.Empty;
[JsonRequired] /// <summary>
public HeadlessCharacterSelector Character { get; init; } = new(); /// Campaign LA slice LA2: ABSENT (<see langword="null"/>) for normal play
/// sessions; <see cref="HeadlessSessionMode.Probe"/> for the LA2 probe
/// (connect → characterList → graceful disconnect, never EnterWorld) —
/// the pinned launch-contract schema's <c>mode</c> field
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1/LA2).
/// <see cref="Character"/>/<see cref="Policy"/> requiredness depends on
/// this value, which is why their requiredness lives in
/// <see cref="HeadlessConfigurationLoader"/>'s semantic validation rather
/// than a <c>[JsonRequired]</c> attribute — that attribute fires during
/// deserialization, before <see cref="Mode"/> can be inspected at all.
/// </summary>
public HeadlessSessionMode? Mode { get; init; }
[JsonRequired] /// <summary>
public HeadlessBotPolicyDescriptor Policy { get; init; } = new(); /// Required for play sessions (<see cref="Mode"/> absent); MUST be
/// omitted for probe sessions (<see cref="HeadlessSessionMode.Probe"/>) —
/// the pinned contract keeps the shape unambiguous by forbidding a probe
/// session from also declaring a selector. Enforced by
/// <see cref="HeadlessConfigurationLoader.ValidateSession"/>, not
/// <c>[JsonRequired]</c> (see this record's own doc on <see cref="Mode"/>).
/// </summary>
public HeadlessCharacterSelector? Character { get; init; }
/// <summary>Same mode-dependent requiredness as <see cref="Character"/>:
/// required for play sessions, forbidden for probe sessions.</summary>
public HeadlessBotPolicyDescriptor? Policy { get; init; }
[JsonRequired] [JsonRequired]
public HeadlessCredentialReference Credential { get; init; } = new(); public HeadlessCredentialReference Credential { get; init; } = new();
@ -140,6 +162,19 @@ internal sealed class HeadlessBotPolicyDescriptor
public HeadlessBotPolicyRole? Role { get; init; } public HeadlessBotPolicyRole? Role { get; init; }
} }
/// <summary>
/// Campaign LA slice LA2: see <see cref="HeadlessSessionDescriptor.Mode"/>.
/// The pinned launch-contract schema defines exactly two states for a
/// session — ABSENT (mapped to <see langword="null"/>, meaning "play") or
/// the literal string <c>"probe"</c> — so <see cref="Probe"/> is the only
/// member; there is no explicit "play" spelling.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessSessionMode>))]
internal enum HeadlessSessionMode
{
Probe,
}
/// <summary>See <see cref="HeadlessBotPolicyDescriptor.Role"/>.</summary> /// <summary>See <see cref="HeadlessBotPolicyDescriptor.Role"/>.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessBotPolicyRole>))] [JsonConverter(typeof(JsonStringEnumConverter<HeadlessBotPolicyRole>))]
internal enum HeadlessBotPolicyRole internal enum HeadlessBotPolicyRole

View file

@ -182,6 +182,57 @@ internal static class HeadlessConfigurationLoader
$"Session '{session.Id}' requires a non-empty account."); $"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);
}
/// <summary>
/// Campaign LA slice LA2: mode-dependent requiredness for
/// <see cref="HeadlessSessionDescriptor.Character"/>/
/// <see cref="HeadlessSessionDescriptor.Policy"/> — 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 <see cref="HeadlessConfigurationException"/>
/// naming the field instead of a raw <see cref="JsonException"/> citing
/// "missing required properties" — same exit code (3,
/// <c>HeadlessExitCode.ConfigurationError</c>) 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.
/// </summary>
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) if (session.Character is null)
{ {
throw new HeadlessConfigurationException( throw new HeadlessConfigurationException(
@ -206,16 +257,6 @@ internal static class HeadlessConfigurationLoader
throw new HeadlessConfigurationException( throw new HeadlessConfigurationException(
$"Session '{session.Id}' requires a non-empty policy id."); $"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);
} }
/// <summary> /// <summary>

View file

@ -200,6 +200,22 @@ internal sealed class HeadlessProcessHost : IDisposable
foreach (HeadlessSessionHost session in _sessions) foreach (HeadlessSessionHost session in _sessions)
{ {
RuntimeSessionStartResult started = session.Start(); 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.Status != RuntimeSessionStartStatus.Connected)
{ {
if (started.Error is { } error) if (started.Error is { } error)

View file

@ -319,7 +319,7 @@ internal sealed class HeadlessSessionHost : IDisposable
// doc). Gating on role keeps two sessions // doc). Gating on role keeps two sessions
// writing the SAME field from ever racing — // writing the SAME field from ever racing —
// only one role ever writes it. // only one role ever writes it.
if (descriptor.Policy.Role if (descriptor.Policy?.Role
== HeadlessBotPolicyRole.Recruit == HeadlessBotPolicyRole.Recruit
&& gateCoordinator is not null) && gateCoordinator is not null)
{ {
@ -366,13 +366,24 @@ internal sealed class HeadlessSessionHost : IDisposable
hostLease = runtime.AcquireHostLease( hostLease = runtime.AcquireHostLease(
$"headless:{descriptor.Id}"); $"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 policy = policyOverride
?? HeadlessBotPolicyFactory.Create( ?? (descriptor.Mode == HeadlessSessionMode.Probe
descriptor.Policy, ? new ProbeHeadlessBotPolicy()
runtime, : HeadlessBotPolicyFactory.Create(
() => _pendingConfirmation, descriptor.Policy!,
RespondToConfirmation, runtime,
gateCoordinator); () => _pendingConfirmation,
RespondToConfirmation,
gateCoordinator));
policySubscription = runtime.Subscribe(policy); policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle( diagnostics.Lifecycle(
descriptor.Id, descriptor.Id,
@ -636,11 +647,18 @@ internal sealed class HeadlessSessionHost : IDisposable
_stoppedGeneration); _stoppedGeneration);
// Campaign LA slice LA1: "exited" = terminal — the sole // Campaign LA slice LA1: "exited" = terminal — the sole
// point every disposal path (graceful and post- // 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( _statusWriter.Exited(
_descriptor.Id, _descriptor.Id,
_faulted ? 1 : 0, _faulted ? 1 : 0,
_faulted ? "fault" : "disposed"); _faulted
? "fault"
: _descriptor.Mode == HeadlessSessionMode.Probe
? "probe"
: "disposed");
_disposeStage++; _disposeStage++;
_disposed = true; _disposed = true;
break; break;
@ -717,7 +735,8 @@ internal sealed class HeadlessSessionHost : IDisposable
_descriptor.Endpoint.Port, _descriptor.Endpoint.Port,
_descriptor.Account, _descriptor.Account,
password, password,
MapCharacterSelector(_descriptor.Character)); MapCharacterSelector(_descriptor.Character),
Probe: _descriptor.Mode == HeadlessSessionMode.Probe);
LiveSessionStartResult result = _liveSession.Start(options); LiveSessionStartResult result = _liveSession.Start(options);
if (result.Selection is { } selection) if (result.Selection is { } selection)
_accountName = selection.AccountName; _accountName = selection.AccountName;
@ -964,12 +983,19 @@ internal sealed class HeadlessSessionHost : IDisposable
return declared; return declared;
} }
private static LiveSessionCharacterSelector MapCharacterSelector( /// <summary>Campaign LA slice LA2: <see langword="null"/> for a probe
HeadlessCharacterSelector selector) => /// session (the loader guarantees <c>Character</c> is omitted whenever
new( /// <c>Mode</c> is <see cref="HeadlessSessionMode.Probe"/>) — a probe
selector.Index, /// never reaches <c>TrySelectCharacter</c>, so "no selector configured"
selector.Id, /// is the correct, harmless mapping.</summary>
selector.Name); private static LiveSessionCharacterSelector? MapCharacterSelector(
HeadlessCharacterSelector? selector) =>
selector is null
? null
: new(
selector.Index,
selector.Id,
selector.Name);
private RuntimeSessionStartResult Convert( private RuntimeSessionStartResult Convert(
LiveSessionStartResult result) LiveSessionStartResult result)
@ -988,6 +1014,8 @@ internal sealed class HeadlessSessionHost : IDisposable
RuntimeSessionStartStatus.Deferred, RuntimeSessionStartStatus.Deferred,
LiveSessionStartStatus.Failed => LiveSessionStartStatus.Failed =>
RuntimeSessionStartStatus.Failed, RuntimeSessionStartStatus.Failed,
LiveSessionStartStatus.ProbeComplete =>
RuntimeSessionStartStatus.ProbeComplete,
_ => throw new ArgumentOutOfRangeException( _ => throw new ArgumentOutOfRangeException(
nameof(result), nameof(result),
result.Status, result.Status,

View file

@ -100,6 +100,21 @@ internal static class HeadlessBotPolicyFactory
} }
} }
/// <summary>
/// Campaign LA slice LA2: the "idle" consumer policy id — the session enters
/// world (unchanged <see cref="HeadlessSessionHost"/> start/select/EnterWorld
/// path) and then does nothing actively: no chat, no movement, no combat.
/// <see cref="IsComplete"/> is permanently <see langword="false"/>, so
/// <see cref="HeadlessProcessScheduler"/> keeps ticking the session
/// (harmlessly — <see cref="Tick"/> and every delta handler below are no-ops)
/// until the process is stopped (SIGINT/cancellation) or disposed; teardown
/// then rides <see cref="HeadlessSessionHost.Dispose"/>'s existing graceful
/// stop/logout path — the same mechanism K4's endurance gate already proved.
/// No <see cref="HeadlessBotPolicyDescriptor.Role"/> 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 <c>HeadlessBotPolicyTests</c>.
/// </summary>
internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy
{ {
public bool IsComplete => false; public bool IsComplete => false;
@ -149,6 +164,70 @@ internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy
} }
} }
/// <summary>
/// Campaign LA slice LA2: the policy substituted (never selected via
/// <see cref="HeadlessBotPolicyFactory.Create"/> — a probe session's
/// descriptor carries no <c>policy</c> id at all) for a
/// <see cref="HeadlessSessionMode.Probe"/> session. <see cref="IsComplete"/>
/// is <see langword="true"/> from construction, BEFORE
/// <see cref="HeadlessSessionHost.Start"/> even runs, so
/// <see cref="HeadlessProcessScheduler"/> never dispatches a tick to this
/// session — a probe session's <see cref="AcDream.Runtime.Session.WorldSession"/>
/// is already gracefully torn down by
/// <see cref="AcDream.Runtime.Session.LiveSessionController"/>'s probe
/// short-circuit by the time the scheduler would otherwise look at it, and a
/// single-session probe process's <c>Run()</c> loop returns immediately
/// instead of waiting for SIGINT.
/// </summary>
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()
{
}
}
/// <summary> /// <summary>
/// Explicit connected-gate policy: wait for the local player, issue one /// Explicit connected-gate policy: wait for the local player, issue one
/// harmless local-speech command and one lifestone recall, reconnect after /// harmless local-speech command and one lifestone recall, reconnect after

View file

@ -27,6 +27,14 @@ public enum RuntimeSessionStartStatus
Failed, Failed,
Inactive, Inactive,
StaleGeneration, StaleGeneration,
/// <summary>
/// Campaign LA slice LA2: mirrors
/// <see cref="Session.LiveSessionStartStatus.ProbeComplete"/> — 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.
/// </summary>
ProbeComplete,
} }
public readonly record struct RuntimeSessionStartResult( public readonly record struct RuntimeSessionStartResult(

View file

@ -13,7 +13,23 @@ public sealed record LiveSessionConnectOptions(
int Port, int Port,
string User, string User,
string Password, string Password,
LiveSessionCharacterSelector? Character = null); LiveSessionCharacterSelector? Character = null,
/// <summary>
/// Campaign LA slice LA2: short-circuits
/// <see cref="LiveSessionController"/>'s connect transaction right after
/// the roster report (before <c>TrySelectCharacter</c>/
/// <c>ApplySelectedCharacter</c>/<c>EnterWorld</c>) — connect, receive
/// <c>CharacterList</c>, report the roster, gracefully disconnect via the
/// same <c>StopCore</c> teardown the <see cref="LiveSessionStartStatus.NoCharacters"/>
/// path already uses, and return
/// <see cref="LiveSessionStartStatus.ProbeComplete"/>. The pinned launch
/// contract (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's
/// <c>mode</c> field) requires a probe session to omit both
/// <see cref="Character"/> and its policy entirely, but the controller
/// itself does not enforce that pairing — the headless config loader
/// does, before a <see cref="LiveSessionConnectOptions"/> is ever built.
/// </summary>
bool Probe = false);
public interface IRuntimeLiveSessionFramePhase public interface IRuntimeLiveSessionFramePhase
{ {

View file

@ -13,6 +13,14 @@ public enum LiveSessionStartStatus
Connected, Connected,
Deferred, Deferred,
Failed, Failed,
/// <summary>
/// Campaign LA slice LA2: a <see cref="LiveSessionConnectOptions.Probe"/>
/// session connected, received (and reported) the character roster, and
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
/// SUCCESS variant of the <see cref="NoCharacters"/> early-exit shape
/// (same <c>StopCore</c> teardown), not a failure.
/// </summary>
ProbeComplete,
} }
public readonly record struct LiveSessionOwnershipSnapshot( public readonly record struct LiveSessionOwnershipSnapshot(
@ -647,6 +655,24 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); 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 if (characters is null
|| !TrySelectCharacter( || !TrySelectCharacter(
characters, characters,

View file

@ -316,6 +316,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
RuntimeSessionStartStatus.Deferred, RuntimeSessionStartStatus.Deferred,
LiveSessionStartStatus.Failed => LiveSessionStartStatus.Failed =>
RuntimeSessionStartStatus.Failed, RuntimeSessionStartStatus.Failed,
LiveSessionStartStatus.ProbeComplete =>
RuntimeSessionStartStatus.ProbeComplete,
_ => throw new ArgumentOutOfRangeException( _ => throw new ArgumentOutOfRangeException(
nameof(result), nameof(result),
result.Status, result.Status,

View file

@ -208,6 +208,208 @@ public sealed class HeadlessConfigurationLoaderTests
Assert.Empty(declared!); 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);
}
/// <summary>
/// A play session (mode absent) missing `character` must still fail —
/// the LA2 change moved this requiredness from `[JsonRequired]` (a raw
/// <see cref="System.Text.Json.JsonException"/> at deserialize time) to
/// <see cref="HeadlessConfigurationLoader.ValidateSession"/>'s semantic
/// check (a <see cref="HeadlessConfigurationException"/> naming the
/// missing field). Exit-code parity (both map to
/// <c>HeadlessExitCode.ConfigurationError</c>) is proven at
/// <c>HeadlessEntryPointTests</c>; this test pins the loader-level
/// exception type/message.
/// </summary>
[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);
}
/// <summary>Same parity claim as
/// <see cref="PlaySessionMissingCharacterStillFailsLoad"/> for the
/// `policy` field.</summary>
[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<HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(badSelector.Path));
Assert.Throws<HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(blankPolicy.Path));
}
private static string ConfigurationWith(params string[] sessions) => private static string ConfigurationWith(params string[] sessions) =>
$$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}"""; $$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}""";

View file

@ -191,6 +191,76 @@ public sealed class HeadlessEntryPointTests
Assert.Contains(expected, error.ToString()); Assert.Contains(expected, error.ToString());
} }
/// <summary>
/// Campaign LA slice LA2: before this change, an omitted `character` or
/// `policy` field failed deserialization itself with a raw
/// <see cref="System.Text.Json.JsonException"/> ("missing required
/// properties") — this test pins that the LA2 move to semantic
/// validation (<see cref="AcDream.Headless.Configuration.HeadlessConfigurationException"/>
/// naming the exact missing field) preserves the SAME exit code
/// (3, <c>HeadlessExitCode.ConfigurationError</c>) end to end through
/// <see cref="HeadlessEntryPoint.Run(IReadOnlyList{string}, TextWriter, TextWriter)"/>.
/// The message text change (generic → field-naming) is a deliberate,
/// accepted improvement, not a contract break.
/// </summary>
[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());
}
/// <summary>
/// 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.
/// </summary>
[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] [Fact]
public void UnknownCommandReturnsUsageError() public void UnknownCommandReturnsUsageError()
{ {

View file

@ -151,6 +151,170 @@ public sealed class HeadlessSessionHostTests
// writer is a permanent no-op with no configured path. // writer is a permanent no-op with no configured path.
} }
/// <summary>
/// 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).
/// </summary>
[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);
}
}
/// <summary>
/// Campaign LA slice LA2: <see cref="HeadlessProcessHost.RunOnUpdateThread"/>
/// maps a ProbeComplete start to <see cref="HeadlessExitCode.Success"/>
/// (0) rather than <see cref="HeadlessExitCode.ConnectionError"/> — a
/// single-session probe-only process must exit cleanly and promptly
/// without ever needing SIGINT/cancellation, because
/// ProbeHeadlessBotPolicy reports IsComplete immediately.
/// </summary>
[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);
}
/// <summary>
/// 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).
/// </summary>
[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] [Fact]
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
{ {
@ -2086,6 +2250,32 @@ public sealed class HeadlessSessionHostTests
StatusFile = statusFile, StatusFile = statusFile,
}; };
/// <summary>Campaign LA slice LA2: a probe-mode descriptor — mode
/// "probe", <c>Character</c>/<c>Policy</c> both omitted per the pinned
/// contract shape <see cref="HeadlessConfigurationLoader"/> enforces.</summary>
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) private static void HydrateGroundedPlayer(GameRuntime runtime)
{ {
const uint player = 0x50000002u; const uint player = 0x50000002u;
@ -2773,10 +2963,15 @@ public sealed class HeadlessSessionHostTests
true, true,
true); true);
/// <summary>Campaign LA slice LA2: lets a probe test assert the
/// live-session controller never reached EnterWorld.</summary>
public int EnterWorldCallCount { get; private set; }
public void EnterWorld( public void EnterWorld(
WorldSession session, WorldSession session,
int activeCharacterIndex) int activeCharacterIndex)
{ {
EnterWorldCallCount++;
} }
public void Tick(WorldSession session) public void Tick(WorldSession session)

View file

@ -28,8 +28,8 @@ public sealed class SessionConfigurationSharedFixtureTests
Assert.Equal("127.0.0.1", session.Endpoint.Host); Assert.Equal("127.0.0.1", session.Endpoint.Host);
Assert.Equal(9000, session.Endpoint.Port); Assert.Equal(9000, session.Endpoint.Port);
Assert.Equal("sharedaccount", session.Account); Assert.Equal("sharedaccount", session.Account);
Assert.Equal("SharedToon", session.Character.Name); Assert.Equal("SharedToon", session.Character!.Name);
Assert.Equal("idle", session.Policy.Id); Assert.Equal("idle", session.Policy!.Id);
Assert.Equal( Assert.Equal(
HeadlessCredentialProviderKind.Environment, HeadlessCredentialProviderKind.Environment,
session.Credential.Provider); session.Credential.Provider);

View file

@ -389,6 +389,81 @@ public sealed class LiveSessionControllerTests
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]); Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
} }
/// <summary>
/// Campaign LA slice LA2: the probe short-circuit — connect, receive
/// CharacterList, report the roster, then gracefully disconnect via the
/// SAME StopCore teardown <see cref="Start_NoAvailableCharacterTearsDownExactScope"/>
/// exercises, returning <see cref="LiveSessionStartStatus.ProbeComplete"/>
/// instead of ever reaching TrySelectCharacter/ApplySelectedCharacter/
/// EnterWorld. Asserted directly against the operations fake:
/// <see cref="TestOperations.EnterWorldCount"/> stays zero and "enter:*"/
/// "selected"/"activate"/"entered" never appear in the call trace.
/// </summary>
[Fact]
public void Start_ProbeReportsRosterThenGracefullyDisconnectsWithoutSelectionOrEnterWorld()
{
var calls = new List<string>();
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);
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Start_ProbeWithoutCharacterListStillCompletesGracefully()
{
var calls = new List<string>();
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] [Theory]
[InlineData("index")] [InlineData("index")]
[InlineData("id")] [InlineData("id")]
@ -1141,14 +1216,16 @@ public sealed class LiveSessionControllerTests
private static LiveSessionConnectOptions LiveOptions( private static LiveSessionConnectOptions LiveOptions(
bool live = true, bool live = true,
string? user = "user", string? user = "user",
LiveSessionCharacterSelector? selector = null) => LiveSessionCharacterSelector? selector = null,
bool probe = false) =>
new( new(
live, live,
"127.0.0.1", "127.0.0.1",
9000, 9000,
user ?? string.Empty, user ?? string.Empty,
"password", "password",
selector); selector,
probe);
private static CharacterList.Parsed AvailableCharacters() => new( private static CharacterList.Parsed AvailableCharacters() => new(
0u, 0u,