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

@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies(
CombatAttackOperationsSlot CombatAttackOperations,
CombatFeedbackSlot CombatFeedback,
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;
@ -1124,7 +1128,9 @@ internal sealed class SessionPlayerCompositionPhase
acceptedPositionDrive,
remotePlacementDrive),
liveSessionCommands,
d.Log);
d.Log,
d.StatusWriter,
d.Options.SessionId ?? "app");
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
liveSession,
new LiveSessionConnectOptions(
@ -1132,7 +1138,8 @@ internal sealed class SessionPlayerCompositionPhase
d.Options.LiveHost,
d.Options.LivePort,
d.Options.LiveUser ?? string.Empty,
d.Options.LivePass ?? string.Empty));
d.Options.LivePass ?? string.Empty,
d.Options.LiveCharacterSelector));
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
// The ImGui developer-tools debug toast sink was removed at Campaign V

View file

@ -1,9 +1,14 @@
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Composition;
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>
/// Terminal startup phase. Every callback, command target, and frame root is
@ -21,6 +26,9 @@ internal sealed class SessionStartCompositionPhase
public void Start(FrameRootResult 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 =
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
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 Action<string> _log;
private readonly LiveMovementStatsApplier _movementStats;
private readonly SessionStatusWriter _statusWriter;
private readonly string _sessionId;
public LiveSessionRuntimeFactory(
LiveSessionPlayerRuntime player,
@ -112,7 +114,9 @@ internal sealed class LiveSessionRuntimeFactory
LiveSessionInteractionRuntime interaction,
LiveSessionWorldRuntime world,
LiveSessionCommandSurface commands,
Action<string> log)
Action<string> log,
SessionStatusWriter? statusWriter = null,
string sessionId = "app")
{
_player = player ?? throw new ArgumentNullException(nameof(player));
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
@ -122,6 +126,10 @@ internal sealed class LiveSessionRuntimeFactory
_world = world ?? throw new ArgumentNullException(nameof(world));
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
_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
// typed application seam; App keeps zero direct controller mutations.
_movementStats = new LiveMovementStatsApplier(
@ -176,9 +184,17 @@ internal sealed class LiveSessionRuntimeFactory
$"connecting to {host}:{port} as {user}",
chatType: 1),
Connected: () =>
{
_domain.Communication.Chat.OnSystemMessage(
"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);
}

View file

@ -1,4 +1,5 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using AcDream.App.Rendering;
using AcDream.Platform;
@ -10,6 +11,21 @@ internal enum GraphicalHostOperatingSystem
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(
string Feature,
string PublishedFileName);

View file

@ -1,4 +1,6 @@
using AcDream.App;
using AcDream.App.Configuration;
using AcDream.App.Credentials;
using AcDream.App.Plugins;
using AcDream.App.Platform;
using AcDream.App.Rendering;
@ -32,17 +34,96 @@ Log.Information(
dependency =>
$"{dependency.Feature}={dependency.PublishedFileName}")));
var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (string.IsNullOrWhiteSpace(datDir))
{
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
return 2;
}
// Campaign LA slice LA1: --session-config <path> is purely additive — the
// existing one positional dat-dir argument and every ACDREAM_* env var keep
// working exactly as before when the flag is absent. See
// docs/plans/2026-08-14-launcher-campaign.md LA1.
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
// consumer (GameWindow + collaborators) reads the typed bundle, not the
// 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)
{
@ -158,3 +239,35 @@ finally
}
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;
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 string _datDir;
private readonly WorldGameState _worldGameState;
@ -615,6 +618,7 @@ public sealed class GameWindow :
GraphicalHostPlatformServices platformServices)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_statusWriter = new SessionStatusWriter(options.StatusFilePath);
_platformServices = platformServices
?? throw new ArgumentNullException(nameof(platformServices));
_applicationPaths = _platformServices.Paths;
@ -1489,7 +1493,8 @@ public sealed class GameWindow :
_combatAttackOperations,
_combatFeedback,
_portalTunnelFallback,
Console.WriteLine),
Console.WriteLine,
_statusWriter),
this).Compose(
hostInputCamera,
contentEffectsAudio,
@ -1548,7 +1553,10 @@ public sealed class GameWindow :
livePresentation,
sessionPlayer),
frameRoots => new SessionStartCompositionPhase(
new SessionStartDependencies(Console.WriteLine))
new SessionStartDependencies(
Console.WriteLine,
_statusWriter,
_options.SessionId ?? "app"))
.Start(frameRoots));
}
@ -1636,13 +1644,30 @@ public sealed class GameWindow :
private void CompleteShutdown(bool releaseNativeWindow)
{
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());
}
GameWindowLifetimeReport report = releaseNativeWindow
? _lifetime.CompleteAndReleaseNativeWindow()
: _lifetime.TryComplete();
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;
}
Console.Error.WriteLine(
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
@ -1655,6 +1680,14 @@ public sealed class GameWindow :
if (report.Error is not null)
Console.Error.WriteLine($"[shutdown] {report.Error}");
if (releaseNativeWindow)
{
_statusWriter.Exited(
_options.SessionId ?? "app",
1,
"shutdown-incomplete");
}
}
private GameWindowShutdownRoots CaptureShutdownRoots() => new(

View file

@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using AcDream.App.Configuration;
using AcDream.App.Rendering.Residency;
using AcDream.App.Streaming;
using AcDream.Runtime.Session;
namespace AcDream.App;
@ -62,7 +65,34 @@ public sealed record RuntimeOptions(
string? VulkanDeviceOverride,
string? VulkanForcedUnsupportedFeature,
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>
/// 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 --
// keeps the interactive behaviour, so no existing invocation changes.
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>
public bool HasLiveCredentials =>
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);

View file

@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor
/// legal no-ops.
/// </summary>
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

View file

@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader
}
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>

