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:
commit
e01b2cd12f
16 changed files with 1361 additions and 69 deletions
|
|
@ -488,7 +488,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|
|||
|---|---|---|---|---|
|
||||
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
|
||||
| LA1 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1–F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. |
|
||||
| LA2 | — | | | |
|
||||
| LA2 | **DONE 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Release builds green on Windows and Ubuntu/WSL; Runtime 1,632/1,632 and Headless 149/149 on both. Repeated live ACE probe remains the LA11 user gate. |
|
||||
| LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. |
|
||||
| LA4 | — | | | |
|
||||
| LA5 | — | | | |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
77
tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs
Normal file
77
tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
using System.Reflection;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Runtime;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
public sealed class HeadlessBotPolicyTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: <c>idle</c> is a deliberately passive,
|
||||
/// non-terminal policy. It must neither inspect Runtime state nor reach
|
||||
/// any command surface, and no event can make it complete on its own.
|
||||
/// The process scheduler therefore keeps the play session alive until
|
||||
/// external cancellation/stop drives the host's ordinary teardown path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IdlePolicyIsPassiveAndNeverCompletesAutonomously()
|
||||
{
|
||||
var policy = new IdleHeadlessBotPolicy();
|
||||
IGameRuntimeView view = CreateNoTouchProxy<IGameRuntimeView>(
|
||||
out InvocationCountingProxy viewCalls);
|
||||
IGameRuntimeCommands commands =
|
||||
CreateNoTouchProxy<IGameRuntimeCommands>(
|
||||
out InvocationCountingProxy commandCalls);
|
||||
|
||||
for (int index = 0; index < 3; index++)
|
||||
policy.Tick(view, commands);
|
||||
|
||||
RuntimeLifecycleDelta lifecycle = default;
|
||||
RuntimeCommandDelta command = default;
|
||||
RuntimeEntityDelta entity = default;
|
||||
RuntimeInventoryDelta inventory = default;
|
||||
RuntimeChatDelta chat = default;
|
||||
RuntimeMovementDelta movement = default;
|
||||
RuntimePortalDelta portal = default;
|
||||
RuntimeCombatDelta combat = default;
|
||||
policy.OnLifecycle(in lifecycle);
|
||||
policy.OnCommand(in command);
|
||||
policy.OnEntity(in entity);
|
||||
policy.OnInventory(in inventory);
|
||||
policy.OnChat(in chat);
|
||||
policy.OnMovement(in movement);
|
||||
policy.OnPortal(in portal);
|
||||
policy.OnCombat(in combat);
|
||||
|
||||
Assert.False(policy.IsComplete);
|
||||
Assert.Equal(0, viewCalls.InvocationCount);
|
||||
Assert.Equal(0, commandCalls.InvocationCount);
|
||||
|
||||
policy.Dispose();
|
||||
policy.Dispose();
|
||||
Assert.False(policy.IsComplete);
|
||||
}
|
||||
|
||||
private static T CreateNoTouchProxy<T>(
|
||||
out InvocationCountingProxy proxy)
|
||||
where T : class
|
||||
{
|
||||
T value = DispatchProxy.Create<T, InvocationCountingProxy>();
|
||||
proxy = (InvocationCountingProxy)(object)value;
|
||||
return value;
|
||||
}
|
||||
|
||||
public class InvocationCountingProxy : DispatchProxy
|
||||
{
|
||||
public int InvocationCount { get; private set; }
|
||||
|
||||
protected override object? Invoke(
|
||||
MethodInfo? targetMethod,
|
||||
object?[]? args)
|
||||
{
|
||||
InvocationCount++;
|
||||
throw new InvalidOperationException(
|
||||
$"Idle policy unexpectedly invoked {targetMethod?.Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -208,6 +208,339 @@ public sealed class HeadlessConfigurationLoaderTests
|
|||
Assert.Empty(declared!);
|
||||
}
|
||||
|
||||
// ── Campaign LA slice LA2: probe-mode `mode` field shape validation ──
|
||||
|
||||
[Fact]
|
||||
public void ProbeSessionOmittingCharacterAndPolicyLoads()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "probe-session",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": "probe",
|
||||
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(file.Path);
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
||||
Assert.Null(session.Character);
|
||||
Assert.Null(session.Policy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pinned v1 contract has one named mode value: <c>"probe"</c>.
|
||||
/// In particular, the enum's underlying numeric zero must not become an
|
||||
/// accidental second spelling through an enum converter configured to
|
||||
/// allow integers.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("\"play\"")]
|
||||
[InlineData("0")]
|
||||
public void SessionModeRejectsEveryValueOtherThanTheNamedProbeMode(
|
||||
string modeJson)
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
$$"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "unsupported-mode",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": {{modeJson}},
|
||||
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<JsonException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA LA2 review fix: the pinned contract is presence-aware.
|
||||
/// Normal play omits mode and supplies character/policy; probe supplies
|
||||
/// mode and omits character/policy. JSON null is not another spelling of
|
||||
/// omission for any of those conditional fields.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(false, "mode")]
|
||||
[InlineData(false, "character")]
|
||||
[InlineData(false, "policy")]
|
||||
[InlineData(true, "character")]
|
||||
[InlineData(true, "policy")]
|
||||
public void ConditionalSessionFieldsRejectExplicitJsonNull(
|
||||
bool probe,
|
||||
string nullField)
|
||||
{
|
||||
string mode = probe
|
||||
? "\"mode\":\"probe\","
|
||||
: nullField == "mode"
|
||||
? "\"mode\":null,"
|
||||
: string.Empty;
|
||||
string character = nullField == "character"
|
||||
? "\"character\":null,"
|
||||
: probe
|
||||
? string.Empty
|
||||
: "\"character\":{\"index\":0},";
|
||||
string policy = nullField == "policy"
|
||||
? "\"policy\":null,"
|
||||
: probe
|
||||
? string.Empty
|
||||
: "\"policy\":{\"id\":\"idle\"},";
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
$$"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "explicit-null",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
{{mode}}
|
||||
{{character}}
|
||||
{{policy}}
|
||||
"credential": { "provider": "environment", "reference": "PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfigurationException exception = Assert.Throws<
|
||||
HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains(
|
||||
$"'{nullField}'",
|
||||
exception.Message,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"cannot be null",
|
||||
exception.Message,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresenceAwareValidationKeepsUnmappedMemberRejection()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
ConfigurationWith(Session(
|
||||
"bot",
|
||||
"PASSWORD",
|
||||
"\"notAContractField\":true")));
|
||||
|
||||
Assert.Throws<JsonException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresenceAwareValidationKeepsTypedValueRejection()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "wrong-type",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": { "value": "probe" },
|
||||
"credential": { "provider": "environment", "reference": "PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<JsonException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "probe-with-character",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": "probe",
|
||||
"character": { "index": 0 },
|
||||
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfigurationException exception = Assert.Throws<
|
||||
HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("character", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbeSessionDeclaringPolicyFailsLoadNamingTheField()
|
||||
{
|
||||
using TemporaryConfiguration file = TemporaryConfiguration.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "probe-with-policy",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"mode": "probe",
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfigurationException exception = Assert.Throws<
|
||||
HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
|
||||
Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("policy", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <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) =>
|
||||
$$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}""";
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,76 @@ public sealed class HeadlessEntryPointTests
|
|||
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]
|
||||
public void UnknownCommandReturnsUsageError()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
|
|
@ -218,17 +219,217 @@ public sealed class HeadlessSessionHostTests
|
|||
// writer is a permanent no-op with no configured path.
|
||||
}
|
||||
|
||||
/// <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 async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
||||
public void ProbeSessionEmitsRosterThenExitsSuccessfullyWithoutEnteringWorld()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-probe-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
ProbeDescriptor(statusFile: statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult started = host.Start();
|
||||
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, started.Status);
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.False(host.Runtime.Session.IsInWorld);
|
||||
|
||||
host.Dispose();
|
||||
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
string[] eventNames = lines
|
||||
.Select(line => JsonDocument.Parse(line)
|
||||
.RootElement.GetProperty("e").GetString()!)
|
||||
.ToArray();
|
||||
Assert.DoesNotContain("enteredWorld", eventNames);
|
||||
Assert.Contains("characterList", eventNames);
|
||||
Assert.Contains("exited", eventNames);
|
||||
Assert.True(
|
||||
Array.IndexOf(eventNames, "characterList")
|
||||
< Array.IndexOf(eventNames, "exited"),
|
||||
"characterList must land before the terminal exited event.");
|
||||
|
||||
using JsonDocument exitedDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "exited")]);
|
||||
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
|
||||
Assert.Equal(
|
||||
"probe",
|
||||
exitedDoc.RootElement.GetProperty("reason").GetString());
|
||||
|
||||
string contents = File.ReadAllText(statusPath);
|
||||
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 LA2 review fix: configured probe intent is not proof of a
|
||||
/// completed probe. If the connected session produces no CharacterList,
|
||||
/// Runtime returns NoCharacters, the process returns ConnectionError, and
|
||||
/// the sole terminal status event reports that same non-success instead of
|
||||
/// the former false code-0/reason-probe pair.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ProbeWithoutRosterReportsTheProcessConnectionErrorExactlyOnce()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-probe-no-roster-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
ProbeDescriptor(
|
||||
provider: HeadlessCredentialProviderKind.StandardInput,
|
||||
credentialReference: "probe-password",
|
||||
statusFile: statusPath),
|
||||
],
|
||||
};
|
||||
var operations = new FixtureSessionOperations
|
||||
{
|
||||
Characters = null,
|
||||
};
|
||||
using var diagnostics = new StringWriter();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
|
||||
new System.IO.StringReader(
|
||||
"probe-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
|
||||
HeadlessExitCode result = await host.RunAsync(
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(HeadlessExitCode.ConnectionError, result);
|
||||
Assert.Equal(0, operations.EnterWorldCallCount);
|
||||
Assert.Equal(1, operations.DisposedSessionCount);
|
||||
|
||||
host.Dispose();
|
||||
host.Dispose();
|
||||
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
JsonElement[] events = lines
|
||||
.Select(static line =>
|
||||
JsonDocument.Parse(line).RootElement.Clone())
|
||||
.ToArray();
|
||||
Assert.Equal(
|
||||
["started", "connected", "disconnected", "exited"],
|
||||
events.Select(static item =>
|
||||
item.GetProperty("e").GetString()));
|
||||
Assert.DoesNotContain(
|
||||
events,
|
||||
static item =>
|
||||
item.GetProperty("e").GetString() == "characterList");
|
||||
JsonElement exited = Assert.Single(
|
||||
events,
|
||||
static item => item.GetProperty("e").GetString() == "exited");
|
||||
Assert.Equal(
|
||||
(int)result,
|
||||
exited.GetProperty("code").GetInt32());
|
||||
Assert.Equal(
|
||||
"connection-error",
|
||||
exited.GetProperty("reason").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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,
|
||||
"stdin-bot"),
|
||||
"play-password"),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
|
|
@ -239,20 +440,144 @@ public sealed class HeadlessSessionHostTests
|
|||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader(
|
||||
"process-password" + Environment.NewLine),
|
||||
"probe-password" + Environment.NewLine
|
||||
+ "play-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
|
||||
HeadlessExitCode result =
|
||||
await host.RunAsync(cancellation.Token);
|
||||
HeadlessExitCode result = await host.RunAsync(cancellation.Token);
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
diagnostics.ToString());
|
||||
Assert.Equal(2, host.Sessions.Count);
|
||||
HeadlessSessionHost probeSession = Assert.Single(
|
||||
host.Sessions,
|
||||
s => s.SessionId == "probe-sibling");
|
||||
HeadlessSessionHost playSession = Assert.Single(
|
||||
host.Sessions,
|
||||
s => s.SessionId == "bot");
|
||||
Assert.False(probeSession.Runtime.Session.IsInWorld);
|
||||
Assert.True(playSession.Runtime.Session.IsInWorld);
|
||||
Assert.False(playSession.IsFaulted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: the configured <c>idle</c> policy follows the
|
||||
/// normal play shape (selector + policy, mode absent), enters world, and
|
||||
/// remains non-terminal through real scheduler turns until cancellation.
|
||||
/// Cancellation stops the process loop; the owning host's ordinary
|
||||
/// disposal transaction then performs graceful session teardown. Status
|
||||
/// events must describe those boundaries truthfully and remain exactly
|
||||
/// once even when disposal is repeated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var configuration = new HeadlessConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Sessions =
|
||||
[
|
||||
Descriptor(
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
"stdin-bot",
|
||||
statusFile: statusPath),
|
||||
],
|
||||
};
|
||||
HeadlessPathSet paths = HeadlessPathSet.Resolve(
|
||||
new HeadlessPathOverrides());
|
||||
using var diagnostics = new StringWriter();
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var host = new HeadlessProcessHost(
|
||||
configuration,
|
||||
paths,
|
||||
new System.IO.StringReader(
|
||||
"process-password" + Environment.NewLine),
|
||||
diagnostics,
|
||||
operations);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
|
||||
Task<HeadlessExitCode> run = host.RunAsync(cancellation.Token);
|
||||
var timeout = Stopwatch.StartNew();
|
||||
while (operations.TickCallCount < 3
|
||||
&& !run.IsCompleted
|
||||
&& timeout.Elapsed < TimeSpan.FromSeconds(10))
|
||||
{
|
||||
await Task.Delay(5);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
operations.TickCallCount >= 3,
|
||||
$"Expected at least 3 idle scheduler turns, observed {operations.TickCallCount}.");
|
||||
Assert.False(run.IsCompleted);
|
||||
Assert.Equal(1, operations.EnterWorldCallCount);
|
||||
Assert.Equal("Headless", host.Session.ActiveCharacterName);
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.False(host.Session.IsPolicyComplete);
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList", "enteredWorld"],
|
||||
ReadStatusEventNames(statusPath));
|
||||
|
||||
cancellation.Cancel();
|
||||
HeadlessExitCode result = await run.WaitAsync(
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(HeadlessExitCode.Success, result);
|
||||
// RunAsync owns scheduling, not the host lifetime. The session
|
||||
// remains honestly connected until its owner disposes it.
|
||||
Assert.True(host.Session.Runtime.Session.IsInWorld);
|
||||
Assert.Equal(
|
||||
["started", "connected", "characterList", "enteredWorld"],
|
||||
ReadStatusEventNames(statusPath));
|
||||
|
||||
host.Dispose();
|
||||
host.Dispose();
|
||||
|
||||
Assert.True(host.Session.Runtime.CaptureOwnership().IsConverged);
|
||||
Assert.Equal(1, operations.DisposedSessionCount);
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
string[] eventNames = ReadStatusEventNames(statusPath);
|
||||
Assert.Equal(
|
||||
[
|
||||
"started", "connected", "characterList", "enteredWorld",
|
||||
"disconnected", "exited",
|
||||
],
|
||||
eventNames);
|
||||
|
||||
using JsonDocument disconnected = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "disconnected")]);
|
||||
Assert.Equal(
|
||||
"stopped",
|
||||
disconnected.RootElement.GetProperty("reason").GetString());
|
||||
|
||||
using JsonDocument exited = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "exited")]);
|
||||
JsonElement exit = exited.RootElement;
|
||||
Assert.Equal(0, exit.GetProperty("code").GetInt32());
|
||||
string? exitReason = exit.GetProperty("reason").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(exitReason));
|
||||
Assert.NotEqual("fault", exitReason);
|
||||
Assert.NotEqual("probe", exitReason);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
File.ReadAllText(statusPath),
|
||||
StringComparison.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
"process-password",
|
||||
diagnostics.ToString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -2153,6 +2478,41 @@ public sealed class HeadlessSessionHostTests
|
|||
StatusFile = statusFile,
|
||||
};
|
||||
|
||||
/// <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 string[] ReadStatusEventNames(string path) =>
|
||||
File.ReadAllLines(path)
|
||||
.Select(static line =>
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
return document.RootElement.GetProperty("e").GetString()!;
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
||||
{
|
||||
const uint player = 0x50000002u;
|
||||
|
|
@ -2794,11 +3154,34 @@ public sealed class HeadlessSessionHostTests
|
|||
|
||||
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
private int _enterWorldCallCount;
|
||||
private int _tickCallCount;
|
||||
|
||||
public List<WorldSession> Sessions { get; } = [];
|
||||
public int CreatedSessionCount { get; private set; }
|
||||
public int DisposedSessionCount { get; private set; }
|
||||
public string? LastUser { get; private set; }
|
||||
public string? LastPassword { get; private set; }
|
||||
public int EnterWorldCallCount =>
|
||||
Volatile.Read(ref _enterWorldCallCount);
|
||||
public int TickCallCount => Volatile.Read(ref _tickCallCount);
|
||||
public CharacterList.Parsed? Characters { get; init; } = new(
|
||||
0u,
|
||||
[
|
||||
new CharacterList.Character(
|
||||
0x50000001u,
|
||||
"Other",
|
||||
0u),
|
||||
new CharacterList.Character(
|
||||
0x50000002u,
|
||||
"Headless",
|
||||
0u),
|
||||
],
|
||||
[],
|
||||
11,
|
||||
"account",
|
||||
true,
|
||||
true);
|
||||
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
|
@ -2820,34 +3203,19 @@ public sealed class HeadlessSessionHostTests
|
|||
LastPassword = password;
|
||||
}
|
||||
|
||||
public CharacterList.Parsed GetCharacters(
|
||||
WorldSession session) =>
|
||||
new(
|
||||
0u,
|
||||
[
|
||||
new CharacterList.Character(
|
||||
0x50000001u,
|
||||
"Other",
|
||||
0u),
|
||||
new CharacterList.Character(
|
||||
0x50000002u,
|
||||
"Headless",
|
||||
0u),
|
||||
],
|
||||
[],
|
||||
11,
|
||||
"account",
|
||||
true,
|
||||
true);
|
||||
public CharacterList.Parsed? GetCharacters(
|
||||
WorldSession session) => Characters;
|
||||
|
||||
public void EnterWorld(
|
||||
WorldSession session,
|
||||
int activeCharacterIndex)
|
||||
{
|
||||
Interlocked.Increment(ref _enterWorldCallCount);
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
{
|
||||
Interlocked.Increment(ref _tickCallCount);
|
||||
}
|
||||
|
||||
public void DisposeSession(WorldSession session)
|
||||
|
|
|
|||
|
|
@ -32,8 +32,12 @@ public sealed class SessionConfigurationSharedFixtureTests
|
|||
Assert.Equal("composer.example", session.Endpoint.Host);
|
||||
Assert.Equal(9010, session.Endpoint.Port);
|
||||
Assert.Equal("composer-account", session.Account);
|
||||
Assert.Equal(0x50000001u, session.Character.Id);
|
||||
Assert.Equal("idle", session.Policy.Id);
|
||||
HeadlessCharacterSelector character =
|
||||
Assert.IsType<HeadlessCharacterSelector>(session.Character);
|
||||
HeadlessBotPolicyDescriptor policy =
|
||||
Assert.IsType<HeadlessBotPolicyDescriptor>(session.Policy);
|
||||
Assert.Equal(0x50000001u, character.Id);
|
||||
Assert.Equal("idle", policy.Id);
|
||||
Assert.Equal(
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
session.Credential.Provider);
|
||||
|
|
@ -73,8 +77,8 @@ public sealed class SessionConfigurationSharedFixtureTests
|
|||
Assert.Equal("127.0.0.1", session.Endpoint.Host);
|
||||
Assert.Equal(9000, session.Endpoint.Port);
|
||||
Assert.Equal("sharedaccount", session.Account);
|
||||
Assert.Equal("SharedToon", session.Character.Name);
|
||||
Assert.Equal("idle", session.Policy.Id);
|
||||
Assert.Equal("SharedToon", session.Character!.Name);
|
||||
Assert.Equal("idle", session.Policy!.Id);
|
||||
Assert.Equal(
|
||||
HeadlessCredentialProviderKind.StandardInput,
|
||||
session.Credential.Provider);
|
||||
|
|
|
|||
|
|
@ -389,6 +389,96 @@ public sealed class LiveSessionControllerTests
|
|||
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>
|
||||
/// Campaign LA LA2 review fix: ProbeComplete proves a real CharacterList
|
||||
/// was received and reported, not merely that the socket connected. A
|
||||
/// missing roster follows the existing NoCharacters non-success path and
|
||||
/// still drains the exact pre-world teardown transaction.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Start_ProbeWithoutCharacterListIsNonSuccessAndTearsDownGracefully()
|
||||
{
|
||||
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.NoCharacters, result.Status);
|
||||
Assert.Empty(host.Rosters);
|
||||
Assert.Equal(
|
||||
[
|
||||
"reset", "resolve", "create", "bind", "report-connecting",
|
||||
"connect", "report-connected", "deactivate",
|
||||
"detach-events", "dispose-session", "detach-session", "reset",
|
||||
],
|
||||
calls);
|
||||
Assert.Equal(0, operations.EnterWorldCount);
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Null(controller.CurrentSession);
|
||||
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
|
||||
|
||||
LiveSessionOwnershipSnapshot ownership = controller.CaptureOwnership();
|
||||
Assert.Equal(RuntimeTeardownStage.Complete, ownership.LastTeardownStages);
|
||||
Assert.False(ownership.HasActiveSession);
|
||||
Assert.False(ownership.HasRetiredSession);
|
||||
Assert.False(ownership.HasPendingOperation);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("index")]
|
||||
[InlineData("id")]
|
||||
|
|
@ -1141,14 +1231,16 @@ public sealed class LiveSessionControllerTests
|
|||
private static LiveSessionConnectOptions LiveOptions(
|
||||
bool live = true,
|
||||
string? user = "user",
|
||||
LiveSessionCharacterSelector? selector = null) =>
|
||||
LiveSessionCharacterSelector? selector = null,
|
||||
bool probe = false) =>
|
||||
new(
|
||||
live,
|
||||
"127.0.0.1",
|
||||
9000,
|
||||
user ?? string.Empty,
|
||||
"password",
|
||||
selector);
|
||||
selector,
|
||||
probe);
|
||||
|
||||
private static CharacterList.Parsed AvailableCharacters() => new(
|
||||
0u,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue