docs: Campaign LA — pinned launch-contract schema COMMITTED into plan LA1

The LA3 Opus review process note was right: the contract both sides
implement lived only in orchestrator prompts, which is exactly the drift
mode the pin exists to prevent (and it produced the paths-key CRITICAL).
The schema, field rules, probe-mode discriminator, and status vocabulary
are now a binding plan section; amendments change this text first,
implementations second. Ledger: LA3 fix round dispatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 16:04:32 +02:00
parent 0bcc7ba3a3
commit db9ad53c1c
38 changed files with 2397 additions and 40 deletions

View file

@ -108,6 +108,69 @@ referencing only `AcDream.Platform`.
## LA1 — launch contract (client side) ## LA1 — launch contract (client side)
### Pinned launch-contract schema (v1, BINDING — committed per LA3 review)
This text is the single source of truth for the launcher↔host file
contract. Both host readers (LA1), the composer (LA3), and the probe
loader (LA2) implement EXACTLY this; any change is an amendment to THIS
section first, implementations second. The LA1+LA3 merge adds a
cross-assembly test feeding a composer-produced document to both host
loaders — that test is the seam's permanent enforcement.
Session-config document (System.Text.Json, camelCase,
`UnmappedMemberHandling.Disallow`, camelCase string enums):
```json
{
"version": 1,
"process": {
"content": { "datDirectory": "...", "preparedAssetPath": "..." }
},
"sessions": [{
"id": "sess-1",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "testaccount",
"mode": "probe",
"character": { "id": 1342177290 },
"policy": { "id": "idle" },
"credential": { "provider": "standardInput", "reference": "session" },
"plugins": ["ExamplePlugin"],
"loginCommands": ["/vt start"],
"loginCommandDelayMs": 500,
"statusFile": ".../launcher/sessions/sess-1/status.jsonl"
}]
}
```
Field rules:
- `process.paths` is OMITTED unless a caller genuinely supplies overrides
(never an empty object — the App reader has no `paths` member and
strict parsing rejects unknown keys; LA3 review finding 1).
- `mode`: ABSENT for normal play sessions; `"probe"` for the LA2 probe
(connect → characterList → graceful disconnect, no EnterWorld). The
headless loader accepts the field starting at LA2.
- `character`: exactly ONE of index|id|name; OMITTED entirely (not null)
for guiSelect and for probe sessions.
- `policy`: `{ "id": "idle" }` for headless play sessions ONLY; omitted
for gui/guiSelect/probe.
- `credential`: always `{ "provider": "standardInput", "reference":
"session" }` for launcher-composed configs.
- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
omitted-when-unset (never null, never `[]` for empty). Absent
`loginCommandDelayMs` means 500.
Status stream (`statusFile`, one JSON object per line, writer flushes per
line, writer opens `FileShare.Read`, tailer opens
`Read/FileShare.ReadWrite|Delete`): events `started`, `connected`,
`characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}`,
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
`pluginFailed{plugin,error}`, `disconnected{reason}`,
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
sides. Unknown `e` values must parse to a typed Unknown event, never
throw; a known `e` with a wrong payload shape should be distinguishable
from an unknown `e` (LA3 review finding 12).
Three pieces, one slice, because they share the session-config/status seam: Three pieces, one slice, because they share the session-config/status seam:
1. **App `--session-config <path>`:** parsed once in `Program.cs` into 1. **App `--session-config <path>`:** parsed once in `Program.cs` into
@ -416,7 +479,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 | | 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 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields | | LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields |
| LA2 | — | | | | | LA2 | — | | | |
| LA3 | implemented (`37d74e44`, campaign-la3); Opus review in flight | `37d74e44` | review in flight | 71/71 Windows AND 71/71 WSL (0600 test real on Linux); reviewer checking contract token-fidelity + tailer fix | | LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge |
| LA4 | — | | | | | LA4 | — | | | |
| LA5 | — | | | | | LA5 | — | | | |
| LA6 | — | | | | | LA6 | — | | | |

View file

@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies(
CombatAttackOperationsSlot CombatAttackOperations, CombatAttackOperationsSlot CombatAttackOperations,
CombatFeedbackSlot CombatFeedback, CombatFeedbackSlot CombatFeedback,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback, TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log) Action<string> Log,
/// <summary>Campaign LA slice LA1: the shared per-session status-event
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
/// not configured.</summary>
SessionStatusWriter StatusWriter)
{ {
public RuntimeActionState Actions => Runtime.ActionOwner; public RuntimeActionState Actions => Runtime.ActionOwner;
@ -1124,7 +1128,9 @@ internal sealed class SessionPlayerCompositionPhase
acceptedPositionDrive, acceptedPositionDrive,
remotePlacementDrive), remotePlacementDrive),
liveSessionCommands, liveSessionCommands,
d.Log); d.Log,
d.StatusWriter,
d.Options.SessionId ?? "app");
LiveSessionHost sessionHost = sessionRuntimeFactory.Create( LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
liveSession, liveSession,
new LiveSessionConnectOptions( new LiveSessionConnectOptions(
@ -1132,7 +1138,8 @@ internal sealed class SessionPlayerCompositionPhase
d.Options.LiveHost, d.Options.LiveHost,
d.Options.LivePort, d.Options.LivePort,
d.Options.LiveUser ?? string.Empty, d.Options.LiveUser ?? string.Empty,
d.Options.LivePass ?? string.Empty)); d.Options.LivePass ?? string.Empty,
d.Options.LiveCharacterSelector));
Fault(SessionPlayerCompositionPoint.SessionHostCreated); Fault(SessionPlayerCompositionPoint.SessionHostCreated);
// The ImGui developer-tools debug toast sink was removed at Campaign V // The ImGui developer-tools debug toast sink was removed at Campaign V

View file

@ -1,9 +1,14 @@
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Composition; namespace AcDream.App.Composition;
internal sealed record SessionStartDependencies( internal sealed record SessionStartDependencies(
Action<string> Log); Action<string> Log,
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
/// configured.</summary>
SessionStatusWriter StatusWriter,
string SessionId);
/// <summary> /// <summary>
/// Terminal startup phase. Every callback, command target, and frame root is /// Terminal startup phase. Every callback, command target, and frame root is
@ -21,6 +26,9 @@ internal sealed class SessionStartCompositionPhase
public void Start(FrameRootResult frame) public void Start(FrameRootResult frame)
{ {
ArgumentNullException.ThrowIfNull(frame); ArgumentNullException.ThrowIfNull(frame);
// Campaign LA slice LA1: "started" = session host start — the
// earliest point the graphical host actually attempts to connect.
_dependencies.StatusWriter.Started(_dependencies.SessionId);
RuntimeSessionStartResult result = RuntimeSessionStartResult result =
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation); frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
Report(result, _dependencies.Log); Report(result, _dependencies.Log);

View file

@ -0,0 +1,142 @@
using System.Text.Json.Serialization;
namespace AcDream.App.Configuration;
/// <summary>
/// Campaign LA slice LA1: the graphical host's reader for the pinned
/// session-config document shape shared with
/// <c>AcDream.Headless.Configuration.HeadlessConfiguration</c> — see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 and
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6.
///
/// <para>
/// This is a DELIBERATELY independent DTO set, not a shared type reused from
/// <c>AcDream.Headless</c> — Headless's config types are internal, tied to
/// its own OP7 <c>characterOptions</c> allow-list semantics, and Headless is
/// not a project App references. The two readers are cross-checked instead
/// by a shared fixture document both test suites parse
/// (<c>SessionConfigurationSharedFixtureTests</c> /
/// <c>HeadlessConfigurationSharedFixtureTests</c>).
/// </para>
///
/// <para>
/// Differences from the Headless reader, all intentional per the pinned
/// contract: <see cref="SessionDescriptor.Character"/> is OPTIONAL here
/// (absent = today's first-available fallback; the character-select screen
/// is LA7, not this slice); <see cref="SessionDescriptor.Policy"/> is parsed
/// but never consulted (App has no bot-policy concept); exactly ONE session
/// is required, not "one or more".
/// </para>
/// </summary>
internal sealed class SessionConfiguration
{
[JsonRequired]
public int Version { get; init; }
public SessionProcessSettings? Process { get; init; }
[JsonRequired]
public List<SessionDescriptor?> Sessions { get; init; } = [];
}
internal sealed class SessionProcessSettings
{
public SessionContentDescriptor? Content { get; init; }
}
internal sealed class SessionContentDescriptor
{
[JsonRequired]
public string DatDirectory { get; init; } = string.Empty;
[JsonRequired]
public string PreparedAssetPath { get; init; } = string.Empty;
}
internal sealed record SessionDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
[JsonRequired]
public SessionEndpointDescriptor Endpoint { get; init; } = new();
[JsonRequired]
public string Account { get; init; } = string.Empty;
/// <summary>Optional for the graphical host: absent means today's
/// existing first-available fallback stays in effect. The retail
/// character-select screen (LA7) is what actually consumes "no
/// selector" as "stop and let the user pick".</summary>
public SessionCharacterSelectorDescriptor? Character { get; init; }
/// <summary>Accepted so the SAME document also satisfies the Headless
/// loader's <c>JsonRequired</c> policy field — parsed and ignored here;
/// App has no bot-policy concept.</summary>
public SessionPolicyDescriptor? Policy { get; init; }
[JsonRequired]
public SessionCredentialDescriptor Credential { get; init; } = new();
/// <summary>Accepted-but-ignored by App; Headless's own loader owns the
/// allow-list semantics for this field (OP7 D8).</summary>
public Dictionary<string, bool>? CharacterOptions { get; init; }
/// <summary>LA1: plugin ids to load. Absent = load all (LA5 consumes
/// this; parsed and carried here now per the pinned launch contract).</summary>
public List<string>? Plugins { get; init; }
/// <summary>LA1: ordered chat-typed strings run after entering world
/// (LA6 consumes this; parsed and carried here now).</summary>
public List<string>? LoginCommands { get; init; }
/// <summary>LA1: inter-command delay for <see cref="LoginCommands"/>,
/// milliseconds. Matches the pinned contract default of 500 ms.</summary>
public int LoginCommandDelayMs { get; init; } = 500;
/// <summary>LA1: absolute path for the status-event JSONL stream.
/// Absent = no writer constructed.</summary>
public string? StatusFile { get; init; }
}
internal sealed class SessionEndpointDescriptor
{
[JsonRequired]
public string Host { get; init; } = string.Empty;
[JsonRequired]
public int Port { get; init; }
}
internal sealed class SessionCharacterSelectorDescriptor
{
public int? Index { get; init; }
public uint? Id { get; init; }
public string? Name { get; init; }
}
/// <summary>Loose by design: App never inspects the policy's shape beyond
/// "does this document parse" — <c>Id</c>/<c>Role</c> stay untyped strings so
/// this DTO never has to track Headless's own policy-id/role vocabulary.</summary>
internal sealed class SessionPolicyDescriptor
{
public string? Id { get; init; }
public string? Role { get; init; }
}
[JsonConverter(typeof(JsonStringEnumConverter<SessionCredentialProviderKind>))]
internal enum SessionCredentialProviderKind
{
Environment,
StandardInput,
File,
}
internal sealed class SessionCredentialDescriptor
{
[JsonRequired]
public SessionCredentialProviderKind Provider { get; init; }
[JsonRequired]
public string Reference { get; init; } = string.Empty;
}

View file

@ -0,0 +1,18 @@
namespace AcDream.App.Configuration;
/// <summary>Mirrors <c>AcDream.Headless.Configuration.HeadlessConfigurationException</c>
/// — a semantic validation failure of an already well-typed session-config
/// document (a type-SHAPE violation fails earlier, as a raw
/// <see cref="System.Text.Json.JsonException"/> during deserialization).</summary>
internal sealed class SessionConfigurationException : Exception
{
internal SessionConfigurationException(string message)
: base(message)
{
}
internal SessionConfigurationException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,158 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.App.Configuration;
/// <summary>
/// Campaign LA slice LA1: loads and validates the <c>--session-config</c>
/// document for the graphical host. Same strictness as
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c>
/// (camelCase, <see cref="JsonUnmappedMemberHandling.Disallow"/>, camelCase
/// string enums) — see that type's own doc for why the two readers are
/// independent DTOs rather than a shared type.
/// </summary>
internal static class SessionConfigurationLoader
{
private const int CurrentVersion = 1;
private static readonly JsonSerializerOptions Options = new()
{
AllowTrailingCommas = false,
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Disallow,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
Converters =
{
new JsonStringEnumConverter(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
};
/// <summary>Loads the document and returns the exact one configured
/// <see cref="SessionDescriptor"/> the graphical host runs — the
/// document itself may only ever declare exactly one session.</summary>
internal static (SessionConfiguration Configuration, SessionDescriptor Session) Load(
string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string fullPath = Path.GetFullPath(path);
using FileStream stream = File.OpenRead(fullPath);
SessionConfiguration? configuration =
JsonSerializer.Deserialize<SessionConfiguration>(stream, Options);
if (configuration is null)
{
throw new SessionConfigurationException(
"The configuration document is empty.");
}
if (configuration.Version != CurrentVersion)
{
throw new SessionConfigurationException(
$"Unsupported configuration version {configuration.Version}; "
+ $"expected {CurrentVersion}.");
}
if (configuration.Sessions is null
|| configuration.Sessions.Count != 1)
{
throw new SessionConfigurationException(
"The graphical host requires exactly one configured session.");
}
SessionDescriptor session = configuration.Sessions[0]
?? throw new SessionConfigurationException(
"The configured session cannot be null.");
ValidateContent(configuration.Process?.Content);
ValidateSession(session);
return (configuration, session);
}
private static void ValidateContent(SessionContentDescriptor? content)
{
if (content is null)
return;
if (string.IsNullOrWhiteSpace(content.DatDirectory)
|| string.IsNullOrWhiteSpace(content.PreparedAssetPath))
{
throw new SessionConfigurationException(
"process.content requires non-empty datDirectory and preparedAssetPath.");
}
}
private static void ValidateSession(SessionDescriptor session)
{
if (string.IsNullOrWhiteSpace(session.Id))
{
throw new SessionConfigurationException(
"The session requires a non-empty id.");
}
if (session.Endpoint is null
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|| session.Endpoint.Port is < 1 or > 65535)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a host and a port from 1 through 65535.");
}
if (string.IsNullOrWhiteSpace(session.Account))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a non-empty account.");
}
if (session.Character is { } selector)
{
int selectorCount =
(selector.Index.HasValue ? 1 : 0)
+ (selector.Id.HasValue ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(selector.Name) ? 1 : 0);
if (selectorCount != 1
|| selector.Index is < 0
|| selector.Id == 0u)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' character selector must specify "
+ "exactly one valid index, id, or name.");
}
}
if (session.Credential is null
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a credential reference.");
}
if (session.Plugins is { } plugins)
{
foreach (string? plugin in plugins)
{
if (string.IsNullOrWhiteSpace(plugin))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' plugins entries must be non-empty strings.");
}
}
}
if (session.LoginCommandDelayMs < 0)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
}
if (session.StatusFile is not null
&& string.IsNullOrWhiteSpace(session.StatusFile))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
}
}
}

View file

@ -0,0 +1,155 @@
using AcDream.App.Configuration;
using AcDream.App.Platform;
namespace AcDream.App.Credentials;
/// <summary>
/// Campaign LA slice LA1: resolves a <c>--session-config</c> session's
/// credential reference — the App-side mirror of
/// <c>AcDream.Headless.Credentials.HeadlessCredentialResolver</c> (see that
/// type's own file for why this is an independent port rather than a shared
/// reference). Supports the same three providers with the same semantics:
/// <c>environment</c> (read an env var), <c>standardInput</c> (read one line
/// from stdin, mirroring <c>HeadlessCredentialResolver.ResolveStandardInput</c>),
/// and <c>file</c> (read a credential file relative to a base directory,
/// rejecting symlinks and, on Linux, group/other-readable permissions).
/// </summary>
internal sealed class AppCredentialResolver
{
private const UnixFileMode NonUserPermissionMask =
UnixFileMode.GroupRead
| UnixFileMode.GroupWrite
| UnixFileMode.GroupExecute
| UnixFileMode.OtherRead
| UnixFileMode.OtherWrite
| UnixFileMode.OtherExecute;
private readonly TextReader _standardInput;
private readonly string _credentialBaseDirectory;
private readonly bool _isLinux;
/// <summary>
/// <paramref name="isLinux"/> is caller-supplied, never detected in this
/// file — <c>LinuxPlatformBoundaryTests</c>'s platform-owner guard
/// requires every OS-family check to live under <c>Platform/</c>;
/// callers pass <c>GraphicalHostPlatformServices</c>'s already-detected
/// value instead of this file re-detecting it itself.
/// </summary>
internal AppCredentialResolver(
TextReader standardInput,
string credentialBaseDirectory,
bool isLinux)
{
_standardInput = standardInput
?? throw new ArgumentNullException(nameof(standardInput));
ArgumentException.ThrowIfNullOrWhiteSpace(credentialBaseDirectory);
_credentialBaseDirectory = Path.GetFullPath(credentialBaseDirectory);
_isLinux = isLinux;
}
internal AppCredentialSecret Resolve(
string sessionId,
SessionCredentialDescriptor credential)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
ArgumentNullException.ThrowIfNull(credential);
string value;
try
{
value = credential.Provider switch
{
SessionCredentialProviderKind.Environment =>
ResolveEnvironment(credential.Reference),
SessionCredentialProviderKind.StandardInput =>
ResolveStandardInput(credential.Reference),
SessionCredentialProviderKind.File =>
ResolveFile(credential.Reference),
_ => throw new AppCredentialException(
$"Session '{sessionId}' uses an unsupported credential provider."),
};
}
catch (AppCredentialException)
{
throw;
}
catch (Exception error)
when (error is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException)
{
throw new AppCredentialException(
$"Credential '{credential.Reference}' for session '{sessionId}' could not be resolved.",
error);
}
try
{
return new AppCredentialSecret(credential.Reference, value.AsSpan());
}
finally
{
// The BCL returns immutable strings from environment, TextReader,
// and File APIs. Do not retain another copy in the resolver; the
// erasable char[] owner becomes the sole explicit retained copy.
value = string.Empty;
}
}
private static string ResolveEnvironment(string reference)
{
string? value = Environment.GetEnvironmentVariable(reference);
if (string.IsNullOrEmpty(value))
{
throw new AppCredentialException(
$"Credential environment reference '{reference}' is unavailable.");
}
return value;
}
private string ResolveStandardInput(string reference)
{
string? value = _standardInput.ReadLine();
if (string.IsNullOrEmpty(value))
{
throw new AppCredentialException(
$"Credential standard-input reference '{reference}' is unavailable.");
}
return value;
}
private string ResolveFile(string reference)
{
string path = Path.GetFullPath(reference, _credentialBaseDirectory);
var file = new FileInfo(path);
if (file.LinkTarget is not null)
{
throw new AppCredentialException(
$"Credential file reference '{reference}' cannot be a symbolic link.");
}
// RuntimePlatformGuard.IsLinuxRuntime is the CA1416-recognized guard
// for File.GetUnixFileMode below; _isLinux is the separate,
// caller-injected value tests use for deterministic cross-platform
// coverage (see the constructor's own doc).
if (RuntimePlatformGuard.IsLinuxRuntime && _isLinux)
{
UnixFileMode mode = File.GetUnixFileMode(path);
if ((mode & NonUserPermissionMask) != 0
|| (mode & UnixFileMode.UserRead) == 0)
{
throw new AppCredentialException(
$"Credential file reference '{reference}' must be readable only by its owner.");
}
}
string value = File.ReadAllText(path).TrimEnd('\r', '\n');
if (value.Length == 0)
{
throw new AppCredentialException(
$"Credential file reference '{reference}' is empty.");
}
return value;
}
}

View file

@ -0,0 +1,65 @@
using System.Security.Cryptography;
namespace AcDream.App.Credentials;
/// <summary>
/// Campaign LA slice LA1: retains a resolved <c>--session-config</c>
/// credential in erasable memory — the App-side mirror of
/// <c>AcDream.Headless.Credentials.HeadlessCredentialSecret</c> (that type is
/// internal to the Headless project, so this is a minimal, independent port
/// rather than a shared reference). The network boundary still requires one
/// short-lived immutable string; callers must not retain that value beyond
/// constructing the connect request.
/// </summary>
internal sealed class AppCredentialSecret : IDisposable
{
private char[]? _buffer;
internal AppCredentialSecret(string referenceId, ReadOnlySpan<char> value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(referenceId);
if (value.IsEmpty)
{
throw new AppCredentialException(
$"Credential '{referenceId}' resolved to an empty secret.");
}
ReferenceId = referenceId;
_buffer = value.ToArray();
}
internal string ReferenceId { get; }
internal bool IsDisposed => _buffer is null;
internal string Reveal()
{
ObjectDisposedException.ThrowIf(_buffer is null, this);
return new string(_buffer);
}
public void Dispose()
{
char[]? buffer = Interlocked.Exchange(ref _buffer, null);
if (buffer is null)
return;
CryptographicOperations.ZeroMemory(
System.Runtime.InteropServices.MemoryMarshal.AsBytes(
buffer.AsSpan()));
}
public override string ToString() =>
$"[redacted:{ReferenceId}]";
}
internal sealed class AppCredentialException : Exception
{
internal AppCredentialException(string message)
: base(message)
{
}
internal AppCredentialException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -104,6 +104,8 @@ internal sealed class LiveSessionRuntimeFactory
private readonly LiveSessionCommandSurface _commands; private readonly LiveSessionCommandSurface _commands;
private readonly Action<string> _log; private readonly Action<string> _log;
private readonly LiveMovementStatsApplier _movementStats; private readonly LiveMovementStatsApplier _movementStats;
private readonly SessionStatusWriter _statusWriter;
private readonly string _sessionId;
public LiveSessionRuntimeFactory( public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player, LiveSessionPlayerRuntime player,
@ -112,7 +114,9 @@ internal sealed class LiveSessionRuntimeFactory
LiveSessionInteractionRuntime interaction, LiveSessionInteractionRuntime interaction,
LiveSessionWorldRuntime world, LiveSessionWorldRuntime world,
LiveSessionCommandSurface commands, LiveSessionCommandSurface commands,
Action<string> log) Action<string> log,
SessionStatusWriter? statusWriter = null,
string sessionId = "app")
{ {
_player = player ?? throw new ArgumentNullException(nameof(player)); _player = player ?? throw new ArgumentNullException(nameof(player));
_domain = domain ?? throw new ArgumentNullException(nameof(domain)); _domain = domain ?? throw new ArgumentNullException(nameof(domain));
@ -122,6 +126,10 @@ internal sealed class LiveSessionRuntimeFactory
_world = world ?? throw new ArgumentNullException(nameof(world)); _world = world ?? throw new ArgumentNullException(nameof(world));
_commands = commands ?? throw new ArgumentNullException(nameof(commands)); _commands = commands ?? throw new ArgumentNullException(nameof(commands));
_log = log ?? throw new ArgumentNullException(nameof(log)); _log = log ?? throw new ArgumentNullException(nameof(log));
// Campaign LA slice LA1: a no-op instance when the caller has no
// status file configured — every call site below stays unconditional.
_statusWriter = statusWriter ?? new SessionStatusWriter(null);
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
// C3c-F1: stat recomputes route through the Runtime movement owner's // C3c-F1: stat recomputes route through the Runtime movement owner's
// typed application seam; App keeps zero direct controller mutations. // typed application seam; App keeps zero direct controller mutations.
_movementStats = new LiveMovementStatsApplier( _movementStats = new LiveMovementStatsApplier(
@ -176,9 +184,17 @@ internal sealed class LiveSessionRuntimeFactory
$"connecting to {host}:{port} as {user}", $"connecting to {host}:{port} as {user}",
chatType: 1), chatType: 1),
Connected: () => Connected: () =>
{
_domain.Communication.Chat.OnSystemMessage( _domain.Communication.Chat.OnSystemMessage(
"connected — character list received", "connected — character list received",
chatType: 1)), chatType: 1);
_statusWriter.Connected(_sessionId);
},
Roster: roster => _statusWriter.CharacterList(_sessionId, roster),
CharacterEntered: selection => _statusWriter.EnteredWorld(
_sessionId,
selection.CharacterId,
selection.CharacterName)),
connectOptions); connectOptions);
} }

