merge: Campaign LA LA2 - probe and idle review-closed

# Conflicts:
#	docs/plans/2026-08-14-launcher-campaign.md
#	src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
This commit is contained in:
Erik 2026-08-14 17:30:07 +02:00
commit e01b2cd12f
16 changed files with 1361 additions and 69 deletions

View file

@ -46,11 +46,34 @@ internal sealed record HeadlessSessionDescriptor
[JsonRequired]
public string Account { get; init; } = string.Empty;
[JsonRequired]
public HeadlessCharacterSelector Character { get; init; } = new();
/// <summary>
/// Campaign LA slice LA2: the JSON field is ABSENT for normal play
/// sessions (explicit JSON <c>null</c> is invalid);
/// <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]
public HeadlessBotPolicyDescriptor Policy { get; init; } = new();
/// <summary>
/// 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]
public HeadlessCredentialReference Credential { get; init; } = new();
@ -140,6 +163,21 @@ internal sealed class HeadlessBotPolicyDescriptor
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. This deliberately uses
/// <see cref="HeadlessConfigurationLoader"/>'s global camel-case,
/// string-only enum converter; a per-enum converter with its default options
/// would accidentally accept numeric <c>0</c> as a second probe spelling.
/// </summary>
internal enum HeadlessSessionMode
{
Probe,
}
/// <summary>See <see cref="HeadlessBotPolicyDescriptor.Role"/>.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessBotPolicyRole>))]
internal enum HeadlessBotPolicyRole

View file

@ -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<HeadlessConfiguration>(
stream,
Options);
document.RootElement.Deserialize<HeadlessConfiguration>(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<string>(StringComparer.Ordinal);
var credentialReferences = new HashSet<string>(
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);
}
/// <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,
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))
/// <summary>
/// Campaign LA LA2 review fix: the pinned launch contract distinguishes
/// an omitted conditional field from a field explicitly authored as JSON
/// <c>null</c>. 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.
/// </summary>
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);
}
/// <summary>

View file

@ -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)

View file

@ -120,6 +120,15 @@ internal sealed class HeadlessSessionHost : IDisposable
/// rework of the shared-stdout diagnostics writer.
/// </summary>
private readonly SessionStatusWriter _statusWriter;
/// <summary>
/// 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 <c>reason:"probe"</c> after this records
/// <see cref="RuntimeSessionStartStatus.ProbeComplete"/>. Any other
/// non-connected result maps to the same connection-error code returned by
/// <see cref="HeadlessProcessHost"/>.
/// </summary>
private RuntimeSessionStartStatus? _startOutcome;
/// <summary>Guards <see cref="Stop"/>'s <c>disconnected</c> 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
}
}
/// <summary>
/// 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 <see cref="SessionStatusWriter"/> make this event
/// exact-once even when disposal is retried.
/// </summary>
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);
/// <summary>Campaign LA slice LA2: <see langword="null"/> for a probe
/// session (the loader guarantees <c>Character</c> is omitted whenever
/// <c>Mode</c> is <see cref="HeadlessSessionMode.Probe"/>) — a probe
/// never reaches <c>TrySelectCharacter</c>, so "no selector configured"
/// is the correct, harmless mapping.</summary>
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,

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
{
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>
/// Explicit connected-gate policy: wait for the local player, issue one
/// harmless local-speech command and one lifestone recall, reconnect after

View file

@ -27,6 +27,14 @@ public enum RuntimeSessionStartStatus
Failed,
Inactive,
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(

View file

@ -13,7 +13,23 @@ public sealed record LiveSessionConnectOptions(
int Port,
string User,
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
{

View file

@ -13,6 +13,14 @@ public enum LiveSessionStartStatus
Connected,
Deferred,
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(
@ -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,

View file

@ -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,