View file

@ -113,6 +113,19 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly HeadlessCredentialSecret _credential;
private readonly HeadlessDiagnosticWriter _diagnostics;
/// <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
/// <c>characterOptions</c> block — empty when the config omitted it.
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
@ -271,6 +284,10 @@ internal sealed class HeadlessSessionHost : IDisposable
var commands = new DirectGameRuntimeCommandAdapter(
runtime,
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(
runtime.Session,
new LiveSessionHostBindings(
@ -318,14 +335,25 @@ internal sealed class HeadlessSessionHost : IDisposable
descriptor.Id,
$"connecting:{host}:{port}:{user}",
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,
"connected",
runtime.Generation.Value)));
selection.CharacterId,
selection.CharacterName)));
Runtime = runtime;
Commands = commands;
_liveSession = liveSession;
_statusWriter = statusWriter;
_localPlayerFrame =
runtime.CreateLocalPlayerFrameController(
new HeadlessLocalPlayerFrameHost(
@ -421,8 +449,13 @@ internal sealed class HeadlessSessionHost : IDisposable
_pendingConfirmation = null;
}
internal RuntimeSessionStartResult Start() =>
Commands.Session.Start(Runtime.Generation);
internal RuntimeSessionStartResult Start()
{
// 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() =>
Commands.Session.Reconnect(Runtime.Generation);
@ -470,6 +503,15 @@ internal sealed class HeadlessSessionHost : IDisposable
// (possibly disposed) WorldSession in the window between this Stop
// and the next CreateEventRoute call.
_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;
}
@ -592,6 +634,13 @@ internal sealed class HeadlessSessionHost : IDisposable
_descriptor.Id,
"disposed",
_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++;
_disposed = true;
break;

View file

@ -50,6 +50,32 @@ public sealed record LiveSessionStartResult(
LiveSessionCharacterSelection? Selection = 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>
/// Runtime boundary for the domain and presentation sinks attached to one
/// exact <see cref="WorldSession"/> generation. The controller owns the
@ -61,6 +87,10 @@ public interface ILiveSessionLifecycleHost
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
void ReportConnecting(string host, int port, string user);
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 ApplyEnteredWorld(LiveSessionCharacterSelection selection);
void DetachSession(WorldSession session);
@ -610,6 +640,13 @@ public sealed class LiveSessionController
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
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
|| !TrySelectCharacter(
characters,
@ -838,6 +875,29 @@ public sealed class LiveSessionController
private LiveSessionStartResult ConnectedResult()
=> 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(
CharacterList.Parsed characters,
LiveSessionCharacterSelector? selector,

View file

@ -28,7 +28,18 @@ public sealed record LiveSessionHostBindings(
LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld,
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>
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
@ -84,6 +95,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
private readonly LiveSessionRoutingFactories _routing;
private readonly LiveSessionSelectionBindings _selection;
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
private readonly Action<RuntimeGenerationToken> _reset;
private readonly LiveSessionLifecycleHost _lifecycle;
private PendingRouteRollback? _pendingRouteRollback;
@ -100,11 +112,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
_enteredWorld = bindings.EnteredWorld
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
_characterEntered = bindings.CharacterEntered
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
ArgumentNullException.ThrowIfNull(bindings.Reset);
ArgumentNullException.ThrowIfNull(bindings.Connecting);
ArgumentNullException.ThrowIfNull(bindings.Connected);
ArgumentNullException.ThrowIfNull(bindings.Roster);
Validate(_selection, _enteredWorld);
_reset = bindings.Reset;
@ -113,6 +128,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
Reset: ResetSessionState,
Connecting: bindings.Connecting,
Connected: bindings.Connected,
Roster: bindings.Roster,
Selected: ApplySelection,
Entered: ApplyEnteredWorld));
}
@ -217,6 +233,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
_enteredWorld.SyncToolbar();
_enteredWorld.LoadCharacterSettings(name);
_enteredWorld.ArmPlayerModeAutoEntry();
_characterEntered(selection);
}
private void RethrowWithRetryableRollback(

View file

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