View file

@ -1,4 +1,5 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Platform; using AcDream.Platform;
@ -10,6 +11,21 @@ internal enum GraphicalHostOperatingSystem
Linux, Linux,
} }
/// <summary>
/// Campaign LA slice LA1: a <c>[SupportedOSPlatformGuard]</c>-annotated
/// runtime-OS check, for code OUTSIDE <c>Platform/</c> that needs a
/// CA1416-recognized guard around a Linux-only API (e.g.
/// <c>AppCredentialResolver</c>'s <c>File.GetUnixFileMode</c> call) without
/// re-detecting the OS itself — <c>LinuxPlatformBoundaryTests
/// .OperatingSystemChecksRemainInsidePlatformOwners</c> requires every such
/// check to live under this folder.
/// </summary>
internal static class RuntimePlatformGuard
{
[SupportedOSPlatformGuard("linux")]
internal static bool IsLinuxRuntime => System.OperatingSystem.IsLinux();
}
internal sealed record GraphicalNativeDependency( internal sealed record GraphicalNativeDependency(
string Feature, string Feature,
string PublishedFileName); string PublishedFileName);

View file

@ -1,4 +1,6 @@
using AcDream.App; using AcDream.App;
using AcDream.App.Configuration;
using AcDream.App.Credentials;
using AcDream.App.Plugins; using AcDream.App.Plugins;
using AcDream.App.Platform; using AcDream.App.Platform;
using AcDream.App.Rendering; using AcDream.App.Rendering;
@ -32,17 +34,96 @@ Log.Information(
dependency => dependency =>
$"{dependency.Feature}={dependency.PublishedFileName}"))); $"{dependency.Feature}={dependency.PublishedFileName}")));
var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); // Campaign LA slice LA1: --session-config <path> is purely additive — the
if (string.IsNullOrWhiteSpace(datDir)) // existing one positional dat-dir argument and every ACDREAM_* env var keep
{ // working exactly as before when the flag is absent. See
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)"); // docs/plans/2026-08-14-launcher-campaign.md LA1.
return 2; string? sessionConfigFlagPath = ExtractFlagValue(args, "--session-config");
} string[] positionalArgs = WithoutFlagAndValue(args, "--session-config");
var datDirArg = positionalArgs.FirstOrDefault();
var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
// Single read of the startup-time process environment. Every downstream // Single read of the startup-time process environment. Every downstream
// consumer (GameWindow + collaborators) reads the typed bundle, not the // consumer (GameWindow + collaborators) reads the typed bundle, not the
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4. // raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); RuntimeOptions runtimeOptions;
if (sessionConfigFlagPath is not null)
{
SessionConfiguration sessionConfig;
SessionDescriptor session;
try
{
(sessionConfig, session) = SessionConfigurationLoader.Load(sessionConfigFlagPath);
}
catch (Exception error)
when (error is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException
or System.Text.Json.JsonException
or SessionConfigurationException)
{
Log.Error("--session-config invalid: {Error}", error.Message);
return 2;
}
string? resolvedDatDir =
NullIfEmpty(sessionConfig.Process?.Content?.DatDirectory)
?? NullIfEmpty(datDirArg)
?? NullIfEmpty(envDatDir);
if (resolvedDatDir is null)
{
Log.Error(
"usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR, "
+ "or supply process.content.datDirectory in --session-config)");
return 2;
}
AppCredentialSecret? secret = null;
try
{
var resolver = new AppCredentialResolver(
Console.In,
applicationPaths.ConfigDirectory,
graphicalPlatform.OperatingSystem
== GraphicalHostOperatingSystem.Linux);
secret = resolver.Resolve(session.Id, session.Credential);
runtimeOptions = RuntimeOptions.FromSessionConfig(
resolvedDatDir,
Environment.GetEnvironmentVariable,
sessionConfigFlagPath,
sessionConfig,
session,
secret.Reveal());
}
catch (AppCredentialException error)
{
Log.Error("--session-config credential unavailable: {Error}", error.Message);
return 2;
}
finally
{
secret?.Dispose();
}
// Env-var flow untouched when the flag is absent; when both are present
// the flag wins — this line makes that explicit rather than silent.
Log.Information(
"--session-config {Path} present; overriding ACDREAM_LIVE*/ACDREAM_TEST_* "
+ "env-var live-session settings",
sessionConfigFlagPath);
}
else
{
var datDir = datDirArg ?? envDatDir;
if (string.IsNullOrWhiteSpace(datDir))
{
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
return 2;
}
runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
}
if (runtimeOptions.DevTools) if (runtimeOptions.DevTools)
{ {
@ -158,3 +239,35 @@ finally
} }
return 0; return 0;
// Campaign LA slice LA1: --session-config <path> parsing helpers. Kept
// local/minimal rather than a general-purpose CLI parser — App has exactly
// one optional flag-with-value today; the positional dat-dir argument must
// stay untouched by its presence (see the comment above the flag parse).
static string? ExtractFlagValue(string[] arguments, string flag)
{
for (int i = 0; i < arguments.Length - 1; i++)
{
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
return arguments[i + 1];
}
return null;
}
static string[] WithoutFlagAndValue(string[] arguments, string flag)
{
var result = new List<string>(arguments.Length);
for (int i = 0; i < arguments.Length; i++)
{
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
{
i++; // also skip the flag's value
continue;
}
result.Add(arguments[i]);
}
return [.. result];
}
static string? NullIfEmpty(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;

View file

@ -36,6 +36,9 @@ public sealed class GameWindow :
/ (double)System.Diagnostics.Stopwatch.Frequency; / (double)System.Diagnostics.Stopwatch.Frequency;
private readonly AcDream.App.RuntimeOptions _options; private readonly AcDream.App.RuntimeOptions _options;
// Campaign LA slice LA1: no-op instance when --session-config didn't
// configure a statusFile (or the env-var launch path was used at all).
private readonly SessionStatusWriter _statusWriter;
private readonly AnimationPresentationDiagnostics _animationDiagnostics; private readonly AnimationPresentationDiagnostics _animationDiagnostics;
private readonly string _datDir; private readonly string _datDir;
private readonly WorldGameState _worldGameState; private readonly WorldGameState _worldGameState;
@ -615,6 +618,7 @@ public sealed class GameWindow :
GraphicalHostPlatformServices platformServices) GraphicalHostPlatformServices platformServices)
{ {
_options = options ?? throw new System.ArgumentNullException(nameof(options)); _options = options ?? throw new System.ArgumentNullException(nameof(options));
_statusWriter = new SessionStatusWriter(options.StatusFilePath);
_platformServices = platformServices _platformServices = platformServices
?? throw new ArgumentNullException(nameof(platformServices)); ?? throw new ArgumentNullException(nameof(platformServices));
_applicationPaths = _platformServices.Paths; _applicationPaths = _platformServices.Paths;
@ -1489,7 +1493,8 @@ public sealed class GameWindow :
_combatAttackOperations, _combatAttackOperations,
_combatFeedback, _combatFeedback,
_portalTunnelFallback, _portalTunnelFallback,
Console.WriteLine), Console.WriteLine,
_statusWriter),
this).Compose( this).Compose(
hostInputCamera, hostInputCamera,
contentEffectsAudio, contentEffectsAudio,
@ -1548,7 +1553,10 @@ public sealed class GameWindow :
livePresentation, livePresentation,
sessionPlayer), sessionPlayer),
frameRoots => new SessionStartCompositionPhase( frameRoots => new SessionStartCompositionPhase(
new SessionStartDependencies(Console.WriteLine)) new SessionStartDependencies(
Console.WriteLine,
_statusWriter,
_options.SessionId ?? "app"))
.Start(frameRoots)); .Start(frameRoots));
} }
@ -1636,13 +1644,30 @@ public sealed class GameWindow :
private void CompleteShutdown(bool releaseNativeWindow) private void CompleteShutdown(bool releaseNativeWindow)
{ {
if (!_lifetime.HasShutdownRoots) if (!_lifetime.HasShutdownRoots)
{
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
// by the time teardown completes, IsInWorld is always false
// regardless of whether a real session was ever connected.
// OnClosing() and Dispose() both funnel through this method;
// HasShutdownRoots's own guard means this fires exactly once,
// from whichever of the two reaches it first.
if (_runtime.Session.IsInWorld)
_statusWriter.Disconnected(_options.SessionId ?? "app", "stopped");
_lifetime.PublishShutdownRoots(CaptureShutdownRoots()); _lifetime.PublishShutdownRoots(CaptureShutdownRoots());
}
GameWindowLifetimeReport report = releaseNativeWindow GameWindowLifetimeReport report = releaseNativeWindow
? _lifetime.CompleteAndReleaseNativeWindow() ? _lifetime.CompleteAndReleaseNativeWindow()
: _lifetime.TryComplete(); : _lifetime.TryComplete();
if (report.Status == GameWindowLifetimeStatus.Complete) if (report.Status == GameWindowLifetimeStatus.Complete)
{
// "exited" = terminal — only the true Dispose() call (not the
// OnClosing() native-window-close-request pass) represents the
// process actually being done.
if (releaseNativeWindow)
_statusWriter.Exited(_options.SessionId ?? "app", 0, "disposed");
return; return;
}
Console.Error.WriteLine( Console.Error.WriteLine(
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}"); $"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
@ -1655,6 +1680,14 @@ public sealed class GameWindow :
if (report.Error is not null) if (report.Error is not null)
Console.Error.WriteLine($"[shutdown] {report.Error}"); Console.Error.WriteLine($"[shutdown] {report.Error}");
if (releaseNativeWindow)
{
_statusWriter.Exited(
_options.SessionId ?? "app",
1,
"shutdown-incomplete");
}
} }
private GameWindowShutdownRoots CaptureShutdownRoots() => new( private GameWindowShutdownRoots CaptureShutdownRoots() => new(

View file

@ -1,8 +1,11 @@
using System; using System;
using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using AcDream.App.Configuration;
using AcDream.App.Rendering.Residency; using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming; using AcDream.App.Streaming;
using AcDream.Runtime.Session;
namespace AcDream.App; namespace AcDream.App;
@ -62,7 +65,34 @@ public sealed record RuntimeOptions(
string? VulkanDeviceOverride, string? VulkanDeviceOverride,
string? VulkanForcedUnsupportedFeature, string? VulkanForcedUnsupportedFeature,
bool VulkanCapabilityProbe, bool VulkanCapabilityProbe,
int VulkanCapabilityProbeFrames) int VulkanCapabilityProbeFrames,
/// <summary>Campaign LA slice LA1: the raw <c>--session-config</c> path,
/// or <see langword="null"/> when the flag was not supplied (the env-var
/// dev flow). Kept for diagnostics/logging only.</summary>
string? SessionConfigPath,
/// <summary>Campaign LA slice LA1: the configured session's id, used as
/// the <c>sessionId</c> field on every status-stream event. Defaults to
/// <c>"app"</c> at every call site when unset (env-var flow).</summary>
string? SessionId,
/// <summary>Campaign LA slice LA1: the session-config character
/// selector, or <see langword="null"/> for today's existing
/// first-available fallback (absent selector = LA7's char-select screen
/// stop point once that slice lands; this slice does not build the
/// screen).</summary>
LiveSessionCharacterSelector? LiveCharacterSelector,
/// <summary>Campaign LA slice LA1: absolute path for the status-event
/// JSONL stream. <see langword="null"/> = no writer constructed.</summary>
string? StatusFilePath,
/// <summary>Campaign LA slice LA1: plugin ids to load.
/// <see langword="null"/> = load every discovered plugin (today's
/// behavior). Consumed by LA5; parsed and carried now.</summary>
IReadOnlyList<string>? Plugins,
/// <summary>Campaign LA slice LA1: ordered chat-typed strings run once
/// entered-world. Consumed by LA6; parsed and carried now.</summary>
IReadOnlyList<string> LoginCommands,
/// <summary>Campaign LA slice LA1: inter-command delay for
/// <see cref="LoginCommands"/>, milliseconds.</summary>
int LoginCommandDelayMs)
{ {
/// <summary> /// <summary>
/// Build options from the process environment. Used by /// Build options from the process environment. Used by
@ -170,9 +200,72 @@ public sealed record RuntimeOptions(
// closes the window. Zero -- unset, unparseable, or an explicit 0 -- // closes the window. Zero -- unset, unparseable, or an explicit 0 --
// keeps the interactive behaviour, so no existing invocation changes. // keeps the interactive behaviour, so no existing invocation changes.
VulkanCapabilityProbeFrames: VulkanCapabilityProbeFrames:
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0); TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0,
// Campaign LA slice LA1: the env-var dev flow never carries a
// session-config document — every new field below stays at its
// "nothing configured" default. RuntimeOptions.FromSessionConfig
// overlays the real values on top of this base.
SessionConfigPath: null,
SessionId: null,
LiveCharacterSelector: null,
StatusFilePath: null,
Plugins: null,
LoginCommands: [],
LoginCommandDelayMs: 500);
} }
/// <summary>
/// Campaign LA slice LA1: builds options for the <c>--session-config</c>
/// launch path. Starts from the same env-var parse as
/// <see cref="FromEnvironment"/> (diagnostic/dev flags are still
/// env-controlled — only the LIVE session settings and the five new LA1
/// fields come from the document) and overlays the resolved session.
/// <paramref name="resolvedPassword"/> is revealed into
/// <see cref="LivePass"/> exactly as wide as the existing env-var flow —
/// see that field's own doc.
/// </summary>
internal static RuntimeOptions FromSessionConfig(
string datDir,
Func<string, string?> env,
string sessionConfigPath,
SessionConfiguration config,
SessionDescriptor session,
string? resolvedPassword)
{
if (config is null) throw new ArgumentNullException(nameof(config));
if (session is null) throw new ArgumentNullException(nameof(session));
ArgumentException.ThrowIfNullOrWhiteSpace(sessionConfigPath);
RuntimeOptions baseOptions = Parse(datDir, env);
SessionContentDescriptor? content = config.Process?.Content;
return baseOptions with
{
PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath)
?? baseOptions.PreparedAssetPath,
LiveMode = true,
LiveHost = session.Endpoint.Host,
LivePort = session.Endpoint.Port,
LiveUser = session.Account,
LivePass = resolvedPassword,
SessionConfigPath = sessionConfigPath,
SessionId = session.Id,
LiveCharacterSelector = MapCharacterSelector(session.Character),
StatusFilePath = NullIfEmpty(session.StatusFile),
Plugins = session.Plugins,
LoginCommands = (IReadOnlyList<string>?)session.LoginCommands ?? [],
LoginCommandDelayMs = session.LoginCommandDelayMs,
};
}
private static LiveSessionCharacterSelector? MapCharacterSelector(
SessionCharacterSelectorDescriptor? selector) =>
selector is null
? null
: new LiveSessionCharacterSelector(
selector.Index,
selector.Id,
selector.Name);
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary> /// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
public bool HasLiveCredentials => public bool HasLiveCredentials =>
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass); LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);

