From db9ad53c1c22124db902f92af08272ab0fdfd5ef Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 16:04:32 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Campaign=20LA=20=E2=80=94=20pinned=20la?= =?UTF-8?q?unch-contract=20schema=20COMMITTED=20into=20plan=20LA1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plans/2026-08-14-launcher-campaign.md | 65 ++++- .../Composition/SessionPlayerComposition.cs | 13 +- .../Composition/SessionStartComposition.cs | 10 +- .../Configuration/SessionConfiguration.cs | 142 +++++++++++ .../SessionConfigurationException.cs | 18 ++ .../SessionConfigurationLoader.cs | 158 ++++++++++++ .../Credentials/AppCredentialResolver.cs | 155 ++++++++++++ .../Credentials/AppCredentialSecret.cs | 65 +++++ .../Net/LiveSessionRuntimeFactory.cs | 20 +- .../Platform/GraphicalHostPlatformServices.cs | 16 ++ src/AcDream.App/Program.cs | 127 +++++++++- src/AcDream.App/Rendering/GameWindow.cs | 37 ++- src/AcDream.App/RuntimeOptions.cs | 97 +++++++- .../Configuration/HeadlessConfiguration.cs | 32 +++ .../HeadlessConfigurationLoader.cs | 36 +++ .../Hosting/HeadlessSessionHost.cs | 59 ++++- .../Session/LiveSessionController.cs | 60 +++++ .../Session/LiveSessionHost.cs | 19 +- .../Session/LiveSessionLifecycleHost.cs | 5 + .../Session/SessionStatusWriter.cs | 162 +++++++++++++ .../RuntimeOptionsSessionConfigTests.cs | 148 ++++++++++++ .../SessionConfigurationSharedFixtureTests.cs | 225 ++++++++++++++++++ .../Credentials/AppCredentialResolverTests.cs | 167 +++++++++++++ .../LiveSessionShutdownIntegrationTests.cs | 1 + .../Runtime/CurrentGameRuntimeAdapterTests.cs | 4 +- ...dlessSessionEventRouteRetryPendingTests.cs | 4 +- .../HeadlessSessionHostTests.cs | 98 +++++++- .../SessionConfigurationSharedFixtureTests.cs | 206 ++++++++++++++++ .../DirectGameRuntimeCommandAdapterTests.cs | 8 +- .../Session/LiveSessionControllerTests.cs | 44 +++- .../Session/LiveSessionHostTests.cs | 8 +- .../Session/LiveSessionLifecycleHostTests.cs | 6 +- ...imeAcceptedPositionDriveControllerTests.cs | 4 +- ...RuntimeLiveEntitySessionControllerTests.cs | 4 +- .../RuntimeLiveSessionNoWindowTests.cs | 6 +- .../Session/SessionStatusWriterTests.cs | 177 ++++++++++++++ .../Support/NoWindowGameRuntimeHost.cs | 11 +- .../session-config-shared-fixture.json | 20 ++ 38 files changed, 2397 insertions(+), 40 deletions(-) create mode 100644 src/AcDream.App/Configuration/SessionConfiguration.cs create mode 100644 src/AcDream.App/Configuration/SessionConfigurationException.cs create mode 100644 src/AcDream.App/Configuration/SessionConfigurationLoader.cs create mode 100644 src/AcDream.App/Credentials/AppCredentialResolver.cs create mode 100644 src/AcDream.App/Credentials/AppCredentialSecret.cs create mode 100644 src/AcDream.Runtime/Session/SessionStatusWriter.cs create mode 100644 tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs create mode 100644 tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs create mode 100644 tests/AcDream.App.Tests/Credentials/AppCredentialResolverTests.cs create mode 100644 tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs create mode 100644 tests/Fixtures/campaign-la/session-config-shared-fixture.json diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md index b362f863..9988b8c4 100644 --- a/docs/plans/2026-08-14-launcher-campaign.md +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -108,6 +108,69 @@ referencing only `AcDream.Platform`. ## 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: 1. **App `--session-config `:** 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 | | LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields | | 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 | — | | | | | LA5 | — | | | | | LA6 | — | | | | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 769203ef..cacc23d5 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies( CombatAttackOperationsSlot CombatAttackOperations, CombatFeedbackSlot CombatFeedback, TransferableResourceSlot PortalTunnelFallback, - Action Log) + Action Log, + /// Campaign LA slice LA1: the shared per-session status-event + /// writer, no-op when was + /// not configured. + 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 diff --git a/src/AcDream.App/Composition/SessionStartComposition.cs b/src/AcDream.App/Composition/SessionStartComposition.cs index fa352db9..35b48db9 100644 --- a/src/AcDream.App/Composition/SessionStartComposition.cs +++ b/src/AcDream.App/Composition/SessionStartComposition.cs @@ -1,9 +1,14 @@ using AcDream.Runtime; +using AcDream.Runtime.Session; namespace AcDream.App.Composition; internal sealed record SessionStartDependencies( - Action Log); + Action Log, + /// Campaign LA slice LA1: no-op when no statusFile was + /// configured. + SessionStatusWriter StatusWriter, + string SessionId); /// /// 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); diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs new file mode 100644 index 00000000..313f75ea --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -0,0 +1,142 @@ +using System.Text.Json.Serialization; + +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1: the graphical host's reader for the pinned +/// session-config document shape shared with +/// AcDream.Headless.Configuration.HeadlessConfiguration — see +/// docs/plans/2026-08-14-launcher-campaign.md LA1 and +/// docs/superpowers/specs/2026-08-14-launcher-campaign-design.md §6. +/// +/// +/// This is a DELIBERATELY independent DTO set, not a shared type reused from +/// AcDream.Headless — Headless's config types are internal, tied to +/// its own OP7 characterOptions 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 +/// (SessionConfigurationSharedFixtureTests / +/// HeadlessConfigurationSharedFixtureTests). +/// +/// +/// +/// Differences from the Headless reader, all intentional per the pinned +/// contract: is OPTIONAL here +/// (absent = today's first-available fallback; the character-select screen +/// is LA7, not this slice); is parsed +/// but never consulted (App has no bot-policy concept); exactly ONE session +/// is required, not "one or more". +/// +/// +internal sealed class SessionConfiguration +{ + [JsonRequired] + public int Version { get; init; } + + public SessionProcessSettings? Process { get; init; } + + [JsonRequired] + public List 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; + + /// 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". + public SessionCharacterSelectorDescriptor? Character { get; init; } + + /// Accepted so the SAME document also satisfies the Headless + /// loader's JsonRequired policy field — parsed and ignored here; + /// App has no bot-policy concept. + public SessionPolicyDescriptor? Policy { get; init; } + + [JsonRequired] + public SessionCredentialDescriptor Credential { get; init; } = new(); + + /// Accepted-but-ignored by App; Headless's own loader owns the + /// allow-list semantics for this field (OP7 D8). + public Dictionary? CharacterOptions { get; init; } + + /// LA1: plugin ids to load. Absent = load all (LA5 consumes + /// this; parsed and carried here now per the pinned launch contract). + public List? Plugins { get; init; } + + /// LA1: ordered chat-typed strings run after entering world + /// (LA6 consumes this; parsed and carried here now). + public List? LoginCommands { get; init; } + + /// LA1: inter-command delay for , + /// milliseconds. Matches the pinned contract default of 500 ms. + public int LoginCommandDelayMs { get; init; } = 500; + + /// LA1: absolute path for the status-event JSONL stream. + /// Absent = no writer constructed. + 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; } +} + +/// Loose by design: App never inspects the policy's shape beyond +/// "does this document parse" — Id/Role stay untyped strings so +/// this DTO never has to track Headless's own policy-id/role vocabulary. +internal sealed class SessionPolicyDescriptor +{ + public string? Id { get; init; } + public string? Role { get; init; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum SessionCredentialProviderKind +{ + Environment, + StandardInput, + File, +} + +internal sealed class SessionCredentialDescriptor +{ + [JsonRequired] + public SessionCredentialProviderKind Provider { get; init; } + + [JsonRequired] + public string Reference { get; init; } = string.Empty; +} diff --git a/src/AcDream.App/Configuration/SessionConfigurationException.cs b/src/AcDream.App/Configuration/SessionConfigurationException.cs new file mode 100644 index 00000000..05ca315f --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigurationException.cs @@ -0,0 +1,18 @@ +namespace AcDream.App.Configuration; + +/// Mirrors AcDream.Headless.Configuration.HeadlessConfigurationException +/// — a semantic validation failure of an already well-typed session-config +/// document (a type-SHAPE violation fails earlier, as a raw +/// during deserialization). +internal sealed class SessionConfigurationException : Exception +{ + internal SessionConfigurationException(string message) + : base(message) + { + } + + internal SessionConfigurationException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs new file mode 100644 index 00000000..e26aa806 --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs @@ -0,0 +1,158 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1: loads and validates the --session-config +/// document for the graphical host. Same strictness as +/// AcDream.Headless.Configuration.HeadlessConfigurationLoader +/// (camelCase, , camelCase +/// string enums) — see that type's own doc for why the two readers are +/// independent DTOs rather than a shared type. +/// +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), + }, + }; + + /// Loads the document and returns the exact one configured + /// the graphical host runs — the + /// document itself may only ever declare exactly one session. + 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(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."); + } + } +} diff --git a/src/AcDream.App/Credentials/AppCredentialResolver.cs b/src/AcDream.App/Credentials/AppCredentialResolver.cs new file mode 100644 index 00000000..7d52fdab --- /dev/null +++ b/src/AcDream.App/Credentials/AppCredentialResolver.cs @@ -0,0 +1,155 @@ +using AcDream.App.Configuration; +using AcDream.App.Platform; + +namespace AcDream.App.Credentials; + +/// +/// Campaign LA slice LA1: resolves a --session-config session's +/// credential reference — the App-side mirror of +/// AcDream.Headless.Credentials.HeadlessCredentialResolver (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: +/// environment (read an env var), standardInput (read one line +/// from stdin, mirroring HeadlessCredentialResolver.ResolveStandardInput), +/// and file (read a credential file relative to a base directory, +/// rejecting symlinks and, on Linux, group/other-readable permissions). +/// +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; + + /// + /// is caller-supplied, never detected in this + /// file — LinuxPlatformBoundaryTests's platform-owner guard + /// requires every OS-family check to live under Platform/; + /// callers pass GraphicalHostPlatformServices's already-detected + /// value instead of this file re-detecting it itself. + /// + 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; + } +} diff --git a/src/AcDream.App/Credentials/AppCredentialSecret.cs b/src/AcDream.App/Credentials/AppCredentialSecret.cs new file mode 100644 index 00000000..a251f0ed --- /dev/null +++ b/src/AcDream.App/Credentials/AppCredentialSecret.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; + +namespace AcDream.App.Credentials; + +/// +/// Campaign LA slice LA1: retains a resolved --session-config +/// credential in erasable memory — the App-side mirror of +/// AcDream.Headless.Credentials.HeadlessCredentialSecret (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. +/// +internal sealed class AppCredentialSecret : IDisposable +{ + private char[]? _buffer; + + internal AppCredentialSecret(string referenceId, ReadOnlySpan 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) + { + } +} diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 174dfa3d..805d0170 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -104,6 +104,8 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveSessionCommandSurface _commands; private readonly Action _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 log) + Action 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); } diff --git a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs index 42d9d373..bd95460d 100644 --- a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs +++ b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs @@ -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, } +/// +/// Campaign LA slice LA1: a [SupportedOSPlatformGuard]-annotated +/// runtime-OS check, for code OUTSIDE Platform/ that needs a +/// CA1416-recognized guard around a Linux-only API (e.g. +/// AppCredentialResolver's File.GetUnixFileMode call) without +/// re-detecting the OS itself — LinuxPlatformBoundaryTests +/// .OperatingSystemChecksRemainInsidePlatformOwners requires every such +/// check to live under this folder. +/// +internal static class RuntimePlatformGuard +{ + [SupportedOSPlatformGuard("linux")] + internal static bool IsLinuxRuntime => System.OperatingSystem.IsLinux(); +} + internal sealed record GraphicalNativeDependency( string Feature, string PublishedFileName); diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 3d46ce90..61ed733a 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -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 (or set ACDREAM_DAT_DIR)"); - return 2; -} +// Campaign LA slice LA1: --session-config 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 (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 (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 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(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; diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 74a61be4..19cd8c44 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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( diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 703a14a3..7b3266d1 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -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, + /// Campaign LA slice LA1: the raw --session-config path, + /// or when the flag was not supplied (the env-var + /// dev flow). Kept for diagnostics/logging only. + string? SessionConfigPath, + /// Campaign LA slice LA1: the configured session's id, used as + /// the sessionId field on every status-stream event. Defaults to + /// "app" at every call site when unset (env-var flow). + string? SessionId, + /// Campaign LA slice LA1: the session-config character + /// selector, or 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). + LiveSessionCharacterSelector? LiveCharacterSelector, + /// Campaign LA slice LA1: absolute path for the status-event + /// JSONL stream. = no writer constructed. + string? StatusFilePath, + /// Campaign LA slice LA1: plugin ids to load. + /// = load every discovered plugin (today's + /// behavior). Consumed by LA5; parsed and carried now. + IReadOnlyList? Plugins, + /// Campaign LA slice LA1: ordered chat-typed strings run once + /// entered-world. Consumed by LA6; parsed and carried now. + IReadOnlyList LoginCommands, + /// Campaign LA slice LA1: inter-command delay for + /// , milliseconds. + int LoginCommandDelayMs) { /// /// 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); } + /// + /// Campaign LA slice LA1: builds options for the --session-config + /// launch path. Starts from the same env-var parse as + /// (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. + /// is revealed into + /// exactly as wide as the existing env-var flow — + /// see that field's own doc. + /// + internal static RuntimeOptions FromSessionConfig( + string datDir, + Func 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?)session.LoginCommands ?? [], + LoginCommandDelayMs = session.LoginCommandDelayMs, + }; + } + + private static LiveSessionCharacterSelector? MapCharacterSelector( + SessionCharacterSelectorDescriptor? selector) => + selector is null + ? null + : new LiveSessionCharacterSelector( + selector.Index, + selector.Id, + selector.Name); + /// True iff live-mode credentials are present and valid for connecting. public bool HasLiveCredentials => LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass); diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 37e62a4d..42fe7e67 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor /// legal no-ops. /// public Dictionary? CharacterOptions { get; init; } + + /// + /// Campaign LA slice LA1: plugin ids to load from the standard plugins + /// directory (docs/plans/2026-08-14-launcher-campaign.md 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. + /// + public List? Plugins { get; init; } + + /// + /// Campaign LA slice LA1: ordered chat-typed strings run once the + /// session enters world. LA6 wires actual execution; parsed and carried + /// here now. + /// + public List? LoginCommands { get; init; } + + /// + /// Campaign LA slice LA1: inter-command delay for + /// , in milliseconds. Matches the pinned + /// launch-contract default (500 ms) when the field is absent from the + /// document. + /// + public int LoginCommandDelayMs { get; init; } = 500; + + /// + /// Campaign LA slice LA1: absolute path for this session's status-event + /// JSONL stream (docs/superpowers/specs/2026-08-14-launcher-campaign-design.md + /// §6). Absent means no + /// is constructed for this session. + /// + public string? StatusFile { get; init; } } internal sealed class HeadlessEndpointDescriptor diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 101850c8..016b79c2 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader } ValidateCharacterOptions(session); + ValidateLaunchContractFields(session); + } + + /// + /// Campaign LA slice LA1: validates the four new optional per-session + /// fields shared with the App session-config reader (see + /// docs/plans/2026-08-14-launcher-campaign.md LA1's pinned + /// contract). All four stay optional; only their SHAPE is checked here + /// — parsing/executing plugins/loginCommands is LA5/LA6. + /// + 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."); + } } /// diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 5682e156..ea3a56d4 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -113,6 +113,19 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly HeadlessCredentialSecret _credential; private readonly HeadlessDiagnosticWriter _diagnostics; /// + /// Campaign LA slice LA1: a SEPARATE per-session sink from + /// — a no-op instance when + /// was not configured. + /// See 's own doc for why this is not a + /// rework of the shared-stdout diagnostics writer. + /// + private readonly SessionStatusWriter _statusWriter; + /// Guards 's disconnected 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. + private bool _hasConnected; + /// /// Campaign OP slice OP7 (2026-08-11), D8: the parsed /// characterOptions block — empty when the config omitted it. /// Parsed once at construction; @@ -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; diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index d09f0ad9..1ecd5d00 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -50,6 +50,32 @@ public sealed record LiveSessionStartResult( LiveSessionCharacterSelection? Selection = null, Exception? Error = null); +/// +/// Campaign LA slice LA1: one roster entry as reported by +/// — decoupled from the wire type so the +/// lifecycle-host seam does not leak AcDream.Core.Net.Messages shapes +/// into every consumer. +/// +public readonly record struct LiveSessionRosterEntry( + uint Id, + string Name, + uint SecondsGreyedOut); + +/// +/// Campaign LA slice LA1: the account's active-character roster, reported to +/// right after +/// CharacterList arrives and BEFORE selection — see +/// docs/plans/2026-08-14-launcher-campaign.md LA1 item 2. Hosts forward +/// this to their status stream (characterList event) and, later +/// (LA7/LA8), to the character-select screen. Deleted characters are +/// deliberately excluded — the same candidate set +/// already uses. +/// +public sealed record LiveSessionRosterReport( + string AccountName, + int SlotCount, + IReadOnlyList Entries); + /// /// Runtime boundary for the domain and presentation sinks attached to one /// exact 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(); + /// Campaign LA slice LA1: reported once per successful + /// CharacterList receipt, right before character selection. See + /// . + 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); + /// Campaign LA slice LA1: projects the wire-shaped + /// into the decoupled + /// . Deleted characters are + /// excluded, matching 's + /// candidate set. + 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, diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 049b8286..95e8f553 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -28,7 +28,18 @@ public sealed record LiveSessionHostBindings( LiveSessionSelectionBindings Selection, LiveSessionEnteredWorldBindings EnteredWorld, Action Connecting, - Action Connected); + Action Connected, + /// Campaign LA slice LA1: reported once per successful + /// CharacterList receipt, right before character selection — see + /// . Hosts forward this to their + /// status stream's characterList event. + Action Roster, + /// Campaign LA slice LA1: reported once entered-world state is + /// applied, carrying the full selection (id + name) — unlike + /// 's narrow SetActiveCharacter(string) + /// fan-out, this exists so a status writer can emit the + /// enteredWorld event's characterId field. + Action CharacterEntered); /// /// Runtime host for the one canonical . @@ -84,6 +95,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands private readonly LiveSessionRoutingFactories _routing; private readonly LiveSessionSelectionBindings _selection; private readonly LiveSessionEnteredWorldBindings _enteredWorld; + private readonly Action _characterEntered; private readonly Action _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( diff --git a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs index b0134366..7f1a7f92 100644 --- a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs @@ -7,6 +7,7 @@ public sealed record LiveSessionLifecycleBindings( Action Reset, Action Connecting, Action Connected, + Action Roster, Action Selected, Action 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); diff --git a/src/AcDream.Runtime/Session/SessionStatusWriter.cs b/src/AcDream.Runtime/Session/SessionStatusWriter.cs new file mode 100644 index 00000000..b2260453 --- /dev/null +++ b/src/AcDream.Runtime/Session/SessionStatusWriter.cs @@ -0,0 +1,162 @@ +using System.Text.Json; + +namespace AcDream.Runtime.Session; + +/// +/// Campaign LA slice LA1: appends one JSON object per line to a per-session +/// status-event file the launcher tails +/// (docs/plans/2026-08-14-launcher-campaign.md LA1, +/// docs/superpowers/specs/2026-08-14-launcher-campaign-design.md §6). +/// +/// +/// This is a SEPARATE sink from HeadlessDiagnosticWriter — 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 ("v":1) so a future event kind +/// (pluginLoaded/pluginFailed, LA5) can be added without +/// breaking an existing reader. +/// +/// +/// +/// Every write opens the file in append mode with +/// 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 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. +/// +/// +/// +/// Never write credential material into this stream. Every +/// event method below takes only identifiers, names, and counts — there is no +/// parameter shape that could carry a password, by construction. +/// +/// +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; + } + + /// + /// 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. + /// + 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 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(); + } + } +} diff --git a/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs new file mode 100644 index 00000000..9cbe54e0 --- /dev/null +++ b/tests/AcDream.App.Tests/Configuration/RuntimeOptionsSessionConfigTests.cs @@ -0,0 +1,148 @@ +using AcDream.App; +using AcDream.App.Configuration; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.Configuration; + +/// +/// Campaign LA slice LA1: round-trip tests for +/// — the overlay that turns a +/// parsed / +/// into the same typed bundle the env-var dev flow produces. +/// +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); + } +} diff --git a/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs new file mode 100644 index 00000000..b5b44ebd --- /dev/null +++ b/tests/AcDream.App.Tests/Configuration/SessionConfigurationSharedFixtureTests.cs @@ -0,0 +1,225 @@ +using System.Runtime.CompilerServices; +using AcDream.App.Configuration; + +namespace AcDream.App.Tests.Configuration; + +/// +/// Campaign LA slice LA1: proves the App config reader accepts the EXACT +/// document the Headless reader also accepts — +/// tests/Fixtures/campaign-la/session-config-shared-fixture.json is +/// parsed by both here and +/// AcDream.Headless.Configuration.HeadlessConfigurationLoader in +/// AcDream.Headless.Tests's twin of this test. This is the +/// pinned-contract acceptance test from +/// docs/plans/2026-08-14-launcher-campaign.md 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. +/// +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( + () => 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( + () => 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( + () => 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( + () => 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); + } +} diff --git a/tests/AcDream.App.Tests/Credentials/AppCredentialResolverTests.cs b/tests/AcDream.App.Tests/Credentials/AppCredentialResolverTests.cs new file mode 100644 index 00000000..49eb9820 --- /dev/null +++ b/tests/AcDream.App.Tests/Credentials/AppCredentialResolverTests.cs @@ -0,0 +1,167 @@ +using AcDream.App.Configuration; +using AcDream.App.Credentials; + +namespace AcDream.App.Tests.Credentials; + +/// +/// Campaign LA slice LA1: is a minimal +/// port of AcDream.Headless.Credentials.HeadlessCredentialResolver +/// 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 HeadlessCredentialResolverTests's coverage. +/// +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(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(() => + 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(() => + resolver.Resolve( + "session", + new SessionCredentialDescriptor + { + Provider = SessionCredentialProviderKind.File, + Reference = Path.GetFileName(path), + })); + } + finally + { + File.Delete(path); + } + } +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs index 50852c52..a11e096d 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionShutdownIntegrationTests.cs @@ -97,6 +97,7 @@ public sealed class LiveSessionShutdownIntegrationTests RuntimeGenerationToken retiringGeneration) { } public void ReportConnecting(string host, int port, string user) { } public void ReportConnected() { } + public void ReportRoster(LiveSessionRosterReport roster) { } public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { } public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { } public void DetachSession(WorldSession session) { } diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index 237e24e4..33dafd1b 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -917,7 +917,9 @@ public sealed class CurrentGameRuntimeAdapterTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), new LiveSessionConnectOptions( true, "127.0.0.1", diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs index cd9d3f2c..a2586870 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionEventRouteRetryPendingTests.cs @@ -222,7 +222,9 @@ public sealed class HeadlessSessionEventRouteRetryPendingTests new LiveSessionEnteredWorldBindings( _ => { }, () => { }, () => { }, _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); LiveSessionStartResult startResult = live.Start(options); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index f2016f17..5c9871f5 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Net; using System.Numerics; using System.Reflection; +using System.Text.Json; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; @@ -61,6 +62,95 @@ public sealed class HeadlessSessionHostTests Assert.DoesNotContain("AcDream.App", diagnostics); } + /// + /// 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 + /// start+dispose cycle, and that the + /// roster surfaced matches + /// exactly (before selection has happened — the roster is reported for + /// BOTH candidates, not just the selected one). + /// + [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] public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() { @@ -1937,7 +2027,9 @@ public sealed class HeadlessSessionHostTests _ => { }, () => { }), (_, _, _) => { }, - () => { })); + () => { }, + _ => { }, + _ => { })); } private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations @@ -1967,7 +2059,8 @@ public sealed class HeadlessSessionHostTests HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind.Environment, string credentialReference = "BOT_PASSWORD", - Dictionary? characterOptions = null) => new() + Dictionary? characterOptions = null, + string? statusFile = null) => new() { Id = "bot", Endpoint = new HeadlessEndpointDescriptor @@ -1990,6 +2083,7 @@ public sealed class HeadlessSessionHostTests Reference = credentialReference, }, CharacterOptions = characterOptions, + StatusFile = statusFile, }; private static void HydrateGroundedPlayer(GameRuntime runtime) diff --git a/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs new file mode 100644 index 00000000..631a6c1d --- /dev/null +++ b/tests/AcDream.Headless.Tests/SessionConfigurationSharedFixtureTests.cs @@ -0,0 +1,206 @@ +using System.Runtime.CompilerServices; +using AcDream.Headless.Configuration; + +namespace AcDream.Headless.Tests; + +/// +/// Campaign LA slice LA1: proves the Headless config reader accepts the +/// EXACT document the App reader also accepts — +/// tests/Fixtures/campaign-la/session-config-shared-fixture.json is +/// parsed by both here and +/// AcDream.App.Configuration.SessionConfigurationLoader in +/// AcDream.App.Tests's twin of this test. This is the pinned-contract +/// acceptance test from docs/plans/2026-08-14-launcher-campaign.md +/// 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. +/// +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( + () => 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( + () => 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( + () => 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); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index cdb95ec3..c2f4fe6d 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -55,7 +55,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); adapter = new DirectGameRuntimeCommandAdapter(runtime, live); var trace = new RuntimeTraceRecorder(); @@ -752,7 +754,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); adapter = new DirectGameRuntimeCommandAdapter(runtime, live); _ = adapter.Session.Start(runtime.Generation); diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs index adeaa109..6a3f49bf 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs @@ -135,6 +135,7 @@ public sealed class LiveSessionControllerTests public Action? OnReset { get; set; } public Action? OnConnecting { get; set; } public Action? OnConnected { get; set; } + public Action? OnRoster { get; set; } public Action? OnSelected { get; set; } public Action? OnActivate { get; set; } public Action? OnEntered { get; set; } @@ -146,6 +147,7 @@ public sealed class LiveSessionControllerTests public bool ThrowOnBind { get; set; } public bool ThrowOnConnecting { get; set; } public bool ThrowOnConnected { get; set; } + public bool ThrowOnRoster { get; set; } public bool ThrowOnSelected { get; set; } public bool ThrowOnActivate { get; set; } public bool ThrowOnEntered { get; set; } @@ -158,6 +160,7 @@ public sealed class LiveSessionControllerTests public List CommandBuses { get; } = []; public List Selections { get; } = []; public List ResetGenerations { get; } = []; + public List Rosters { get; } = []; public LiveSessionBinding BindSession(WorldSession session) { @@ -231,6 +234,15 @@ public sealed class LiveSessionControllerTests 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) { calls.Add("selected"); @@ -290,7 +302,7 @@ public sealed class LiveSessionControllerTests Assert.Equal( [ "reset", "resolve", "create", "bind", "report-connecting", - "connect", "report-connected", "selected", "enter:1", + "connect", "report-connected", "roster", "selected", "enter:1", "activate", "entered", ], calls); @@ -302,6 +314,32 @@ public sealed class LiveSessionControllerTests Assert.True(host.CommandBuses[0].Active); } + [Fact] + public void Start_ReportsRosterFromCharacterListBeforeSelection() + { + var calls = new List(); + 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] public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession() { @@ -488,7 +526,7 @@ public sealed class LiveSessionControllerTests [ "deactivate", "detach-events", "dispose-session", "detach-session", "reset", "resolve", "create", "bind", "report-connecting", - "connect", "report-connected", "selected", "enter:1", + "connect", "report-connected", "roster", "selected", "enter:1", "activate", "entered", ], calls); @@ -742,6 +780,7 @@ public sealed class LiveSessionControllerTests [InlineData("connecting")] [InlineData("connected")] [InlineData("characters")] + [InlineData("roster")] [InlineData("selected")] [InlineData("activate")] [InlineData("entered")] @@ -755,6 +794,7 @@ public sealed class LiveSessionControllerTests case "connecting": host.ThrowOnConnecting = true; break; case "connected": host.ThrowOnConnected = true; break; case "characters": operations.ThrowOnCharacters = true; break; + case "roster": host.ThrowOnRoster = true; break; case "selected": host.ThrowOnSelected = true; break; case "activate": host.ThrowOnActivate = true; break; case "entered": host.ThrowOnEntered = true; break; diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs index 1cda8f2f..31373a74 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionHostTests.cs @@ -35,12 +35,13 @@ public sealed class LiveSessionHostTests Assert.Equal( [ "reset", "resolve", "create", "events", "attach-events", "commands", - "connecting", "connect", "connected", + "connecting", "connect", "connected", "roster:Canonical", "player:1342177282", "vitals:1342177282", "chat:1342177282", "persistent:1342177282", "vanish:1342177282", "clear-combat", "enter:1", "activate", "active:Ready", "restore-layout", "sync-toolbar", "load-settings:Ready", "arm-auto-entry", + "character-entered:1342177282", ], calls); Assert.Same(controller.CurrentSession, host.CurrentSession); @@ -236,7 +237,10 @@ public sealed class LiveSessionHostTests name => calls.Add($"load-settings:{name}"), () => calls.Add("arm-auto-entry")), 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( bool live = true, diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs index 74b36ae1..48064bc3 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs @@ -18,6 +18,7 @@ public sealed class LiveSessionLifecycleHostTests host.ResetSessionState(RuntimeGenerationToken.Initial); host.ReportConnecting("host", 9000, "user"); host.ReportConnected(); + host.ReportRoster(new LiveSessionRosterReport("account", 11, [])); var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account"); host.ApplySelectedCharacter(selection); binding.ActivateCommands(); @@ -31,8 +32,8 @@ public sealed class LiveSessionLifecycleHostTests Assert.Equal( [ "bind", "reset", "connecting:host:9000:user", - "connected", "selected:toon", "activate", "entered:toon", - "deactivate", "detach-events", "bind", + "connected", "roster:account", "selected:toon", "activate", + "entered:toon", "deactivate", "detach-events", "bind", ], calls); replacement.Dispose(); @@ -71,6 +72,7 @@ public sealed class LiveSessionLifecycleHostTests Connecting: (host, port, user) => calls.Add($"connecting:{host}:{port}:{user}"), Connected: () => calls.Add("connected"), + Roster: roster => calls.Add($"roster:{roster.AccountName}"), Selected: selection => calls.Add($"selected:{selection.CharacterName}"), Entered: selection => calls.Add($"entered:{selection.CharacterName}"))); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs index eeb6dfbf..21328b16 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs @@ -2342,7 +2342,9 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); LiveSessionStartResult startResult = live.Start(options); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs index 9f80fe45..925af86f 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveEntitySessionControllerTests.cs @@ -1049,7 +1049,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests _ => { }, () => { }), (_, _, _) => { }, - () => { }), + () => { }, + _ => { }, + _ => { }), options); LiveSessionStartResult startResult = live.Start(options); Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status); diff --git a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs index 38fb9544..2c09dab7 100644 --- a/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/RuntimeLiveSessionNoWindowTests.cs @@ -34,7 +34,9 @@ public sealed class RuntimeLiveSessionNoWindowTests _ => { }, () => { }), (_, _, _) => calls.Add("connecting"), - () => calls.Add("connected")), + () => calls.Add("connected"), + _ => calls.Add("roster"), + selection => calls.Add($"character-entered:{selection.CharacterId}")), new LiveSessionConnectOptions( true, "127.0.0.1", @@ -61,10 +63,12 @@ public sealed class RuntimeLiveSessionNoWindowTests "connect", "connected", "characters", + "roster", "player:1342177281", "enter:0", "activate-commands", "entered:Runtime", + "character-entered:1342177281", "deactivate-commands", "detach-events", "dispose-session", diff --git a/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs new file mode 100644 index 00000000..5b9e7133 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs @@ -0,0 +1,177 @@ +using System.Text.Json; +using AcDream.Runtime.Session; + +namespace AcDream.Runtime.Tests.Session; + +/// +/// 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 docs/plans/2026-08-14-launcher-campaign.md +/// LA1 and docs/superpowers/specs/2026-08-14-launcher-campaign-design.md +/// §6. +/// +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); + } + + /// A path that is never actually created — used by the + /// no-op test to assert the writer truly never touches disk. + internal static TemporaryFile Reserve() => Create(); + + public void Dispose() + { + if (File.Exists(Path)) + File.Delete(Path); + } + } +} diff --git a/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs index 0603b0b8..8074f6a4 100644 --- a/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs +++ b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs @@ -72,7 +72,9 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable host, port, connectingUser), - _operations.RecordConnected), + _operations.RecordConnected, + _operations.RecordRoster, + _operations.RecordCharacterEntered), new LiveSessionConnectOptions( true, "127.0.0.1", @@ -693,6 +695,13 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable Trace.Add($"connecting:{host}:{port}:{user}"); 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 diff --git a/tests/Fixtures/campaign-la/session-config-shared-fixture.json b/tests/Fixtures/campaign-la/session-config-shared-fixture.json new file mode 100644 index 00000000..822921a6 --- /dev/null +++ b/tests/Fixtures/campaign-la/session-config-shared-fixture.json @@ -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" + } + ] +}