View file

@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor
/// legal no-ops. /// legal no-ops.
/// </summary> /// </summary>
public Dictionary<string, bool>? CharacterOptions { get; init; } public Dictionary<string, bool>? CharacterOptions { get; init; }
/// <summary>
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
/// Absent means load every discovered plugin (today's dev behavior);
/// LA5 wires this into an actual allow-list filter. Parsed and carried
/// here now so the session-config shape is stable before LA5 lands.
/// </summary>
public List<string>? Plugins { get; init; }
/// <summary>
/// Campaign LA slice LA1: ordered chat-typed strings run once the
/// session enters world. LA6 wires actual execution; parsed and carried
/// here now.
/// </summary>
public List<string>? LoginCommands { get; init; }
/// <summary>
/// Campaign LA slice LA1: inter-command delay for
/// <see cref="LoginCommands"/>, in milliseconds. Matches the pinned
/// launch-contract default (500 ms) when the field is absent from the
/// document.
/// </summary>
public int LoginCommandDelayMs { get; init; } = 500;
/// <summary>
/// Campaign LA slice LA1: absolute path for this session's status-event
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
/// §6). Absent means no <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>
/// is constructed for this session.
/// </summary>
public string? StatusFile { get; init; }
} }
internal sealed class HeadlessEndpointDescriptor internal sealed class HeadlessEndpointDescriptor

View file

@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader
} }
ValidateCharacterOptions(session); ValidateCharacterOptions(session);
ValidateLaunchContractFields(session);
}
/// <summary>
/// Campaign LA slice LA1: validates the four new optional per-session
/// fields shared with the App session-config reader (see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's pinned
/// contract). All four stay optional; only their SHAPE is checked here
/// — parsing/executing <c>plugins</c>/<c>loginCommands</c> is LA5/LA6.
/// </summary>
private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session)
{
if (session.Plugins is { } plugins)
{
foreach (string? plugin in plugins)
{
if (string.IsNullOrWhiteSpace(plugin))
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' plugins entries must be non-empty strings.");
}
}
}
if (session.LoginCommandDelayMs < 0)
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
}
if (session.StatusFile is not null
&& string.IsNullOrWhiteSpace(session.StatusFile))
{
throw new HeadlessConfigurationException(
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
}
} }
/// <summary> /// <summary>

View file

@ -113,6 +113,19 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly HeadlessCredentialSecret _credential; private readonly HeadlessCredentialSecret _credential;
private readonly HeadlessDiagnosticWriter _diagnostics; private readonly HeadlessDiagnosticWriter _diagnostics;
/// <summary> /// <summary>
/// Campaign LA slice LA1: a SEPARATE per-session sink from
/// <see cref="_diagnostics"/> — a no-op instance when
/// <see cref="HeadlessSessionDescriptor.StatusFile"/> was not configured.
/// See <see cref="SessionStatusWriter"/>'s own doc for why this is not a
/// rework of the shared-stdout diagnostics writer.
/// </summary>
private readonly SessionStatusWriter _statusWriter;
/// <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
/// disconnect.</summary>
private bool _hasConnected;
/// <summary>
/// Campaign OP slice OP7 (2026-08-11), D8: the parsed /// Campaign OP slice OP7 (2026-08-11), D8: the parsed
/// <c>characterOptions</c> block — empty when the config omitted it. /// <c>characterOptions</c> block — empty when the config omitted it.
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/> /// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
@ -271,6 +284,10 @@ internal sealed class HeadlessSessionHost : IDisposable
var commands = new DirectGameRuntimeCommandAdapter( var commands = new DirectGameRuntimeCommandAdapter(
runtime, runtime,
bridge); bridge);
// Campaign LA slice LA1: no-op instance when
// descriptor.StatusFile is unset — every call site below stays
// unconditional.
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
var liveSession = new LiveSessionHost( var liveSession = new LiveSessionHost(
runtime.Session, runtime.Session,
new LiveSessionHostBindings( new LiveSessionHostBindings(
@ -318,14 +335,25 @@ internal sealed class HeadlessSessionHost : IDisposable
descriptor.Id, descriptor.Id,
$"connecting:{host}:{port}:{user}", $"connecting:{host}:{port}:{user}",
runtime.Generation.Value), runtime.Generation.Value),
() => diagnostics.Message( () =>
{
diagnostics.Message(
descriptor.Id,
"connected",
runtime.Generation.Value);
statusWriter.Connected(descriptor.Id);
_hasConnected = true;
},
roster => statusWriter.CharacterList(descriptor.Id, roster),
selection => statusWriter.EnteredWorld(
descriptor.Id, descriptor.Id,
"connected", selection.CharacterId,
runtime.Generation.Value))); selection.CharacterName)));
Runtime = runtime; Runtime = runtime;
Commands = commands; Commands = commands;
_liveSession = liveSession; _liveSession = liveSession;
_statusWriter = statusWriter;
_localPlayerFrame = _localPlayerFrame =
runtime.CreateLocalPlayerFrameController( runtime.CreateLocalPlayerFrameController(
new HeadlessLocalPlayerFrameHost( new HeadlessLocalPlayerFrameHost(
@ -421,8 +449,13 @@ internal sealed class HeadlessSessionHost : IDisposable
_pendingConfirmation = null; _pendingConfirmation = null;
} }
internal RuntimeSessionStartResult Start() => internal RuntimeSessionStartResult Start()
Commands.Session.Start(Runtime.Generation); {
// 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);
}
internal RuntimeSessionStartResult Reconnect() => internal RuntimeSessionStartResult Reconnect() =>
Commands.Session.Reconnect(Runtime.Generation); Commands.Session.Reconnect(Runtime.Generation);
@ -470,6 +503,15 @@ internal sealed class HeadlessSessionHost : IDisposable
// (possibly disposed) WorldSession in the window between this Stop // (possibly disposed) WorldSession in the window between this Stop
// and the next CreateEventRoute call. // and the next CreateEventRoute call.
_currentSession = null; _currentSession = null;
// Campaign LA slice LA1: only report a disconnect for a session that
// actually reached Connected — a Stop() on a never-started or
// never-connected host (e.g. immediate Dispose()) is not a real
// disconnect.
if (_hasConnected)
{
_hasConnected = false;
_statusWriter.Disconnected(_descriptor.Id, "stopped");
}
return result; return result;
} }
@ -592,6 +634,13 @@ internal sealed class HeadlessSessionHost : IDisposable
_descriptor.Id, _descriptor.Id,
"disposed", "disposed",
_stoppedGeneration); _stoppedGeneration);
// Campaign LA slice LA1: "exited" = terminal — the sole
// point every disposal path (graceful and post-
// quarantine) converges on.
_statusWriter.Exited(
_descriptor.Id,
_faulted ? 1 : 0,
_faulted ? "fault" : "disposed");
_disposeStage++; _disposeStage++;
_disposed = true; _disposed = true;
break; break;

View file

@ -50,6 +50,32 @@ public sealed record LiveSessionStartResult(
LiveSessionCharacterSelection? Selection = null, LiveSessionCharacterSelection? Selection = null,
Exception? Error = null); Exception? Error = null);
/// <summary>
/// Campaign LA slice LA1: one roster entry as reported by
/// <see cref="CharacterList.Parsed"/> — decoupled from the wire type so the
/// lifecycle-host seam does not leak <c>AcDream.Core.Net.Messages</c> shapes
/// into every consumer.
/// </summary>
public readonly record struct LiveSessionRosterEntry(
uint Id,
string Name,
uint SecondsGreyedOut);
/// <summary>
/// Campaign LA slice LA1: the account's active-character roster, reported to
/// <see cref="ILiveSessionLifecycleHost.ReportRoster"/> right after
/// <c>CharacterList</c> arrives and BEFORE selection — see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 item 2. Hosts forward
/// this to their status stream (<c>characterList</c> event) and, later
/// (LA7/LA8), to the character-select screen. Deleted characters are
/// deliberately excluded — the same candidate set
/// <see cref="CharacterList.TrySelectFirstAvailable"/> already uses.
/// </summary>
public sealed record LiveSessionRosterReport(
string AccountName,
int SlotCount,
IReadOnlyList<LiveSessionRosterEntry> Entries);
/// <summary> /// <summary>
/// Runtime boundary for the domain and presentation sinks attached to one /// Runtime boundary for the domain and presentation sinks attached to one
/// exact <see cref="WorldSession"/> generation. The controller owns the /// exact <see cref="WorldSession"/> generation. The controller owns the
@ -61,6 +87,10 @@ public interface ILiveSessionLifecycleHost
void ResetSessionState(RuntimeGenerationToken retiringGeneration); void ResetSessionState(RuntimeGenerationToken retiringGeneration);
void ReportConnecting(string host, int port, string user); void ReportConnecting(string host, int port, string user);
void ReportConnected(); void ReportConnected();
/// <summary>Campaign LA slice LA1: reported once per successful
/// <c>CharacterList</c> receipt, right before character selection. See
/// <see cref="LiveSessionRosterReport"/>.</summary>
void ReportRoster(LiveSessionRosterReport roster);
void ApplySelectedCharacter(LiveSessionCharacterSelection selection); void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
void ApplyEnteredWorld(LiveSessionCharacterSelection selection); void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
void DetachSession(WorldSession session); void DetachSession(WorldSession session);
@ -610,6 +640,13 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred); return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
CharacterList.Parsed? characters = _operations.GetCharacters(session); CharacterList.Parsed? characters = _operations.GetCharacters(session);
if (characters is not null)
{
host.ReportRoster(BuildRosterReport(characters));
if (!IsCurrent(scope, generation))
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
}
if (characters is null if (characters is null
|| !TrySelectCharacter( || !TrySelectCharacter(
characters, characters,
@ -838,6 +875,29 @@ public sealed class LiveSessionController
private LiveSessionStartResult ConnectedResult() private LiveSessionStartResult ConnectedResult()
=> new(LiveSessionStartStatus.Connected, _activeSelection); => new(LiveSessionStartStatus.Connected, _activeSelection);
/// <summary>Campaign LA slice LA1: projects the wire-shaped
/// <see cref="CharacterList.Parsed"/> into the decoupled
/// <see cref="LiveSessionRosterReport"/>. Deleted characters are
/// excluded, matching <see cref="CharacterList.TrySelectFirstAvailable"/>'s
/// candidate set.</summary>
private static LiveSessionRosterReport BuildRosterReport(
CharacterList.Parsed characters)
{
var entries = new LiveSessionRosterEntry[characters.Characters.Count];
for (int i = 0; i < entries.Length; i++)
{
CharacterList.Character character = characters.Characters[i];
entries[i] = new LiveSessionRosterEntry(
character.Id,
character.Name,
character.SecondsGreyedOut);
}
return new LiveSessionRosterReport(
characters.AccountName,
characters.SlotCount,
entries);
}
private static bool TrySelectCharacter( private static bool TrySelectCharacter(
CharacterList.Parsed characters, CharacterList.Parsed characters,
LiveSessionCharacterSelector? selector, LiveSessionCharacterSelector? selector,

View file

@ -28,7 +28,18 @@ public sealed record LiveSessionHostBindings(
LiveSessionSelectionBindings Selection, LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld, LiveSessionEnteredWorldBindings EnteredWorld,
Action<string, int, string> Connecting, Action<string, int, string> Connecting,
Action Connected); Action Connected,
/// <summary>Campaign LA slice LA1: reported once per successful
/// <c>CharacterList</c> receipt, right before character selection — see
/// <see cref="LiveSessionRosterReport"/>. Hosts forward this to their
/// status stream's <c>characterList</c> event.</summary>
Action<LiveSessionRosterReport> Roster,
/// <summary>Campaign LA slice LA1: reported once entered-world state is
/// applied, carrying the full selection (id + name) — unlike
/// <see cref="EnteredWorld"/>'s narrow <c>SetActiveCharacter(string)</c>
/// fan-out, this exists so a status writer can emit the
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
Action<LiveSessionCharacterSelection> CharacterEntered);
/// <summary> /// <summary>
/// Runtime host for the one canonical <see cref="LiveSessionController"/>. /// Runtime host for the one canonical <see cref="LiveSessionController"/>.
@ -84,6 +95,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
private readonly LiveSessionRoutingFactories _routing; private readonly LiveSessionRoutingFactories _routing;
private readonly LiveSessionSelectionBindings _selection; private readonly LiveSessionSelectionBindings _selection;
private readonly LiveSessionEnteredWorldBindings _enteredWorld; private readonly LiveSessionEnteredWorldBindings _enteredWorld;
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
private readonly Action<RuntimeGenerationToken> _reset; private readonly Action<RuntimeGenerationToken> _reset;
private readonly LiveSessionLifecycleHost _lifecycle; private readonly LiveSessionLifecycleHost _lifecycle;
private PendingRouteRollback? _pendingRouteRollback; private PendingRouteRollback? _pendingRouteRollback;
@ -100,11 +112,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection)); _selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
_enteredWorld = bindings.EnteredWorld _enteredWorld = bindings.EnteredWorld
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld)); ?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
_characterEntered = bindings.CharacterEntered
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
ArgumentNullException.ThrowIfNull(_routing.CreateEvents); ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
ArgumentNullException.ThrowIfNull(_routing.CreateCommands); ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
ArgumentNullException.ThrowIfNull(bindings.Reset); ArgumentNullException.ThrowIfNull(bindings.Reset);
ArgumentNullException.ThrowIfNull(bindings.Connecting); ArgumentNullException.ThrowIfNull(bindings.Connecting);
ArgumentNullException.ThrowIfNull(bindings.Connected); ArgumentNullException.ThrowIfNull(bindings.Connected);
ArgumentNullException.ThrowIfNull(bindings.Roster);
Validate(_selection, _enteredWorld); Validate(_selection, _enteredWorld);
_reset = bindings.Reset; _reset = bindings.Reset;
@ -113,6 +128,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
Reset: ResetSessionState, Reset: ResetSessionState,
Connecting: bindings.Connecting, Connecting: bindings.Connecting,
Connected: bindings.Connected, Connected: bindings.Connected,
Roster: bindings.Roster,
Selected: ApplySelection, Selected: ApplySelection,
Entered: ApplyEnteredWorld)); Entered: ApplyEnteredWorld));
} }
@ -217,6 +233,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
_enteredWorld.SyncToolbar(); _enteredWorld.SyncToolbar();
_enteredWorld.LoadCharacterSettings(name); _enteredWorld.LoadCharacterSettings(name);
_enteredWorld.ArmPlayerModeAutoEntry(); _enteredWorld.ArmPlayerModeAutoEntry();
_characterEntered(selection);
} }
private void RethrowWithRetryableRollback( private void RethrowWithRetryableRollback(

View file

@ -7,6 +7,7 @@ public sealed record LiveSessionLifecycleBindings(
Action<RuntimeGenerationToken> Reset, Action<RuntimeGenerationToken> Reset,
Action<string, int, string> Connecting, Action<string, int, string> Connecting,
Action Connected, Action Connected,
Action<LiveSessionRosterReport> Roster,
Action<LiveSessionCharacterSelection> Selected, Action<LiveSessionCharacterSelection> Selected,
Action<LiveSessionCharacterSelection> Entered); Action<LiveSessionCharacterSelection> Entered);
@ -27,6 +28,7 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
ArgumentNullException.ThrowIfNull(bindings.Reset); ArgumentNullException.ThrowIfNull(bindings.Reset);
ArgumentNullException.ThrowIfNull(bindings.Connecting); ArgumentNullException.ThrowIfNull(bindings.Connecting);
ArgumentNullException.ThrowIfNull(bindings.Connected); ArgumentNullException.ThrowIfNull(bindings.Connected);
ArgumentNullException.ThrowIfNull(bindings.Roster);
ArgumentNullException.ThrowIfNull(bindings.Selected); ArgumentNullException.ThrowIfNull(bindings.Selected);
ArgumentNullException.ThrowIfNull(bindings.Entered); ArgumentNullException.ThrowIfNull(bindings.Entered);
} }
@ -51,6 +53,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
public void ReportConnected() => _bindings.Connected(); public void ReportConnected() => _bindings.Connected();
public void ReportRoster(LiveSessionRosterReport roster) =>
_bindings.Roster(roster);
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) => public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
_bindings.Selected(selection); _bindings.Selected(selection);

View file

@ -0,0 +1,162 @@
using System.Text.Json;
namespace AcDream.Runtime.Session;
/// <summary>
/// Campaign LA slice LA1: appends one JSON object per line to a per-session
/// status-event file the launcher tails
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1,
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6).
///
/// <para>
/// This is a SEPARATE sink from <c>HeadlessDiagnosticWriter</c> — that class
/// is a single shared-stdout JSONL diagnostics stream with no per-session
/// file; this class writes one file per session, meant to be read by an
/// external process (the launcher) rather than scraped from console output.
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without
/// breaking an existing reader.
/// </para>
///
/// <para>
/// Every write opens the file in append mode with <see cref="FileShare.Read"/>
/// so an external tailer can read the file concurrently, writes exactly one
/// line, flushes, and closes — there is no long-lived file handle to leak or
/// to dispose. A writer constructed with a <see langword="null"/> or blank
/// path is a permanent no-op: every method becomes a cheap null-check, so
/// callers never need to guard construction sites on whether a status file
/// was configured.
/// </para>
///
/// <para>
/// <strong>Never write credential material into this stream.</strong> Every
/// event method below takes only identifiers, names, and counts — there is no
/// parameter shape that could carry a password, by construction.
/// </para>
/// </summary>
public sealed class SessionStatusWriter
{
private const int VocabularyVersion = 1;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
private readonly string? _path;
private readonly TimeProvider _timeProvider;
private readonly object _gate = new();
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
{
_path = string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path);
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// True when this writer has a configured path and will actually append
/// events. Lets a caller with an expensive report to build (e.g. the
/// roster projection) skip that work entirely when nobody configured a
/// status file for this session.
/// </summary>
public bool IsEnabled => _path is not null;
public void Started(string sessionId) =>
Write(new
{
v = VocabularyVersion,
e = "started",
t = Now(),
sessionId,
});
public void Connected(string sessionId) =>
Write(new
{
v = VocabularyVersion,
e = "connected",
t = Now(),
sessionId,
});
public void CharacterList(string sessionId, LiveSessionRosterReport roster)
{
ArgumentNullException.ThrowIfNull(roster);
if (!IsEnabled)
return;
Write(new
{
v = VocabularyVersion,
e = "characterList",
t = Now(),
sessionId,
accountName = roster.AccountName,
slotCount = roster.SlotCount,
characters = roster.Entries
.Select(static entry => new
{
id = entry.Id,
name = entry.Name,
secondsGreyedOut = entry.SecondsGreyedOut,
})
.ToArray(),
});
}
public void EnteredWorld(string sessionId, uint characterId, string characterName) =>
Write(new
{
v = VocabularyVersion,
e = "enteredWorld",
t = Now(),
sessionId,
characterId,
characterName,
});
public void Disconnected(string sessionId, string reason) =>
Write(new
{
v = VocabularyVersion,
e = "disconnected",
t = Now(),
sessionId,
reason,
});
public void Exited(string sessionId, int code, string reason) =>
Write(new
{
v = VocabularyVersion,
e = "exited",
t = Now(),
sessionId,
code,
reason,
});
private string Now() =>
_timeProvider.GetUtcNow().ToString(
"O",
System.Globalization.CultureInfo.InvariantCulture);
private void Write<T>(T value)
{
if (_path is not { } path)
return;
string line = JsonSerializer.Serialize(value, JsonOptions);
lock (_gate)
{
using FileStream stream = new(
path,
FileMode.Append,
FileAccess.Write,
FileShare.Read);
using var writer = new StreamWriter(stream);
writer.WriteLine(line);
writer.Flush();
}
}
}

View file

@ -0,0 +1,148 @@
using AcDream.App;
using AcDream.App.Configuration;
using AcDream.Runtime.Session;
namespace AcDream.App.Tests.Configuration;
/// <summary>
/// Campaign LA slice LA1: round-trip tests for
/// <see cref="RuntimeOptions.FromSessionConfig"/> — the overlay that turns a
/// parsed <see cref="SessionConfiguration"/>/<see cref="SessionDescriptor"/>
/// into the same typed bundle the env-var dev flow produces.
/// </summary>
public sealed class RuntimeOptionsSessionConfigTests
{
[Fact]
public void SessionConfigOverridesLiveSettingsAndCarriesAllFiveNewFields()
{
var config = new SessionConfiguration { Version = 1 };
var session = new SessionDescriptor
{
Id = "gui-session",
Endpoint = new SessionEndpointDescriptor
{
Host = "192.168.1.50",
Port = 9123,
},
Account = "guiaccount",
Character = new SessionCharacterSelectorDescriptor { Name = "GuiToon" },
Credential = new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.Environment,
Reference = "IGNORED",
},
Plugins = ["PluginA", "PluginB"],
LoginCommands = ["/tell x, hi"],
LoginCommandDelayMs = 900,
StatusFile = "status.jsonl",
};
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
"D:\\dat",
_ => null,
"session.json",
config,
session,
"resolved-password");
Assert.True(options.LiveMode);
Assert.Equal("192.168.1.50", options.LiveHost);
Assert.Equal(9123, options.LivePort);
Assert.Equal("guiaccount", options.LiveUser);
Assert.Equal("resolved-password", options.LivePass);
Assert.Equal("session.json", options.SessionConfigPath);
Assert.Equal("gui-session", options.SessionId);
Assert.Equal(
new LiveSessionCharacterSelector(null, null, "GuiToon"),
options.LiveCharacterSelector);
Assert.Equal("status.jsonl", options.StatusFilePath);
Assert.Equal(["PluginA", "PluginB"], options.Plugins);
Assert.Equal(["/tell x, hi"], options.LoginCommands);
Assert.Equal(900, options.LoginCommandDelayMs);
}
[Fact]
public void AbsentCharacterSelectorLeavesFirstAvailableFallbackInEffect()
{
var config = new SessionConfiguration { Version = 1 };
var session = new SessionDescriptor
{
Id = "no-selector",
Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 },
Account = "account",
Credential = new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.Environment,
Reference = "X",
},
};
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
"D:\\dat",
_ => null,
"session.json",
config,
session,
"password");
Assert.Null(options.LiveCharacterSelector);
Assert.Null(options.Plugins);
Assert.Empty(options.LoginCommands);
Assert.Equal(500, options.LoginCommandDelayMs);
Assert.Null(options.StatusFilePath);
}
[Fact]
public void ProcessContentOverridesDatDirectoryAndPreparedAssetPath()
{
var config = new SessionConfiguration
{
Version = 1,
Process = new SessionProcessSettings
{
Content = new SessionContentDescriptor
{
DatDirectory = "D:\\configured-dats",
PreparedAssetPath = "D:\\configured-dats\\acdream.pak",
},
},
};
var session = new SessionDescriptor
{
Id = "content-session",
Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 },
Account = "account",
Credential = new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.Environment,
Reference = "X",
},
};
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
"D:\\configured-dats",
_ => null,
"session.json",
config,
session,
"password");
Assert.Equal(
"D:\\configured-dats\\acdream.pak",
options.PreparedAssetPath);
}
[Fact]
public void EnvironmentFlowLeavesEveryNewFieldAtItsNothingConfiguredDefault()
{
RuntimeOptions options = RuntimeOptions.Parse("D:\\dat", _ => null);
Assert.Null(options.SessionConfigPath);
Assert.Null(options.SessionId);
Assert.Null(options.LiveCharacterSelector);
Assert.Null(options.StatusFilePath);
Assert.Null(options.Plugins);
Assert.Empty(options.LoginCommands);
Assert.Equal(500, options.LoginCommandDelayMs);
}
}

View file

@ -0,0 +1,225 @@
using System.Runtime.CompilerServices;
using AcDream.App.Configuration;
namespace AcDream.App.Tests.Configuration;
/// <summary>
/// Campaign LA slice LA1: proves the App config reader accepts the EXACT
/// document the Headless reader also accepts —
/// <c>tests/Fixtures/campaign-la/session-config-shared-fixture.json</c> is
/// parsed by both <see cref="SessionConfigurationLoader"/> here and
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c> in
/// <c>AcDream.Headless.Tests</c>'s twin of this test. This is the
/// pinned-contract acceptance test from
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1: "a SHARED fixture
/// JSON parsed by both test suites proving the two readers accept the
/// identical document." If either reader's DTO shape drifts from the pinned
/// contract, ONE of these two tests fails.
/// </summary>
public sealed class SessionConfigurationSharedFixtureTests
{
[Fact]
public void AppReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
{
(SessionConfiguration configuration, SessionDescriptor session) =
SessionConfigurationLoader.Load(SharedFixturePath());
Assert.Equal(1, configuration.Version);
Assert.Equal("shared-fixture", session.Id);
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);
// App parses the policy field structurally but never consults it —
// the pinned contract's "parsed-and-ignored" clause.
Assert.Equal("idle", session.Policy?.Id);
Assert.Equal(
SessionCredentialProviderKind.Environment,
session.Credential.Provider);
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference);
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
Assert.Equal(
["/tell someone, hi", "/vt start"],
session.LoginCommands);
Assert.Equal(750, session.LoginCommandDelayMs);
Assert.Equal("shared-fixture-status.jsonl", session.StatusFile);
}
[Fact]
public void AbsentLaunchContractFieldsFallBackToPinnedDefaults()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "no-launch-contract-fields",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "X" }
}
]
}
""");
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
Assert.Null(session.Character);
Assert.Null(session.Plugins);
Assert.Null(session.LoginCommands);
Assert.Equal(500, session.LoginCommandDelayMs);
Assert.Null(session.StatusFile);
}
[Fact]
public void MoreThanOneSessionFailsLoadForTheGraphicalHost()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "one",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "A" }
},
{
"id": "two",
"endpoint": { "host": "127.0.0.1", "port": 9001 },
"account": "account2",
"credential": { "provider": "environment", "reference": "B" }
}
]
}
""");
Assert.Throws<SessionConfigurationException>(
() => SessionConfigurationLoader.Load(file.Path));
}
[Fact]
public void EmptyPluginsEntryFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-plugins",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "X" },
"plugins": ["Ok", " "]
}
]
}
""");
Assert.Throws<SessionConfigurationException>(
() => SessionConfigurationLoader.Load(file.Path));
}
[Fact]
public void NegativeLoginCommandDelayFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-delay",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "X" },
"loginCommandDelayMs": -1
}
]
}
""");
Assert.Throws<SessionConfigurationException>(
() => SessionConfigurationLoader.Load(file.Path));
}
[Fact]
public void BlankStatusFileFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-status-file",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"credential": { "provider": "environment", "reference": "X" },
"statusFile": " "
}
]
}
""");
Assert.Throws<SessionConfigurationException>(
() => SessionConfigurationLoader.Load(file.Path));
}
internal static string SharedFixturePath(
[CallerFilePath] string sourcePath = "") =>
Path.Combine(
FindRepositoryRoot(sourcePath),
"tests",
"Fixtures",
"campaign-la",
"session-config-shared-fixture.json");
private static string FindRepositoryRoot(string sourcePath)
{
string[] starts =
{
Path.GetDirectoryName(sourcePath) ?? string.Empty,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
};
foreach (string start in starts)
{
if (string.IsNullOrEmpty(start))
continue;
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
"Could not find AcDream.slnx above the working or output directory.");
}
private sealed class TemporaryFile : IDisposable
{
private TemporaryFile(string path) => Path = path;
internal string Path { get; }
internal static TemporaryFile Create(string json)
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-app-la1-{Guid.NewGuid():N}.json");
File.WriteAllText(path, json);
return new TemporaryFile(path);
}
public void Dispose() => File.Delete(Path);
}
}

View file

@ -0,0 +1,167 @@
using AcDream.App.Configuration;
using AcDream.App.Credentials;
namespace AcDream.App.Tests.Credentials;
/// <summary>
/// Campaign LA slice LA1: <see cref="AppCredentialResolver"/> is a minimal
/// port of <c>AcDream.Headless.Credentials.HeadlessCredentialResolver</c>
/// scoped to the App session-config credential shape — see that file's own
/// doc for why it is an independent port rather than a shared reference.
/// Mirrors <c>HeadlessCredentialResolverTests</c>'s coverage.
/// </summary>
public sealed class AppCredentialResolverTests
{
[Fact]
public void EnvironmentSecretIsRedactedAndErasable()
{
const string variable = "ACDREAM_LA1_TEST_ENV_SECRET";
const string secretValue = "test-secret-value";
Environment.SetEnvironmentVariable(variable, secretValue);
try
{
var resolver = new AppCredentialResolver(
TextReader.Null,
Environment.CurrentDirectory,
isLinux: false);
AppCredentialSecret secret = resolver.Resolve(
"session",
new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.Environment,
Reference = variable,
});
Assert.Equal(secretValue, secret.Reveal());
Assert.DoesNotContain(secretValue, secret.ToString());
secret.Dispose();
Assert.True(secret.IsDisposed);
Assert.Throws<ObjectDisposedException>(secret.Reveal);
}
finally
{
Environment.SetEnvironmentVariable(variable, null);
}
}
[Fact]
public void StandardInputConsumesOneSecretWithoutEchoingIt()
{
const string secretValue = "stdin-secret";
var resolver = new AppCredentialResolver(
new StringReader(secretValue + Environment.NewLine),
Environment.CurrentDirectory,
isLinux: false);
using AppCredentialSecret secret = resolver.Resolve(
"session",
new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.StandardInput,
Reference = "session-stdin",
});
Assert.Equal(secretValue, secret.Reveal());
Assert.DoesNotContain(secretValue, secret.ToString());
}
[Fact]
public void CredentialFileIsResolvedRelativeToConfiguredDirectory()
{
string directory = Path.Combine(
Path.GetTempPath(),
$"acdream-app-credentials-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "session.pass");
File.WriteAllText(path, "file-secret" + Environment.NewLine);
try
{
var resolver = new AppCredentialResolver(
TextReader.Null,
directory,
isLinux: false);
using AppCredentialSecret secret = resolver.Resolve(
"session",
new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.File,
Reference = "session.pass",
});
Assert.Equal("file-secret", secret.Reveal());
}
finally
{
File.Delete(path);
Directory.Delete(directory);
}
}
[Fact]
public void MissingSecretErrorNeverContainsAnotherSecret()
{
const string variable = "ACDREAM_LA1_TEST_OTHER_SECRET";
const string unrelatedSecret = "must-not-leak";
Environment.SetEnvironmentVariable(variable, unrelatedSecret);
try
{
var resolver = new AppCredentialResolver(
new StringReader(string.Empty),
Environment.CurrentDirectory,
isLinux: false);
AppCredentialException error =
Assert.Throws<AppCredentialException>(() =>
resolver.Resolve(
"session",
new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.Environment,
Reference = "ACDREAM_LA1_TEST_DOES_NOT_EXIST",
}));
Assert.DoesNotContain(unrelatedSecret, error.ToString());
}
finally
{
Environment.SetEnvironmentVariable(variable, null);
}
}
[Fact]
public void LinuxRejectsGroupOrOtherCredentialPermissions()
{
if (!OperatingSystem.IsLinux())
return;
string path = Path.Combine(
Path.GetTempPath(),
$"acdream-app-credential-{Guid.NewGuid():N}");
File.WriteAllText(path, "linux-secret");
File.SetUnixFileMode(
path,
UnixFileMode.UserRead | UnixFileMode.GroupRead);
try
{
var resolver = new AppCredentialResolver(
TextReader.Null,
Path.GetDirectoryName(path)!,
isLinux: true);
Assert.Throws<AppCredentialException>(() =>
resolver.Resolve(
"session",
new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.File,
Reference = Path.GetFileName(path),
}));
}
finally
{
File.Delete(path);
}
}
}

View file

@ -97,6 +97,7 @@ public sealed class LiveSessionShutdownIntegrationTests
RuntimeGenerationToken retiringGeneration) { } RuntimeGenerationToken retiringGeneration) { }
public void ReportConnecting(string host, int port, string user) { } public void ReportConnecting(string host, int port, string user) { }
public void ReportConnected() { } public void ReportConnected() { }
public void ReportRoster(LiveSessionRosterReport roster) { }
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { } public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { }
public void DetachSession(WorldSession session) { } public void DetachSession(WorldSession session) { }

View file

@ -917,7 +917,9 @@ public sealed class CurrentGameRuntimeAdapterTests
_ => { }, _ => { },
() => { }), () => { }),
(_, _, _) => { }, (_, _, _) => { },
() => { }), () => { },
_ => { },
_ => { }),
new LiveSessionConnectOptions( new LiveSessionConnectOptions(
true, true,
"127.0.0.1", "127.0.0.1",

View file

@ -222,7 +222,9 @@ public sealed class HeadlessSessionEventRouteRetryPendingTests
new LiveSessionEnteredWorldBindings( new LiveSessionEnteredWorldBindings(
_ => { }, () => { }, () => { }, _ => { }, () => { }), _ => { }, () => { }, () => { }, _ => { }, () => { }),
(_, _, _) => { }, (_, _, _) => { },
() => { }), () => { },
_ => { },
_ => { }),
options); options);
LiveSessionStartResult startResult = live.Start(options); LiveSessionStartResult startResult = live.Start(options);
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);

View file

@ -3,6 +3,7 @@ using System.Collections.Immutable;
using System.Net; using System.Net;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Text.Json;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
@ -61,6 +62,95 @@ public sealed class HeadlessSessionHostTests
Assert.DoesNotContain("AcDream.App", diagnostics); Assert.DoesNotContain("AcDream.App", diagnostics);
} }
/// <summary>
/// Campaign LA slice LA1: proves the status-event writer fires the
/// pinned lifecycle vocabulary — started/connected/characterList/
/// enteredWorld/disconnected/exited — in order, from a real
/// <see cref="HeadlessSessionHost"/> start+dispose cycle, and that the
/// roster surfaced matches <see cref="FixtureSessionOperations.GetCharacters"/>
/// exactly (before selection has happened — the roster is reported for
/// BOTH candidates, not just the selected one).
/// </summary>
[Fact]
public void StatusFileReceivesThePinnedLifecycleEventsInOrder()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-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(
Descriptor(statusFile: statusPath),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
RuntimeSessionStartResult started = host.Start();
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
host.Dispose();
string[] lines = File.ReadAllLines(statusPath);
string[] eventNames = lines
.Select(line => JsonDocument.Parse(line)
.RootElement.GetProperty("e").GetString()!)
.ToArray();
Assert.Equal(
[
"started", "connected", "characterList", "enteredWorld",
"disconnected", "exited",
],
eventNames);
using JsonDocument characterListDoc = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "characterList")]);
JsonElement characterList = characterListDoc.RootElement;
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
Assert.Equal(2, characterList.GetProperty("characters").GetArrayLength());
using JsonDocument enteredWorldDoc = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "enteredWorld")]);
Assert.Equal(
0x50000002u,
enteredWorldDoc.RootElement.GetProperty("characterId").GetUInt32());
using JsonDocument exitedDoc = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "exited")]);
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
string contents = File.ReadAllText(statusPath);
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
}
[Fact]
public void AbsentStatusFileConstructsANoOpWriter()
{
var operations = new FixtureSessionOperations();
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret("fixture", "password");
using var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
RuntimeSessionStartResult started = host.Start();
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
// No exception, and (implicitly) no file was ever touched — the
// writer is a permanent no-op with no configured path.
}
[Fact] [Fact]
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
{ {
@ -1937,7 +2027,9 @@ public sealed class HeadlessSessionHostTests
_ => { }, _ => { },
() => { }), () => { }),
(_, _, _) => { }, (_, _, _) => { },
() => { })); () => { },
_ => { },
_ => { }));
} }
private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations
@ -1967,7 +2059,8 @@ public sealed class HeadlessSessionHostTests
HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment, HeadlessCredentialProviderKind.Environment,
string credentialReference = "BOT_PASSWORD", string credentialReference = "BOT_PASSWORD",
Dictionary<string, bool>? characterOptions = null) => new() Dictionary<string, bool>? characterOptions = null,
string? statusFile = null) => new()
{ {
Id = "bot", Id = "bot",
Endpoint = new HeadlessEndpointDescriptor Endpoint = new HeadlessEndpointDescriptor
@ -1990,6 +2083,7 @@ public sealed class HeadlessSessionHostTests
Reference = credentialReference, Reference = credentialReference,
}, },
CharacterOptions = characterOptions, CharacterOptions = characterOptions,
StatusFile = statusFile,
}; };
private static void HydrateGroundedPlayer(GameRuntime runtime) private static void HydrateGroundedPlayer(GameRuntime runtime)

View file

@ -0,0 +1,206 @@
using System.Runtime.CompilerServices;
using AcDream.Headless.Configuration;
namespace AcDream.Headless.Tests;
/// <summary>
/// Campaign LA slice LA1: proves the Headless config reader accepts the
/// EXACT document the App reader also accepts —
/// <c>tests/Fixtures/campaign-la/session-config-shared-fixture.json</c> is
/// parsed by both <see cref="HeadlessConfigurationLoader"/> here and
/// <c>AcDream.App.Configuration.SessionConfigurationLoader</c> in
/// <c>AcDream.App.Tests</c>'s twin of this test. This is the pinned-contract
/// acceptance test from <c>docs/plans/2026-08-14-launcher-campaign.md</c>
/// LA1: "a SHARED fixture JSON parsed by both test suites proving the two
/// readers accept the identical document." If either reader's DTO shape
/// drifts from the pinned contract, ONE of these two tests fails.
/// </summary>
public sealed class SessionConfigurationSharedFixtureTests
{
[Fact]
public void HeadlessReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
{
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(SharedFixturePath());
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
Assert.Equal("shared-fixture", session.Id);
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(
HeadlessCredentialProviderKind.Environment,
session.Credential.Provider);
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference);
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
Assert.Equal(
["/tell someone, hi", "/vt start"],
session.LoginCommands);
Assert.Equal(750, session.LoginCommandDelayMs);
Assert.Equal("shared-fixture-status.jsonl", session.StatusFile);
}
[Fact]
public void AbsentLaunchContractFieldsFallBackToPinnedDefaults()
{
// Every LA1 field is optional; a document that omits all five must
// still load, with loginCommandDelayMs defaulting to the pinned
// 500 ms and the rest defaulting to "nothing configured".
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "no-launch-contract-fields",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"character": { "index": 0 },
"policy": { "id": "idle" },
"credential": { "provider": "environment", "reference": "X" }
}
]
}
""");
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(file.Path);
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
Assert.Null(session.Plugins);
Assert.Null(session.LoginCommands);
Assert.Equal(500, session.LoginCommandDelayMs);
Assert.Null(session.StatusFile);
}
[Fact]
public void EmptyPluginsEntryFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-plugins",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"character": { "index": 0 },
"policy": { "id": "idle" },
"credential": { "provider": "environment", "reference": "X" },
"plugins": ["Ok", " "]
}
]
}
""");
Assert.Throws<HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact]
public void NegativeLoginCommandDelayFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-delay",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"character": { "index": 0 },
"policy": { "id": "idle" },
"credential": { "provider": "environment", "reference": "X" },
"loginCommandDelayMs": -1
}
]
}
""");
Assert.Throws<HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact]
public void BlankStatusFileFailsLoad()
{
using TemporaryFile file = TemporaryFile.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-status-file",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"character": { "index": 0 },
"policy": { "id": "idle" },
"credential": { "provider": "environment", "reference": "X" },
"statusFile": " "
}
]
}
""");
Assert.Throws<HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
internal static string SharedFixturePath(
[CallerFilePath] string sourcePath = "") =>
Path.Combine(
FindRepositoryRoot(sourcePath),
"tests",
"Fixtures",
"campaign-la",
"session-config-shared-fixture.json");
private static string FindRepositoryRoot(string sourcePath)
{
string[] starts =
{
Path.GetDirectoryName(sourcePath) ?? string.Empty,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
};
foreach (string start in starts)
{
if (string.IsNullOrEmpty(start))
continue;
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
return directory.FullName;
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
"Could not find AcDream.slnx above the working or output directory.");
}
private sealed class TemporaryFile : IDisposable
{
private TemporaryFile(string path) => Path = path;
internal string Path { get; }
internal static TemporaryFile Create(string json)
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-headless-la1-{Guid.NewGuid():N}.json");
File.WriteAllText(path, json);
return new TemporaryFile(path);
}
public void Dispose() => File.Delete(Path);
}
}

View file

@ -55,7 +55,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
_ => { }, _ => { },
() => { }), () => { }),
(_, _, _) => { }, (_, _, _) => { },
() => { }), () => { },
_ => { },
_ => { }),
options); options);
adapter = new DirectGameRuntimeCommandAdapter(runtime, live); adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
var trace = new RuntimeTraceRecorder(); var trace = new RuntimeTraceRecorder();
@ -752,7 +754,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
_ => { }, _ => { },
() => { }), () => { }),
(_, _, _) => { }, (_, _, _) => { },
() => { }), () => { },
_ => { },
_ => { }),
options); options);
adapter = new DirectGameRuntimeCommandAdapter(runtime, live); adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
_ = adapter.Session.Start(runtime.Generation); _ = adapter.Session.Start(runtime.Generation);

View file

@ -135,6 +135,7 @@ public sealed class LiveSessionControllerTests
public Action? OnReset { get; set; } public Action? OnReset { get; set; }
public Action? OnConnecting { get; set; } public Action? OnConnecting { get; set; }
public Action? OnConnected { get; set; } public Action? OnConnected { get; set; }
public Action? OnRoster { get; set; }
public Action? OnSelected { get; set; } public Action? OnSelected { get; set; }
public Action? OnActivate { get; set; } public Action? OnActivate { get; set; }
public Action? OnEntered { get; set; } public Action? OnEntered { get; set; }
@ -146,6 +147,7 @@ public sealed class LiveSessionControllerTests
public bool ThrowOnBind { get; set; } public bool ThrowOnBind { get; set; }
public bool ThrowOnConnecting { get; set; } public bool ThrowOnConnecting { get; set; }
public bool ThrowOnConnected { get; set; } public bool ThrowOnConnected { get; set; }
public bool ThrowOnRoster { get; set; }
public bool ThrowOnSelected { get; set; } public bool ThrowOnSelected { get; set; }
public bool ThrowOnActivate { get; set; } public bool ThrowOnActivate { get; set; }
public bool ThrowOnEntered { get; set; } public bool ThrowOnEntered { get; set; }
@ -158,6 +160,7 @@ public sealed class LiveSessionControllerTests
public List<TestCommandBus> CommandBuses { get; } = []; public List<TestCommandBus> CommandBuses { get; } = [];
public List<LiveSessionCharacterSelection> Selections { get; } = []; public List<LiveSessionCharacterSelection> Selections { get; } = [];
public List<RuntimeGenerationToken> ResetGenerations { get; } = []; public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
public List<LiveSessionRosterReport> Rosters { get; } = [];
public LiveSessionBinding BindSession(WorldSession session) public LiveSessionBinding BindSession(WorldSession session)
{ {
@ -231,6 +234,15 @@ public sealed class LiveSessionControllerTests
throw new InvalidOperationException("connected failure"); throw new InvalidOperationException("connected failure");
} }
public void ReportRoster(LiveSessionRosterReport roster)
{
calls.Add("roster");
Rosters.Add(roster);
OnRoster?.Invoke();
if (ThrowOnRoster)
throw new InvalidOperationException("roster failure");
}
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
{ {
calls.Add("selected"); calls.Add("selected");
@ -290,7 +302,7 @@ public sealed class LiveSessionControllerTests
Assert.Equal( Assert.Equal(
[ [
"reset", "resolve", "create", "bind", "report-connecting", "reset", "resolve", "create", "bind", "report-connecting",
"connect", "report-connected", "selected", "enter:1", "connect", "report-connected", "roster", "selected", "enter:1",
"activate", "entered", "activate", "entered",
], ],
calls); calls);
@ -302,6 +314,32 @@ public sealed class LiveSessionControllerTests
Assert.True(host.CommandBuses[0].Active); Assert.True(host.CommandBuses[0].Active);
} }
[Fact]
public void Start_ReportsRosterFromCharacterListBeforeSelection()
{
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(), host);
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
LiveSessionRosterReport roster = Assert.Single(host.Rosters);
Assert.Equal("Canonical", roster.AccountName);
Assert.Equal(11, roster.SlotCount);
Assert.Equal(
[
new LiveSessionRosterEntry(0x50000001u, "Grey", 10u),
new LiveSessionRosterEntry(0x50000002u, "Ready", 0u),
],
roster.Entries);
// "roster" must land strictly before "selected" — the launcher's
// char-select screen (LA7/LA8) will read the roster before any
// selection has been made.
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
}
[Fact] [Fact]
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession() public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
{ {
@ -488,7 +526,7 @@ public sealed class LiveSessionControllerTests
[ [
"deactivate", "detach-events", "dispose-session", "detach-session", "deactivate", "detach-events", "dispose-session", "detach-session",
"reset", "resolve", "create", "bind", "report-connecting", "reset", "resolve", "create", "bind", "report-connecting",
"connect", "report-connected", "selected", "enter:1", "connect", "report-connected", "roster", "selected", "enter:1",
"activate", "entered", "activate", "entered",
], ],
calls); calls);
@ -742,6 +780,7 @@ public sealed class LiveSessionControllerTests
[InlineData("connecting")] [InlineData("connecting")]
[InlineData("connected")] [InlineData("connected")]
[InlineData("characters")] [InlineData("characters")]
[InlineData("roster")]
[InlineData("selected")] [InlineData("selected")]
[InlineData("activate")] [InlineData("activate")]
[InlineData("entered")] [InlineData("entered")]
@ -755,6 +794,7 @@ public sealed class LiveSessionControllerTests
case "connecting": host.ThrowOnConnecting = true; break; case "connecting": host.ThrowOnConnecting = true; break;
case "connected": host.ThrowOnConnected = true; break; case "connected": host.ThrowOnConnected = true; break;
case "characters": operations.ThrowOnCharacters = true; break; case "characters": operations.ThrowOnCharacters = true; break;
case "roster": host.ThrowOnRoster = true; break;
case "selected": host.ThrowOnSelected = true; break; case "selected": host.ThrowOnSelected = true; break;
case "activate": host.ThrowOnActivate = true; break; case "activate": host.ThrowOnActivate = true; break;
case "entered": host.ThrowOnEntered = true; break; case "entered": host.ThrowOnEntered = true; break;

View file

@ -35,12 +35,13 @@ public sealed class LiveSessionHostTests
Assert.Equal( Assert.Equal(
[ [
"reset", "resolve", "create", "events", "attach-events", "commands", "reset", "resolve", "create", "events", "attach-events", "commands",
"connecting", "connect", "connected", "connecting", "connect", "connected", "roster:Canonical",
"player:1342177282", "vitals:1342177282", "player:1342177282", "vitals:1342177282",
"chat:1342177282", "persistent:1342177282", "chat:1342177282", "persistent:1342177282",
"vanish:1342177282", "clear-combat", "enter:1", "vanish:1342177282", "clear-combat", "enter:1",
"activate", "active:Ready", "restore-layout", "activate", "active:Ready", "restore-layout",
"sync-toolbar", "load-settings:Ready", "arm-auto-entry", "sync-toolbar", "load-settings:Ready", "arm-auto-entry",
"character-entered:1342177282",
], ],
calls); calls);
Assert.Same(controller.CurrentSession, host.CurrentSession); Assert.Same(controller.CurrentSession, host.CurrentSession);
@ -236,7 +237,10 @@ public sealed class LiveSessionHostTests
name => calls.Add($"load-settings:{name}"), name => calls.Add($"load-settings:{name}"),
() => calls.Add("arm-auto-entry")), () => calls.Add("arm-auto-entry")),
Connecting: (_, _, _) => calls.Add("connecting"), Connecting: (_, _, _) => calls.Add("connecting"),
Connected: () => calls.Add("connected"))); Connected: () => calls.Add("connected"),
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
CharacterEntered: selection =>
calls.Add($"character-entered:{selection.CharacterId}")));
private static LiveSessionConnectOptions LiveOptions( private static LiveSessionConnectOptions LiveOptions(
bool live = true, bool live = true,

View file

@ -18,6 +18,7 @@ public sealed class LiveSessionLifecycleHostTests
host.ResetSessionState(RuntimeGenerationToken.Initial); host.ResetSessionState(RuntimeGenerationToken.Initial);
host.ReportConnecting("host", 9000, "user"); host.ReportConnecting("host", 9000, "user");
host.ReportConnected(); host.ReportConnected();
host.ReportRoster(new LiveSessionRosterReport("account", 11, []));
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account"); var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
host.ApplySelectedCharacter(selection); host.ApplySelectedCharacter(selection);
binding.ActivateCommands(); binding.ActivateCommands();
@ -31,8 +32,8 @@ public sealed class LiveSessionLifecycleHostTests
Assert.Equal( Assert.Equal(
[ [
"bind", "reset", "connecting:host:9000:user", "bind", "reset", "connecting:host:9000:user",
"connected", "selected:toon", "activate", "entered:toon", "connected", "roster:account", "selected:toon", "activate",
"deactivate", "detach-events", "bind", "entered:toon", "deactivate", "detach-events", "bind",
], ],
calls); calls);
replacement.Dispose(); replacement.Dispose();
@ -71,6 +72,7 @@ public sealed class LiveSessionLifecycleHostTests
Connecting: (host, port, user) => Connecting: (host, port, user) =>
calls.Add($"connecting:{host}:{port}:{user}"), calls.Add($"connecting:{host}:{port}:{user}"),
Connected: () => calls.Add("connected"), Connected: () => calls.Add("connected"),
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
Selected: selection => calls.Add($"selected:{selection.CharacterName}"), Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
Entered: selection => calls.Add($"entered:{selection.CharacterName}"))); Entered: selection => calls.Add($"entered:{selection.CharacterName}")));

View file

@ -2342,7 +2342,9 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
_ => { }, _ => { },
() => { }), () => { }),
(_, _, _) => { }, (_, _, _) => { },
() => { }), () => { },
_ => { },
_ => { }),
options); options);
LiveSessionStartResult startResult = live.Start(options); LiveSessionStartResult startResult = live.Start(options);
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);

View file

@ -1049,7 +1049,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests
_ => { }, _ => { },
() => { }), () => { }),
(_, _, _) => { }, (_, _, _) => { },
() => { }), () => { },
_ => { },
_ => { }),
options); options);
LiveSessionStartResult startResult = live.Start(options); LiveSessionStartResult startResult = live.Start(options);
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);

View file

@ -34,7 +34,9 @@ public sealed class RuntimeLiveSessionNoWindowTests
_ => { }, _ => { },
() => { }), () => { }),
(_, _, _) => calls.Add("connecting"), (_, _, _) => calls.Add("connecting"),
() => calls.Add("connected")), () => calls.Add("connected"),
_ => calls.Add("roster"),
selection => calls.Add($"character-entered:{selection.CharacterId}")),
new LiveSessionConnectOptions( new LiveSessionConnectOptions(
true, true,
"127.0.0.1", "127.0.0.1",
@ -61,10 +63,12 @@ public sealed class RuntimeLiveSessionNoWindowTests
"connect", "connect",
"connected", "connected",
"characters", "characters",
"roster",
"player:1342177281", "player:1342177281",
"enter:0", "enter:0",
"activate-commands", "activate-commands",
"entered:Runtime", "entered:Runtime",
"character-entered:1342177281",
"deactivate-commands", "deactivate-commands",
"detach-events", "detach-events",
"dispose-session", "dispose-session",

View file

@ -0,0 +1,177 @@
using System.Text.Json;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
/// <summary>
/// Campaign LA slice LA1: pins the exact JSONL status-stream contract both
/// the App and Headless hosts write into, and the launcher (a process we
/// don't own) reads — see <c>docs/plans/2026-08-14-launcher-campaign.md</c>
/// LA1 and <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
/// §6.
/// </summary>
public sealed class SessionStatusWriterTests
{
[Fact]
public void EachEventWritesTheExactPinnedShapeInOrder()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Started("s1");
writer.Connected("s1");
writer.CharacterList(
"s1",
new LiveSessionRosterReport(
"account",
11,
[
new LiveSessionRosterEntry(0x50000001u, "Ready", 0u),
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
]));
writer.EnteredWorld("s1", 0x50000001u, "Ready");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(6, lines.Length);
JsonElement started = Parse(lines[0]);
Assert.Equal(1, started.GetProperty("v").GetInt32());
Assert.Equal("started", started.GetProperty("e").GetString());
Assert.True(started.TryGetProperty("t", out _));
Assert.Equal("s1", started.GetProperty("sessionId").GetString());
JsonElement connected = Parse(lines[1]);
Assert.Equal("connected", connected.GetProperty("e").GetString());
Assert.Equal("s1", connected.GetProperty("sessionId").GetString());
JsonElement characterList = Parse(lines[2]);
Assert.Equal("characterList", characterList.GetProperty("e").GetString());
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
Assert.Equal(11, characterList.GetProperty("slotCount").GetInt32());
JsonElement characters = characterList.GetProperty("characters");
Assert.Equal(2, characters.GetArrayLength());
JsonElement first = characters[0];
Assert.Equal(0x50000001u, first.GetProperty("id").GetUInt32());
Assert.Equal("Ready", first.GetProperty("name").GetString());
Assert.Equal(0u, first.GetProperty("secondsGreyedOut").GetUInt32());
JsonElement enteredWorld = Parse(lines[3]);
Assert.Equal("enteredWorld", enteredWorld.GetProperty("e").GetString());
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
JsonElement disconnected = Parse(lines[4]);
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
JsonElement exited = Parse(lines[5]);
Assert.Equal("exited", exited.GetProperty("e").GetString());
Assert.Equal(0, exited.GetProperty("code").GetInt32());
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
}
[Fact]
public void NoOpWriterNeverCreatesAFile()
{
using TemporaryFile file = TemporaryFile.Reserve();
var writer = new SessionStatusWriter(null);
writer.Started("s1");
writer.Connected("s1");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
Assert.False(writer.IsEnabled);
Assert.False(File.Exists(file.Path));
}
[Fact]
public void BlankPathIsTreatedAsAbsent()
{
var writer = new SessionStatusWriter(" ");
Assert.False(writer.IsEnabled);
// Must not throw even though there is no real path behind it.
writer.Started("s1");
}
[Fact]
public void PasswordNeverAppearsInTheStatusStream()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Started("bot");
writer.Connected("bot");
writer.CharacterList(
"bot",
new LiveSessionRosterReport(
"account-name",
11,
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
writer.EnteredWorld("bot", 0x50000001u, "Ready");
writer.Disconnected("bot", "stopped");
writer.Exited("bot", 0, "disposed");
string contents = File.ReadAllText(file.Path);
Assert.DoesNotContain("hunter2", contents, StringComparison.Ordinal);
Assert.DoesNotContain("password", contents, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void FileIsOpenedShareReadSoAConcurrentTailerCanReadWhileAppending()
{
using TemporaryFile file = TemporaryFile.Create();
var writer = new SessionStatusWriter(file.Path);
writer.Started("s1");
// A concurrent reader (the launcher's tailer) must be able to open
// the file for read while the writer holds it — FileShare.Read on
// the writer side is what this test is pinning.
using FileStream tailer = new(
file.Path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var tailerReader = new StreamReader(tailer);
string? firstLine = tailerReader.ReadLine();
Assert.NotNull(firstLine);
Assert.Contains("\"started\"", firstLine);
// The writer keeps working while the tailer's handle is still open.
writer.Connected("s1");
string? secondLine = tailerReader.ReadLine();
Assert.NotNull(secondLine);
Assert.Contains("\"connected\"", secondLine);
}
private static JsonElement Parse(string line) =>
JsonDocument.Parse(line).RootElement;
private sealed class TemporaryFile : IDisposable
{
private TemporaryFile(string path) => Path = path;
internal string Path { get; }
internal static TemporaryFile Create()
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-status-{Guid.NewGuid():N}.jsonl");
return new TemporaryFile(path);
}
/// <summary>A path that is never actually created — used by the
/// no-op test to assert the writer truly never touches disk.</summary>
internal static TemporaryFile Reserve() => Create();
public void Dispose()
{
if (File.Exists(Path))
File.Delete(Path);
}
}
}

View file

@ -72,7 +72,9 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
host, host,
port, port,
connectingUser), connectingUser),
_operations.RecordConnected), _operations.RecordConnected,
_operations.RecordRoster,
_operations.RecordCharacterEntered),
new LiveSessionConnectOptions( new LiveSessionConnectOptions(
true, true,
"127.0.0.1", "127.0.0.1",
@ -693,6 +695,13 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
Trace.Add($"connecting:{host}:{port}:{user}"); Trace.Add($"connecting:{host}:{port}:{user}");
public void RecordConnected() => Trace.Add("connected"); public void RecordConnected() => Trace.Add("connected");
public void RecordRoster(LiveSessionRosterReport roster) =>
Trace.Add($"roster:{roster.AccountName}");
public void RecordCharacterEntered(
LiveSessionCharacterSelection selection) =>
Trace.Add($"character-entered:{selection.CharacterId}");
} }
private sealed class FixtureTransport : IWorldSessionTransport private sealed class FixtureTransport : IWorldSessionTransport

View file

@ -0,0 +1,20 @@
{
"version": 1,
"sessions": [
{
"id": "shared-fixture",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "sharedaccount",
"character": { "name": "SharedToon" },
"policy": { "id": "idle" },
"credential": {
"provider": "environment",
"reference": "SHARED_FIXTURE_PASSWORD"
},
"plugins": ["ExamplePlugin", "AnotherPlugin"],
"loginCommands": ["/tell someone, hi", "/vt start"],
"loginCommandDelayMs": 750,
"statusFile": "shared-fixture-status.jsonl"
}
]
}