From 37d74e44029fd1b7159492077a7ae525511b4dcd Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 15:49:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(launcher):=20Campaign=20LA=20LA3=20?= =?UTF-8?q?=E2=80=94=20AcDream.Launcher.Core=20profile=20store,=20composer?= =?UTF-8?q?,=20supervisor,=20status=20tailer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AcDream.Launcher.Core (BCL-only, ProjectReference: AcDream.Platform ONLY) plus tests/AcDream.Launcher.Core.Tests, both registered in AcDream.slnx. This is the file-contract orchestrator core the Avalonia launcher (LA4) will bind to — the game solution (Core/Runtime/App/ Headless) stays entirely out of this dependency graph, so the launcher can never accidentally grow a game-protocol coupling. - Profiles/: LauncherProfileStore owns launcher-profiles.json (spec §5 schema: version 1, servers[]/accounts[]/characters[]), strict camelCase System.Text.Json (UnmappedMemberHandling.Disallow), typed CRUD (add/edit/remove server; add/edit/remove account; edit character settings), and MergeRoster (fold a reported roster into an account's characters[] while preserving user-owned launchMode/plugins/ loginCommands, adding new rows with default guiSelect, and retaining rows absent from the roster — they may be pending-delete). 0600 on Linux via File.SetUnixFileMode after save. - Launching/: SessionConfigComposer builds the pinned session-config contract (Headless K1 shape + plugins/loginCommands/ loginCommandDelayMs/statusFile) from a profile character + install record — character selector omitted entirely for guiSelect, policy {id:"idle"} only for headless, credential always standardInput/ session. Passwords never enter this document (proven by a dedicated test). LauncherProcessSupervisor spawns a host, feeds the password to stdin then closes it, and exposes Starting/Running/Exited lifecycle; Stop calls CloseMainWindow falling back to Kill after a timeout, both reachable through an injectable ILauncherChildProcess/factory seam so the state machine is unit-testable without real OS process timing. - Status/: StatusEventParser decodes the v1 status.jsonl vocabulary (started/connected/characterList/enteredWorld/pluginLoaded/ pluginFailed/disconnected/exited); an unrecognized "e" or a malformed line degrades to a typed Unknown event rather than throwing. StatusFileTailer incrementally reads new lines, tolerating a not-yet-existing file and a partial trailing line (only advances its read position past confirmed '\n' boundaries; a truncated tail is simply re-read next poll, never parsed early). - Integrity/: streaming SHA-256 + hex verify for later pak/download checks (LA9/LA10). Tests: 71 passed (profile CRUD + roster-merge matrix + strict-schema rejection; composer golden-shape tests for gui/guiSelect/headless + password-absence; supervisor tests against both an injected fake child (state-machine determinism) and a real spawned `dotnet --version` child (genuine cross-platform stdin/exit-code proof); tailer tests incl. partial-line and not-yet-existing-file; SHA-256 tests). Verified green on Windows (Release) and native WSL/Linux (Release) — the Linux 0600 test executes its real assertion body under WSL rather than early-returning. Co-Authored-By: Claude Fable 5 --- AcDream.slnx | 2 + .../AcDream.Launcher.Core.csproj | 12 + .../Integrity/FileIntegrity.cs | 61 +++ .../Launching/ILauncherChildProcess.cs | 120 ++++++ .../Launching/LauncherInstallRecord.cs | 12 + .../Launching/LauncherProcessSpec.cs | 15 + .../Launching/LauncherProcessSupervisor.cs | 146 +++++++ .../Launching/LauncherSessionState.cs | 21 + .../Launching/SessionConfigComposer.cs | 167 ++++++++ .../Launching/SessionConfigDocument.cs | 134 ++++++ .../Profiles/AccountProfile.cs | 22 + .../Profiles/CharacterIdFormat.cs | 32 ++ .../Profiles/CharacterProfile.cs | 31 ++ .../Profiles/CharacterRosterEntry.cs | 19 + .../Profiles/LaunchMode.cs | 35 ++ .../Profiles/LauncherProfileDocument.cs | 16 + .../Profiles/LauncherProfileException.cs | 17 + .../Profiles/LauncherProfileStore.cs | 395 ++++++++++++++++++ .../Profiles/ServerProfile.cs | 19 + .../Status/StatusEvent.cs | 81 ++++ .../Status/StatusEventParser.cs | 247 +++++++++++ .../Status/StatusFileTailer.cs | 118 ++++++ .../AcDream.Launcher.Core.Tests.csproj | 23 + .../Integrity/FileIntegrityTests.cs | 98 +++++ .../LauncherProcessSupervisorTests.cs | 246 +++++++++++ .../Launching/SessionConfigComposerTests.cs | 269 ++++++++++++ .../Profiles/CharacterIdFormatTests.cs | 43 ++ .../Profiles/LauncherProfileStoreTests.cs | 287 +++++++++++++ .../Profiles/RosterMergeTests.cs | 146 +++++++ .../Status/StatusEventParserTests.cs | 125 ++++++ .../Status/StatusFileTailerTests.cs | 172 ++++++++ 31 files changed, 3131 insertions(+) create mode 100644 src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj create mode 100644 src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs create mode 100644 src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs create mode 100644 src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs create mode 100644 src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs create mode 100644 src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs create mode 100644 src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs create mode 100644 src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs create mode 100644 src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/AccountProfile.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/LaunchMode.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs create mode 100644 src/AcDream.Launcher.Core/Profiles/ServerProfile.cs create mode 100644 src/AcDream.Launcher.Core/Status/StatusEvent.cs create mode 100644 src/AcDream.Launcher.Core/Status/StatusEventParser.cs create mode 100644 src/AcDream.Launcher.Core/Status/StatusFileTailer.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj create mode 100644 tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs diff --git a/AcDream.slnx b/AcDream.slnx index fa90475d..20f17f98 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -7,6 +7,7 @@ + @@ -25,6 +26,7 @@ + diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj new file mode 100644 index 00000000..89be4968 --- /dev/null +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + latest + true + + + + + diff --git a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs new file mode 100644 index 00000000..327d406a --- /dev/null +++ b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs @@ -0,0 +1,61 @@ +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Integrity; + +/// +/// Streaming SHA-256 for pak/download verification, consumed by the +/// install engine (LA9) and the updater (LA10). Kept minimal in this +/// slice: hash a file and compare its hex digest. +/// +public static class FileIntegrity +{ + /// + /// Computes the lower-case hex SHA-256 digest of a file, streaming it + /// from disk rather than loading it fully into memory (relevant for + /// the ~30 GB pak file LA9 verifies). + /// + public static string ComputeSha256Hex(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + byte[] hash = SHA256.HashData(stream); + return Convert.ToHexStringLower(hash); + } + + public static async Task ComputeSha256HexAsync( + string filePath, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + useAsync: true); + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken) + .ConfigureAwait(false); + return Convert.ToHexStringLower(hash); + } + + /// Case-insensitive hex comparison — callers may receive an + /// expected digest in either case from a manifest or a hand-typed + /// fixture. + public static bool Matches(string actualHex, string expectedHex) + { + ArgumentNullException.ThrowIfNull(actualHex); + ArgumentNullException.ThrowIfNull(expectedHex); + return string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase); + } + + /// Computes and compares in one call. + public static bool Verify(string filePath, string expectedHex) => + Matches(ComputeSha256Hex(filePath), expectedHex); +} diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs new file mode 100644 index 00000000..7ee3ed95 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs @@ -0,0 +1,120 @@ +using System.Diagnostics; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Thin seam over so +/// 's lifecycle and Stop +/// (CloseMainWindow, falling back to Kill after a timeout) state machine +/// can be unit-tested against an in-memory fake without spawning a real +/// OS process or depending on real window-message timing — both +/// "injectable for tests" per Campaign LA spec §3. +/// +public interface ILauncherChildProcess : IDisposable +{ + bool HasExited { get; } + + int ExitCode { get; } + + /// The child's redirected stdin. The supervisor writes the + /// account password here (followed by a newline) and then closes it — + /// never anywhere else. + TextWriter StandardInput { get; } + + /// Fires exactly once, when the child process terminates + /// (mirrors with + /// EnableRaisingEvents on). + event EventHandler? Exited; + + void Start(); + + /// Mirrors — requests + /// a graceful close via WM_CLOSE. Returns false for a console/no- + /// window process (never throws), matching the real API. + bool CloseMainWindow(); + + /// Mirrors with + /// entireProcessTree: true. + void Kill(); + + bool WaitForExit(TimeSpan timeout); +} + +/// Creates instances from a +/// . +public interface ILauncherChildProcessFactory +{ + ILauncherChildProcess Create(LauncherProcessSpec spec); +} + +/// Real-process implementation used in production. +public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory +{ + public ILauncherChildProcess Create(LauncherProcessSpec spec) => + new SystemChildProcess(spec); +} + +internal sealed class SystemChildProcess : ILauncherChildProcess +{ + private readonly Process _process; + private bool _raisingEnabled; + + internal SystemChildProcess(LauncherProcessSpec spec) + { + ArgumentNullException.ThrowIfNull(spec); + + var startInfo = new ProcessStartInfo + { + FileName = spec.ExecutablePath, + RedirectStandardInput = true, + UseShellExecute = false, + }; + + foreach (string argument in spec.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (!string.IsNullOrEmpty(spec.WorkingDirectory)) + { + startInfo.WorkingDirectory = spec.WorkingDirectory; + } + + _process = new Process { StartInfo = startInfo }; + } + + public bool HasExited => _process.HasExited; + + public int ExitCode => _process.ExitCode; + + public TextWriter StandardInput => _process.StandardInput; + + public event EventHandler? Exited; + + public void Start() + { + _process.EnableRaisingEvents = true; + _process.Exited += OnExited; + _raisingEnabled = true; + _process.Start(); + } + + public bool CloseMainWindow() => _process.CloseMainWindow(); + + public void Kill() => _process.Kill(entireProcessTree: true); + + public bool WaitForExit(TimeSpan timeout) => _process.WaitForExit(timeout); + + public void Dispose() + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + } + + private void OnExited(object? sender, EventArgs e) => + Exited?.Invoke(this, EventArgs.Empty); +} diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs new file mode 100644 index 00000000..5d8e0c07 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs @@ -0,0 +1,12 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// The DAT/pak locations a completed install (LA9) records and every +/// session-config composition consumes for +/// . SHA-256/version bookkeeping +/// for the install record itself is LA9/LA10 scope; this slice only +/// needs the two paths a session config requires. +/// +public sealed record LauncherInstallRecord( + string DatDirectory, + string PreparedAssetPath); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs new file mode 100644 index 00000000..11b59dee --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs @@ -0,0 +1,15 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// What to spawn: the host executable path + argument list (both +/// injectable per Campaign LA spec §3, e.g. AcDream.Headless --config +/// <path> or AcDream.App --session-config <path>). +/// Deliberately carries no credential field — the password is a separate +/// transient parameter to +/// that flows only to the child's stdin, never into this spec, an +/// argument list, or a process environment. +/// +public sealed record LauncherProcessSpec( + string ExecutablePath, + IReadOnlyList Arguments, + string? WorkingDirectory = null); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs new file mode 100644 index 00000000..c6749fdf --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -0,0 +1,146 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// Spawns a host process (App/Headless), feeds the account password to +/// its stdin then closes it, and supervises its lifetime (Campaign LA +/// spec §3/§6). One supervisor instance owns exactly one child process +/// for its lifetime — start a new supervisor per launched session. +/// +public sealed class LauncherProcessSupervisor : IDisposable +{ + private readonly ILauncherChildProcessFactory _factory; + private readonly object _gate = new(); + private ILauncherChildProcess? _process; + + public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) + { + _factory = factory ?? new SystemChildProcessFactory(); + } + + public LauncherSessionState State { get; private set; } = LauncherSessionState.Starting; + + /// Set once reaches + /// ; null before then. + /// + public int? ExitCode { get; private set; } + + /// Fires on every + /// transition, in order. + public event EventHandler? StateChanged; + + /// + /// Spawns the child described by , writes + /// (if any) followed by a newline to its + /// stdin, then closes stdin. The password is never written anywhere + /// else — not into , not into an environment + /// variable, not logged. + /// + public void Start(LauncherProcessSpec spec, string? password) + { + ArgumentNullException.ThrowIfNull(spec); + + ILauncherChildProcess process; + lock (_gate) + { + if (_process is not null) + { + throw new InvalidOperationException( + "This supervisor already owns a process; start a new " + + "supervisor per launched session."); + } + + process = _factory.Create(spec); + process.Exited += OnProcessExited; + _process = process; + } + + SetState(LauncherSessionState.Starting); + + try + { + process.Start(); + + if (password is not null) + { + process.StandardInput.Write(password); + process.StandardInput.Write('\n'); + process.StandardInput.Flush(); + } + + process.StandardInput.Close(); + } + catch + { + lock (_gate) + { + process.Exited -= OnProcessExited; + _process = null; + } + + throw; + } + + SetState(LauncherSessionState.Running); + } + + /// + /// Requests a graceful stop (CloseMainWindow), falling back to Kill + /// if the process has not exited within . + /// A no-op if was never called or the process has + /// already exited. + /// + public void Stop(TimeSpan timeout) + { + ILauncherChildProcess? process; + lock (_gate) + { + process = _process; + } + + if (process is null || process.HasExited) + { + return; + } + + process.CloseMainWindow(); + if (!process.WaitForExit(timeout) && !process.HasExited) + { + process.Kill(); + } + } + + private void OnProcessExited(object? sender, EventArgs e) + { + ILauncherChildProcess? process; + lock (_gate) + { + process = _process; + } + + ExitCode = process is { HasExited: true } ? process.ExitCode : null; + SetState(LauncherSessionState.Exited); + } + + private void SetState(LauncherSessionState state) + { + lock (_gate) + { + State = state; + } + + StateChanged?.Invoke(this, state); + } + + public void Dispose() + { + lock (_gate) + { + if (_process is not null) + { + _process.Exited -= OnProcessExited; + _process.Dispose(); + _process = null; + } + } + } +} diff --git a/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs b/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs new file mode 100644 index 00000000..2f04fae0 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs @@ -0,0 +1,21 @@ +namespace AcDream.Launcher.Core.Launching; + +/// Lifecycle of a launched host process, per Campaign LA spec §3 +/// ("supervise lifetime ... surface typed session state"). +public enum LauncherSessionState +{ + /// The child process has been created and the credential + /// handed off, but has not yet reached . + Starting, + + /// The child process is spawned and its stdin has been + /// closed. Says nothing about game-level connection state — that + /// comes from the status stream (see + /// AcDream.Launcher.Core.Status). + Running, + + /// The child process has exited. See + /// for the exit + /// code. + Exited, +} diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs new file mode 100644 index 00000000..9bba0226 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Profiles; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Launching; + +/// The composed session-config document plus the two per-launch +/// paths derived from the session id, per Campaign LA spec §6. +public sealed record ComposedSessionConfig( + string SessionId, + string ConfigFilePath, + string StatusFilePath, + SessionConfigDocument Document); + +/// +/// Builds the per-launch from a +/// profile character + install record (Campaign LA spec §6). Passwords +/// NEVER appear in the composed document — the credential is always the +/// standardInput provider; the launcher feeds the password to the +/// child process's stdin separately (). +/// +public static class SessionConfigComposer +{ + internal static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + /// + /// Builds the document and the paths it would be written to under + /// <CacheDirectory>/launcher/sessions/<sessionId>/, + /// without touching disk. is caller- + /// supplied so composition stays a pure function of its inputs + /// (golden-file tests pass a fixed id). + /// + public static ComposedSessionConfig Compose( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(account); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(install); + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + string sessionDirectory = Path.Combine( + paths.CacheDirectory, + "launcher", + "sessions", + sessionId); + string configFilePath = Path.Combine(sessionDirectory, "session.json"); + string statusFilePath = Path.Combine(sessionDirectory, "status.jsonl"); + + SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect + ? null + : BuildSelector(character); + + SessionPolicyDescriptor? policy = character.LaunchMode == LaunchMode.Headless + ? new SessionPolicyDescriptor() + : null; + + var descriptor = new SessionDescriptor + { + Id = sessionId, + Endpoint = new SessionEndpointDescriptor + { + Host = server.Host, + Port = server.Port, + }, + Account = account.Account, + Character = selector, + Policy = policy, + Credential = new SessionCredentialDescriptor(), + Plugins = character.Plugins.Count > 0 ? [.. character.Plugins] : null, + LoginCommands = character.LoginCommands.Count > 0 + ? [.. character.LoginCommands] + : null, + LoginCommandDelayMs = loginCommandDelayMs, + StatusFile = statusFilePath, + }; + + var document = new SessionConfigDocument + { + Process = new SessionProcessSettings + { + Paths = new SessionPathOverrides(), + Content = new SessionContentDescriptor + { + DatDirectory = install.DatDirectory, + PreparedAssetPath = install.PreparedAssetPath, + }, + }, + Sessions = [descriptor], + }; + + return new ComposedSessionConfig( + sessionId, + configFilePath, + statusFilePath, + document); + } + + /// Composes and writes session.json to + /// , creating the + /// per-session directory. The status file itself is created by the + /// launched host, not the launcher. + public static ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) + { + ComposedSessionConfig composed = Compose( + server, + account, + character, + install, + paths, + sessionId, + loginCommandDelayMs); + + string? directory = Path.GetDirectoryName(composed.ConfigFilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using FileStream stream = File.Create(composed.ConfigFilePath); + JsonSerializer.Serialize(stream, composed.Document, SerializerOptions); + + return composed; + } + + /// Serializes the composed document exactly as + /// would write it — used by golden-file + /// tests that assert on the JSON text without touching disk. + public static string Serialize(SessionConfigDocument document) => + JsonSerializer.Serialize(document, SerializerOptions); + + private static SessionCharacterSelector BuildSelector(CharacterProfile character) + { + if (CharacterIdFormat.TryParse(character.Id, out uint id)) + { + return new SessionCharacterSelector { Id = id }; + } + + if (!string.IsNullOrWhiteSpace(character.Name)) + { + return new SessionCharacterSelector { Name = character.Name }; + } + + throw new InvalidOperationException( + "Character has neither a usable id nor a name to select by."); + } +} diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs new file mode 100644 index 00000000..fca7c970 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -0,0 +1,134 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// The per-launch session-config document written to +/// <CacheDirectory>/launcher/sessions/<sessionId>/session.json +/// and consumed by AcDream.Headless --config / (LA1) +/// AcDream.App --session-config. +/// +/// +/// PINNED CONTRACT (Campaign LA plan §LA3): this is the Slice K1 +/// HeadlessConfiguration version-1 shape extended with optional +/// launcher fields (plugins, loginCommands, +/// loginCommandDelayMs, statusFile). Launcher.Core defines +/// its own DTOs rather than referencing AcDream.Headless — the +/// project reference set for this assembly is AcDream.Platform +/// ONLY (no game-solution dependency; see LA3 acceptance). +/// Serialized camelCase via , with +/// null optional members omitted from the written JSON. +/// +/// +public sealed class SessionConfigDocument +{ + public int Version { get; init; } = 1; + + public SessionProcessSettings Process { get; init; } = new(); + + public List Sessions { get; init; } = []; +} + +public sealed class SessionProcessSettings +{ + public SessionPathOverrides Paths { get; init; } = new(); + + public SessionContentDescriptor Content { get; init; } = new(); +} + +/// All three members are optional overrides; a host resolves +/// its own default ApplicationPathSet when a member is +/// omitted. +public sealed class SessionPathOverrides +{ + public string? ConfigDirectory { get; init; } + + public string? DataDirectory { get; init; } + + public string? CacheDirectory { get; init; } +} + +// NOTE: these DTOs are write-only (Launcher.Core composes and serializes +// them; it never deserializes a session-config document back). Members +// that the pinned contract calls "always present" therefore use plain +// non-nullable defaults rather than C#'s `required` modifier — a +// `required` member cannot be given a `= new()` default on a containing +// type without a [SetsRequiredMembers] constructor, and correctness here +// is enforced by SessionConfigComposer's tests, not the compiler. + +public sealed class SessionContentDescriptor +{ + public string DatDirectory { get; init; } = string.Empty; + + public string PreparedAssetPath { get; init; } = string.Empty; +} + +public sealed class SessionDescriptor +{ + public string Id { get; init; } = string.Empty; + + public SessionEndpointDescriptor Endpoint { get; init; } = new(); + + public string Account { get; init; } = string.Empty; + + /// Exactly one of index/id/name when present. OMITTED + /// entirely for a guiSelect launch (retail character-select + /// screen instead of auto-enter). + public SessionCharacterSelector? Character { get; init; } + + /// Present only for a headless launch (the idle + /// bot policy). Omitted for gui/guiSelect. + public SessionPolicyDescriptor? Policy { get; init; } + + public SessionCredentialDescriptor Credential { get; init; } = new(); + + /// Omitted (never an empty array) when the character has no + /// configured plugin set. + public List? Plugins { get; init; } + + /// Omitted (never an empty array) when the character has no + /// configured login commands. + public List? LoginCommands { get; init; } + + /// Overrides the host's default 500 ms inter-command + /// delay when set; omitted otherwise. + public int? LoginCommandDelayMs { get; init; } + + public string StatusFile { get; init; } = string.Empty; +} + +public sealed class SessionEndpointDescriptor +{ + public string Host { get; init; } = string.Empty; + + public int Port { get; init; } +} + +/// Exactly one of // +/// is set by . +/// +public sealed class SessionCharacterSelector +{ + public int? Index { get; init; } + + public uint? Id { get; init; } + + public string? Name { get; init; } +} + +/// Campaign LA composes exactly the idle bot policy (LA2) +/// for headless launches — the launcher never asks for any other +/// policy id. +public sealed class SessionPolicyDescriptor +{ + public string Id { get; init; } = "idle"; +} + +/// Always the standardInput provider — the launcher pipes +/// the account password to the child's stdin and never places it in the +/// session-config document, process arguments, or environment (see +/// ). +public sealed class SessionCredentialDescriptor +{ + public string Provider { get; init; } = "standardInput"; + + public string Reference { get; init; } = "session"; +} diff --git a/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs b/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs new file mode 100644 index 00000000..03b3693c --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One account under a server, per Campaign LA spec §5. The password is +/// plaintext by explicit user decision +/// (claude-memory/project_launcher_direction.md) — never written +/// anywhere except this file, never logged, never placed in a session +/// config or process argument/environment (see +/// ). +/// +public sealed class AccountProfile +{ + [JsonRequired] + public string Account { get; set; } = string.Empty; + + [JsonRequired] + public string Password { get; set; } = string.Empty; + + public List Characters { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs new file mode 100644 index 00000000..55570cd5 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs @@ -0,0 +1,32 @@ +using System.Globalization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Converts between the wire uint character GUID and the +/// launcher-profile hex-string representation ("0x5000000A", +/// matching the convention used throughout the project, e.g. the +/// +Acdream test character's 0x5000000A in CLAUDE.md). +/// +public static class CharacterIdFormat +{ + public static string ToHexString(uint id) => + "0x" + id.ToString("X8", CultureInfo.InvariantCulture); + + public static bool TryParse(string? text, out uint id) + { + id = 0; + if (string.IsNullOrWhiteSpace(text)) + return false; + + ReadOnlySpan span = text.AsSpan().Trim(); + if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + span = span[2..]; + + return uint.TryParse( + span, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out id); + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs b/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs new file mode 100644 index 00000000..18827873 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One character row under an account, per Campaign LA spec §5. The +/// / pair is the launcher-maintained +/// cache (fed by status-stream characterList events and roster +/// probes via ); +/// // +/// are user-owned settings that a roster merge must never clobber. +/// +public sealed class CharacterProfile +{ + [JsonRequired] + public string Name { get; set; } = string.Empty; + + /// + /// Hex-formatted character GUID (e.g. "0x5000000A"), matching + /// the convention used elsewhere in the project. Null only for a + /// hand-authored fixture/profile entry that has never been through a + /// roster merge. + /// + public string? Id { get; set; } + + public LaunchMode LaunchMode { get; set; } = LaunchMode.GuiSelect; + + public List Plugins { get; set; } = []; + + public List LoginCommands { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs b/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs new file mode 100644 index 00000000..91b3ea6b --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs @@ -0,0 +1,19 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One roster row as reported by a host's characterList status +/// event or an on-demand probe launch (Campaign LA spec §3/§6). Mirrors +/// the wire shape of AcDream.Core.Net.Messages.CharacterList.Character +/// (uint Id, string Name, uint SecondsGreyedOut) — Launcher.Core +/// does not reference Core.Net, so this is an independent, intentionally +/// identical shape fed by the status-stream parser +/// (). +/// is carried for completeness but is +/// NEVER persisted into — ACE reports a +/// constant 1 during the pending-delete grace window (a boolean, not a +/// countdown), and the profile schema (§5) has no field for it. +/// +public readonly record struct CharacterRosterEntry( + uint Id, + string Name, + uint SecondsGreyedOut); diff --git a/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs b/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs new file mode 100644 index 00000000..c3c02633 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs @@ -0,0 +1,35 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Per-character launch behaviour (Campaign LA spec §5). Stored on each +/// and read by +/// to +/// decide the shape of the composed session-config document. +/// +/// +/// Serialized as camelCase text ("gui"/"guiSelect"/ +/// "headless") via the explicit +/// new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, ...) +/// registered in 's serializer options +/// — deliberately NOT a per-type [JsonConverter] attribute, which +/// uses exact member-name casing ("Gui") regardless of the +/// ambient PropertyNamingPolicy. +/// +/// +public enum LaunchMode +{ + /// Launch the graphical client straight into the world as + /// this character. + Gui, + + /// Launch the graphical client but stop at the retail + /// character-select screen — no character selector is sent. This is + /// the default for a character that has never had its launch mode set + /// explicitly. + GuiSelect, + + /// Launch the no-window host running the idle bot + /// policy (enter world, run plugins/login commands, stay until + /// stopped). + Headless, +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs new file mode 100644 index 00000000..771d3dc2 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Root document for launcher-profiles.json (Campaign LA spec §5) +/// — the launcher's ONLY credential/profile store. Loaded and saved by +/// . +/// +public sealed class LauncherProfileDocument +{ + [JsonRequired] + public int Version { get; set; } = LauncherProfileStore.CurrentVersion; + + public List Servers { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs new file mode 100644 index 00000000..7bef5a6a --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs @@ -0,0 +1,17 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// Thrown for a malformed launcher-profiles.json document +/// or an invalid CRUD operation against +/// (unknown target, duplicate name, etc.). +public sealed class LauncherProfileException : Exception +{ + public LauncherProfileException(string message) + : base(message) + { + } + + public LauncherProfileException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs new file mode 100644 index 00000000..2fac9f0b --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -0,0 +1,395 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Load/save/CRUD owner for launcher-profiles.json (Campaign LA +/// spec §5) — the launcher's ONLY credential/profile store, and the +/// binding surface the Avalonia UI (slice LA4) mutates directly. +/// +/// +/// A store instance holds the current in-memory +/// after ; every CRUD method mutates that document in +/// place so callers can chain store.AddServer(...); store.Save(); +/// without re-threading a returned document through every call. +/// +/// +public sealed class LauncherProfileStore +{ + internal const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + AllowTrailingCommas = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + ReadCommentHandling = JsonCommentHandling.Disallow, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + WriteIndented = true, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, + }; + + public LauncherProfileStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + FilePath = Path.GetFullPath(filePath); + Document = new LauncherProfileDocument(); + } + + /// Resolve the store at the canonical location under + /// + /// (%APPDATA%\acdream\launcher-profiles.json / + /// ~/.config/acdream/launcher-profiles.json). + public static LauncherProfileStore ForApplicationPaths(ApplicationPathSet paths) + { + ArgumentNullException.ThrowIfNull(paths); + return new LauncherProfileStore( + Path.Combine(paths.ConfigDirectory, "launcher-profiles.json")); + } + + public string FilePath { get; } + + public LauncherProfileDocument Document { get; private set; } + + /// + /// Loads from . A + /// missing file is not an error — it resolves to a fresh empty + /// document (version 1, no servers), matching a never-launched + /// installation. Returns true when a file was actually read. + /// + public bool Load() + { + if (!File.Exists(FilePath)) + { + Document = new LauncherProfileDocument(); + return false; + } + + LauncherProfileDocument? document; + using (FileStream stream = File.OpenRead(FilePath)) + { + try + { + document = JsonSerializer.Deserialize( + stream, + SerializerOptions); + } + catch (JsonException ex) + { + throw new LauncherProfileException( + $"'{FilePath}' is not a valid launcher profile document.", + ex); + } + } + + if (document is null) + { + throw new LauncherProfileException($"'{FilePath}' is empty."); + } + + if (document.Version != CurrentVersion) + { + throw new LauncherProfileException( + $"Unsupported launcher-profiles version {document.Version}; " + + $"expected {CurrentVersion}."); + } + + Document = document; + return true; + } + + /// + /// Persists to via a + /// write-then-atomic-rename so a crash mid-write never leaves a + /// truncated credentials file. On Linux, restricts the final file to + /// owner read/write (0600) per Campaign LA's plaintext-credential + /// decision (spec §5, decisions log). + /// + public void Save() + { + string? directory = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string tempPath = FilePath + ".tmp"; + using (FileStream stream = File.Create(tempPath)) + { + JsonSerializer.Serialize(stream, Document, SerializerOptions); + } + + File.Move(tempPath, FilePath, overwrite: true); + + if (OperatingSystem.IsLinux()) + { + File.SetUnixFileMode( + FilePath, + UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + // --- Server CRUD ----------------------------------------------- + + public ServerProfile AddServer(string name, string host, int port) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(host); + RequireValidPort(port); + + if (FindServer(name) is not null) + { + throw new LauncherProfileException( + $"A server named '{name}' already exists."); + } + + var server = new ServerProfile { Name = name, Host = host, Port = port }; + Document.Servers.Add(server); + return server; + } + + public void EditServer( + string name, + string? newName = null, + string? newHost = null, + int? newPort = null) + { + ServerProfile server = FindServerOrThrow(name); + + if (newName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + if (!string.Equals(newName, server.Name, StringComparison.Ordinal) + && FindServer(newName) is not null) + { + throw new LauncherProfileException( + $"A server named '{newName}' already exists."); + } + + server.Name = newName; + } + + if (newHost is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newHost); + server.Host = newHost; + } + + if (newPort is not null) + { + RequireValidPort(newPort.Value); + server.Port = newPort.Value; + } + } + + public void RemoveServer(string name) + { + ServerProfile server = FindServerOrThrow(name); + Document.Servers.Remove(server); + } + + // --- Account CRUD ------------------------------------------------ + + public AccountProfile AddAccount(string serverName, string account, string password) + { + ArgumentException.ThrowIfNullOrWhiteSpace(account); + ArgumentNullException.ThrowIfNull(password); + ServerProfile server = FindServerOrThrow(serverName); + + if (FindAccount(server, account) is not null) + { + throw new LauncherProfileException( + $"Account '{account}' already exists on server '{serverName}'."); + } + + var profile = new AccountProfile { Account = account, Password = password }; + server.Accounts.Add(profile); + return profile; + } + + public void EditAccount( + string serverName, + string account, + string? newAccount = null, + string? newPassword = null) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + if (newAccount is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newAccount); + if (!string.Equals(newAccount, profile.Account, StringComparison.Ordinal) + && FindAccount(server, newAccount) is not null) + { + throw new LauncherProfileException( + $"Account '{newAccount}' already exists on server '{serverName}'."); + } + + profile.Account = newAccount; + } + + if (newPassword is not null) + { + profile.Password = newPassword; + } + } + + public void RemoveAccount(string serverName, string account) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + server.Accounts.Remove(profile); + } + + // --- Character settings (roster-driven add/remove; user-edited settings) --- + + /// + /// Edits the user-owned settings of an existing character row. There + /// is no manual add/remove for characters — the roster ( + /// ) is the only source of new rows, per + /// spec §5/§6. + /// + public void EditCharacter( + string serverName, + string account, + string characterName, + LaunchMode? launchMode = null, + IReadOnlyList? plugins = null, + IReadOnlyList? loginCommands = null) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + CharacterProfile character = FindCharacterOrThrow(profile, characterName); + + if (launchMode is not null) + { + character.LaunchMode = launchMode.Value; + } + + if (plugins is not null) + { + character.Plugins = [.. plugins]; + } + + if (loginCommands is not null) + { + character.LoginCommands = [.. loginCommands]; + } + } + + /// + /// Folds a reported character roster into an account's + /// (Campaign LA spec §3/§5/ + /// §6): every roster entry either updates the name of an existing + /// row (matched by ) while + /// PRESERVING that row's user settings (, + /// , + /// ), or is inserted as a + /// new row with default settings (, + /// no plugins, no login commands). Existing rows absent from the + /// roster are RETAINED unchanged — they may simply be pending-delete + /// (ACE keeps deleted characters queryable during the grace window) + /// or the roster snapshot may be partial; this store never deletes a + /// character row on the caller's behalf. + /// + public void MergeRoster( + string serverName, + string account, + IReadOnlyList roster) + { + ArgumentNullException.ThrowIfNull(roster); + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + foreach (CharacterRosterEntry entry in roster) + { + string idText = CharacterIdFormat.ToHexString(entry.Id); + CharacterProfile? existing = profile.Characters.Find( + character => string.Equals( + character.Id, + idText, + StringComparison.OrdinalIgnoreCase)); + + // Defensive fallback for a hand-edited file where a character + // row was added with a name but no id yet. + existing ??= profile.Characters.Find( + character => character.Id is null + && string.Equals( + character.Name, + entry.Name, + StringComparison.Ordinal)); + + if (existing is not null) + { + existing.Id = idText; + existing.Name = entry.Name; + continue; + } + + profile.Characters.Add(new CharacterProfile + { + Id = idText, + Name = entry.Name, + LaunchMode = LaunchMode.GuiSelect, + Plugins = [], + LoginCommands = [], + }); + } + } + + // --- Lookups ------------------------------------------------------- + + private ServerProfile? FindServer(string name) => + Document.Servers.Find( + server => string.Equals(server.Name, name, StringComparison.Ordinal)); + + private ServerProfile FindServerOrThrow(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return FindServer(name) + ?? throw new LauncherProfileException($"No server named '{name}'."); + } + + private static AccountProfile? FindAccount(ServerProfile server, string account) => + server.Accounts.Find( + candidate => string.Equals(candidate.Account, account, StringComparison.Ordinal)); + + private static AccountProfile FindAccountOrThrow(ServerProfile server, string account) + { + ArgumentException.ThrowIfNullOrWhiteSpace(account); + return FindAccount(server, account) + ?? throw new LauncherProfileException( + $"No account '{account}' on server '{server.Name}'."); + } + + private static CharacterProfile FindCharacterOrThrow( + AccountProfile profile, + string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + return profile.Characters.Find( + character => string.Equals( + character.Name, + characterName, + StringComparison.Ordinal)) + ?? throw new LauncherProfileException( + $"No character '{characterName}' on account '{profile.Account}'."); + } + + private static void RequireValidPort(int port) + { + if (port is < 1 or > 65535) + { + throw new LauncherProfileException( + $"Port {port} is outside the valid 1-65535 range."); + } + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs b/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs new file mode 100644 index 00000000..3a5f0388 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// One server entry, per Campaign LA spec §5 (manual add — no +/// published server-list import this campaign). +public sealed class ServerProfile +{ + [JsonRequired] + public string Name { get; set; } = string.Empty; + + [JsonRequired] + public string Host { get; set; } = string.Empty; + + [JsonRequired] + public int Port { get; set; } + + public List Accounts { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs new file mode 100644 index 00000000..b216392b --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -0,0 +1,81 @@ +namespace AcDream.Launcher.Core.Status; + +/// +/// One parsed line of a host's status.jsonl stream (Campaign LA +/// spec §6). Every event carries the versioned envelope +/// (v/e/t/sessionId) plus its own typed +/// payload. See for the wire shape and +/// for the +/// incremental reader that produces these. +/// +public abstract record StatusEvent +{ + public required int V { get; init; } + + public required string E { get; init; } + + public required DateTimeOffset T { get; init; } + + public required string SessionId { get; init; } +} + +public sealed record StartedStatusEvent : StatusEvent; + +public sealed record ConnectedStatusEvent : StatusEvent; + +public readonly record struct StatusCharacterEntry( + uint Id, + string Name, + int SecondsGreyedOut); + +public sealed record CharacterListStatusEvent : StatusEvent +{ + public required string AccountName { get; init; } + + public required int SlotCount { get; init; } + + public required IReadOnlyList Characters { get; init; } +} + +public sealed record EnteredWorldStatusEvent : StatusEvent +{ + public required uint CharacterId { get; init; } + + public required string CharacterName { get; init; } +} + +public sealed record PluginLoadedStatusEvent : StatusEvent +{ + public required string Plugin { get; init; } +} + +public sealed record PluginFailedStatusEvent : StatusEvent +{ + public required string Plugin { get; init; } + + public required string Error { get; init; } +} + +public sealed record DisconnectedStatusEvent : StatusEvent +{ + public required string Reason { get; init; } +} + +public sealed record ExitedStatusEvent : StatusEvent +{ + public required int Code { get; init; } + + public required string Reason { get; init; } +} + +/// +/// A well-formed status line whose e value (or overall envelope +/// shape) this reader does not recognize. The tailer never throws on an +/// unrecognized event — an older launcher reading a newer host's stream +/// degrades to seeing rows instead of +/// crashing. +/// +public sealed record UnknownStatusEvent : StatusEvent +{ + public required string RawJson { get; init; } +} diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs new file mode 100644 index 00000000..17495b7c --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -0,0 +1,247 @@ +using System.Text.Json; + +namespace AcDream.Launcher.Core.Status; + +/// +/// Parses one status.jsonl line (Campaign LA spec §6) into a typed +/// . Wire shape: every line is a flat JSON +/// object carrying the envelope (v, e, t, +/// sessionId) alongside that event's own fields — e.g. +/// {"v":1,"e":"characterList","t":"...","sessionId":"...", +/// "accountName":"...","slotCount":6,"characters":[...]}. +/// +/// +/// Never throws: a line whose e is not one of the eight known +/// values, or whose payload doesn't match that event's expected shape, +/// or that isn't valid JSON at all, degrades to a typed +/// rather than an exception — a +/// launcher must keep tailing a session's status stream even against a +/// host running a newer/older wire version. +/// +/// +public static class StatusEventParser +{ + public static StatusEvent Parse(string line) + { + ArgumentException.ThrowIfNullOrWhiteSpace(line); + + try + { + using JsonDocument document = JsonDocument.Parse(line); + JsonElement root = document.RootElement; + + int v = GetInt32OrDefault(root, "v"); + string e = GetStringOrDefault(root, "e"); + DateTimeOffset t = GetDateTimeOffsetOrDefault(root, "t"); + string sessionId = GetStringOrDefault(root, "sessionId"); + + return e switch + { + "started" => + new StartedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, + "connected" => + new ConnectedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, + "characterList" => + ParseCharacterList(root, v, e, t, sessionId), + "enteredWorld" => + ParseEnteredWorld(root, v, e, t, sessionId), + "pluginLoaded" => + ParsePluginLoaded(root, v, e, t, sessionId), + "pluginFailed" => + ParsePluginFailed(root, v, e, t, sessionId), + "disconnected" => + ParseDisconnected(root, v, e, t, sessionId), + "exited" => + ParseExited(root, v, e, t, sessionId), + _ => + new UnknownStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + RawJson = line, + }, + }; + } + catch (Exception) + { + // JsonException (malformed JSON), FormatException (a + // required-field miss inside a Parse* helper) — all degrade + // the same way: never throw out of the tailer. + return new UnknownStatusEvent + { + V = 0, + E = string.Empty, + T = default, + SessionId = string.Empty, + RawJson = line, + }; + } + } + + private static StatusEvent ParseCharacterList( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) + { + string accountName = RequireString(root, "accountName"); + int slotCount = RequireInt32(root, "slotCount"); + JsonElement charactersElement = RequireProperty(root, "characters"); + + var characters = new List(); + foreach (JsonElement item in charactersElement.EnumerateArray()) + { + uint id = RequireUInt32(item, "id"); + string name = RequireString(item, "name"); + int secondsGreyedOut = RequireInt32(item, "secondsGreyedOut"); + characters.Add(new StatusCharacterEntry(id, name, secondsGreyedOut)); + } + + return new CharacterListStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + AccountName = accountName, + SlotCount = slotCount, + Characters = characters, + }; + } + + private static StatusEvent ParseEnteredWorld( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new EnteredWorldStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + CharacterId = RequireUInt32(root, "characterId"), + CharacterName = RequireString(root, "characterName"), + }; + + private static StatusEvent ParsePluginLoaded( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new PluginLoadedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Plugin = RequireString(root, "plugin"), + }; + + private static StatusEvent ParsePluginFailed( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new PluginFailedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Plugin = RequireString(root, "plugin"), + Error = RequireString(root, "error"), + }; + + private static StatusEvent ParseDisconnected( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new DisconnectedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Reason = RequireString(root, "reason"), + }; + + private static StatusEvent ParseExited( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new ExitedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Code = RequireInt32(root, "code"), + Reason = RequireString(root, "reason"), + }; + + private static int GetInt32OrDefault(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.Number + && element.TryGetInt32(out int value) + ? value + : 0; + + private static string GetStringOrDefault(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + ? element.GetString() ?? string.Empty + : string.Empty; + + private static DateTimeOffset GetDateTimeOffsetOrDefault( + JsonElement root, + string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + && DateTimeOffset.TryParse( + element.GetString(), + System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, + out DateTimeOffset value) + ? value + : default; + + private static JsonElement RequireProperty(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + ? element + : throw new FormatException($"status event is missing '{name}'."); + + private static string RequireString(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.String + ? element.GetString() ?? string.Empty + : throw new FormatException($"status event field '{name}' is not a string."); + } + + private static int RequireInt32(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value) + ? value + : throw new FormatException($"status event field '{name}' is not an integer."); + } + + private static uint RequireUInt32(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value) + ? value + : throw new FormatException($"status event field '{name}' is not an unsigned integer."); + } +} diff --git a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs new file mode 100644 index 00000000..69b81852 --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs @@ -0,0 +1,118 @@ +using System.Text; + +namespace AcDream.Launcher.Core.Status; + +/// +/// Incremental reader over a host's status.jsonl file (Campaign LA +/// spec §3/§6). Each call to returns the +/// events that arrived since the previous call, tolerating: +/// +/// the file not existing yet (the launcher may start tailing +/// before the host has written its first line — returns no events, not +/// an error); +/// a partial last line (the host may be mid-write when polled — +/// the tailer only advances its read position past the last confirmed +/// '\n'; a still-incomplete tail is re-read, combined with +/// whatever gets appended, on the next poll — never parsed while +/// truncated). +/// +/// One tailer instance owns one file's read position; construct a new +/// one per session. +/// +public sealed class StatusFileTailer +{ + private readonly string _path; + private long _position; + + public StatusFileTailer(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _path = path; + } + + /// + /// Reads and parses every complete line appended to the file since + /// the last call. Returns an empty list (never null, never throws) + /// when the file doesn't exist yet or nothing new/complete has + /// arrived since the last poll. + /// + public IReadOnlyList ReadNewEvents() + { + if (!File.Exists(_path)) + { + return []; + } + + using var stream = new FileStream( + _path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + + if (stream.Length < _position) + { + // The file was truncated/replaced under us (e.g. a fresh + // session reusing a stale path) — restart from the top + // rather than throwing or silently missing the new content. + _position = 0; + } + + if (stream.Length == _position) + { + return []; + } + + stream.Seek(_position, SeekOrigin.Begin); + int unreadByteCount = checked((int)(stream.Length - _position)); + byte[] buffer = new byte[unreadByteCount]; + int totalRead = 0; + while (totalRead < unreadByteCount) + { + int read = stream.Read(buffer, totalRead, unreadByteCount - totalRead); + if (read == 0) + { + break; + } + + totalRead += read; + } + + var events = new List(); + int lineStart = 0; + + // How far into `buffer` we've confirmed a complete line — this + // is where `_position` advances to. Bytes after this point (an + // in-progress line with no trailing '\n' yet) are simply left + // unread on disk; the next poll re-reads them from `_position` + // combined with whatever the writer appends in between. No + // separate in-memory carry-over buffer is needed. + int consumedThroughIndex = 0; + + for (int i = 0; i < totalRead; i++) + { + if (buffer[i] != (byte)'\n') + { + continue; + } + + int lineEnd = i; + if (lineEnd > lineStart && buffer[lineEnd - 1] == (byte)'\r') + { + lineEnd--; + } + + if (lineEnd > lineStart) + { + string rawLine = Encoding.UTF8.GetString(buffer, lineStart, lineEnd - lineStart); + events.Add(StatusEventParser.Parse(rawLine)); + } + + lineStart = i + 1; + consumedThroughIndex = lineStart; + } + + _position += consumedThroughIndex; + + return events; + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj new file mode 100644 index 00000000..6f77e682 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + latest + true + + + + + + + + + + + + + + + diff --git a/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs b/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs new file mode 100644 index 00000000..106106c7 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Integrity/FileIntegrityTests.cs @@ -0,0 +1,98 @@ +using System.Security.Cryptography; +using System.Text; +using AcDream.Launcher.Core.Integrity; + +namespace AcDream.Launcher.Core.Tests.Integrity; + +public sealed class FileIntegrityTests : IDisposable +{ + private readonly string _root; + + public FileIntegrityTests() + { + _root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-integrity-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void ComputeSha256HexMatchesTheFrameworkHasher() + { + string path = Path.Combine(_root, "file.bin"); + byte[] content = Encoding.UTF8.GetBytes("acdream launcher integrity fixture"); + File.WriteAllBytes(path, content); + string expected = Convert.ToHexStringLower(SHA256.HashData(content)); + + string actual = FileIntegrity.ComputeSha256Hex(path); + + Assert.Equal(expected, actual); + } + + [Fact] + public async Task ComputeSha256HexAsyncMatchesTheSyncResult() + { + string path = Path.Combine(_root, "file.bin"); + File.WriteAllBytes(path, Encoding.UTF8.GetBytes("async path fixture")); + + string sync = FileIntegrity.ComputeSha256Hex(path); + string asyncResult = await FileIntegrity.ComputeSha256HexAsync(path); + + Assert.Equal(sync, asyncResult); + } + + [Fact] + public void VerifySucceedsForAMatchingDigestRegardlessOfCase() + { + string path = Path.Combine(_root, "file.bin"); + File.WriteAllBytes(path, Encoding.UTF8.GetBytes("case-insensitive fixture")); + string lower = FileIntegrity.ComputeSha256Hex(path); + + Assert.True(FileIntegrity.Verify(path, lower)); + Assert.True(FileIntegrity.Verify(path, lower.ToUpperInvariant())); + } + + [Fact] + public void VerifyFailsForAMismatchedDigest() + { + string path = Path.Combine(_root, "file.bin"); + File.WriteAllBytes(path, Encoding.UTF8.GetBytes("original content")); + + Assert.False(FileIntegrity.Verify(path, new string('0', 64))); + } + + [Fact] + public void DifferentContentProducesDifferentDigests() + { + string pathA = Path.Combine(_root, "a.bin"); + string pathB = Path.Combine(_root, "b.bin"); + File.WriteAllBytes(pathA, Encoding.UTF8.GetBytes("content A")); + File.WriteAllBytes(pathB, Encoding.UTF8.GetBytes("content B")); + + Assert.NotEqual( + FileIntegrity.ComputeSha256Hex(pathA), + FileIntegrity.ComputeSha256Hex(pathB)); + } + + [Fact] + public void EmptyFileHashesToTheWellKnownSha256OfEmptyInput() + { + string path = Path.Combine(_root, "empty.bin"); + File.WriteAllBytes(path, []); + + string actual = FileIntegrity.ComputeSha256Hex(path); + + Assert.Equal( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + actual); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs new file mode 100644 index 00000000..df12ea29 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -0,0 +1,246 @@ +using System.Threading; +using AcDream.Launcher.Core.Launching; + +namespace AcDream.Launcher.Core.Tests.Launching; + +public sealed class LauncherProcessSupervisorTests +{ + [Fact] + public void StartWritesPasswordThenClosesStdinAndTransitionsToRunning() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + var states = new List(); + supervisor.StateChanged += (_, s) => states.Add(s); + + supervisor.Start(Spec(), "S3cretPassw0rd!"); + + FakeChildProcess fake = factory.LastCreated!; + Assert.True(fake.Started); + Assert.Equal("S3cretPassw0rd!\n", fake.StandardInputText); + Assert.True(fake.StandardInputClosed); + Assert.Equal(LauncherSessionState.Running, supervisor.State); + Assert.Equal( + [LauncherSessionState.Starting, LauncherSessionState.Running], + states); + } + + [Fact] + public void StartWithNullPasswordClosesStdinWithoutWriting() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + + supervisor.Start(Spec(), password: null); + + FakeChildProcess fake = factory.LastCreated!; + Assert.Equal(string.Empty, fake.StandardInputText); + Assert.True(fake.StandardInputClosed); + } + + [Fact] + public void StartTwiceOnTheSameSupervisorThrows() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + + Assert.Throws(() => supervisor.Start(Spec(), "pw")); + } + + [Fact] + public void StopCallsCloseMainWindowAndSucceedsWithoutKillWhenTheProcessExitsInTime() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + FakeChildProcess fake = factory.LastCreated!; + Assert.True(fake.CloseMainWindowCalled); + Assert.Equal(0, fake.KillCallCount); + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + Assert.Equal(0, supervisor.ExitCode); + } + + [Fact] + public void StopFallsBackToKillWhenTheProcessDoesNotExitWithinTheTimeout() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: false); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + FakeChildProcess fake = factory.LastCreated!; + Assert.True(fake.CloseMainWindowCalled); + Assert.Equal(1, fake.KillCallCount); + Assert.Equal(LauncherSessionState.Exited, supervisor.State); + } + + [Fact] + public void StopIsANoOpBeforeStart() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + Assert.Null(factory.LastCreated); + Assert.Equal(LauncherSessionState.Starting, supervisor.State); + } + + [Fact] + public void StopIsANoOpAfterTheProcessHasAlreadyExited() + { + var factory = new FakeChildProcessFactory(exitsWithinStopTimeout: true); + using var supervisor = new LauncherProcessSupervisor(factory); + supervisor.Start(Spec(), "pw"); + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + FakeChildProcess fake = factory.LastCreated!; + Assert.Equal(0, fake.KillCallCount); + + supervisor.Stop(TimeSpan.FromMilliseconds(50)); + + // CloseMainWindow was called exactly once (the first Stop) — + // Stop after exit does not re-invoke the graceful/kill dance. + Assert.Equal(1, fake.CloseMainWindowCallCount); + Assert.Equal(0, fake.KillCallCount); + } + + [Fact] + public void LauncherProcessSpecCarriesNoCredentialLikeMember() + { + // Defense in depth: the password must never be able to reach + // process arguments or environment (Campaign LA plan §LA3). This + // guards against a future field addition accidentally widening + // that surface. + System.Reflection.PropertyInfo[] properties = + typeof(LauncherProcessSpec).GetProperties(); + Assert.DoesNotContain( + properties, + p => p.Name.Contains("password", StringComparison.OrdinalIgnoreCase) + || p.Name.Contains("credential", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void RealProcessSpawnFeedsStdinAndCapturesExitCode() + { + // The "trivial cross-platform fake child" (plan §LA3 acceptance): + // `dotnet --version` is guaranteed present (we're running under + // `dotnet test`) on both Windows and Linux/WSL, ignores stdin + // entirely, and reliably exits 0 — proving the REAL + // SystemChildProcessFactory spawn/stdin-feed/exit-code-capture + // path end to end without any OS-specific script branching. + string dotnet = FindDotnetExecutable(); + using var supervisor = new LauncherProcessSupervisor(); + var exited = new ManualResetEventSlim(false); + supervisor.StateChanged += (_, s) => + { + if (s == LauncherSessionState.Exited) + exited.Set(); + }; + + supervisor.Start( + new LauncherProcessSpec(dotnet, ["--version"]), + "unused-password-ignored-by-dotnet"); + + bool completed = exited.Wait(TimeSpan.FromSeconds(30)); + + Assert.True(completed, "the real dotnet --version child did not exit within 30s"); + Assert.Equal(0, supervisor.ExitCode); + } + + private static LauncherProcessSpec Spec() => + new("fake-host", ["--session-config", "session.json"]); + + private static string FindDotnetExecutable() => + // PATH-based resolution: .NET Core's Process.Start searches PATH + // for a bare filename when UseShellExecute is false, on both + // Windows and Unix, and `dotnet` is guaranteed on PATH here + // because this test is itself running under `dotnet test`. + OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + + private sealed class FakeChildProcessFactory(bool exitsWithinStopTimeout) + : ILauncherChildProcessFactory + { + public FakeChildProcess? LastCreated { get; private set; } + + public ILauncherChildProcess Create(LauncherProcessSpec spec) + { + LastCreated = new FakeChildProcess(spec, exitsWithinStopTimeout); + return LastCreated; + } + } + + private sealed class FakeChildProcess(LauncherProcessSpec spec, bool exitsWithinStopTimeout) + : ILauncherChildProcess + { + private readonly RecordingTextWriter _standardInput = new(); + + public LauncherProcessSpec Spec { get; } = spec; + + public bool Started { get; private set; } + + public string StandardInputText => _standardInput.ToString(); + + public bool StandardInputClosed => _standardInput.IsClosed; + + public bool CloseMainWindowCalled => CloseMainWindowCallCount > 0; + + public int CloseMainWindowCallCount { get; private set; } + + public int KillCallCount { get; private set; } + + public bool HasExited { get; private set; } + + public int ExitCode { get; private set; } + + public TextWriter StandardInput => _standardInput; + + public event EventHandler? Exited; + + public void Start() => Started = true; + + public bool CloseMainWindow() + { + CloseMainWindowCallCount++; + return true; + } + + public void Kill() + { + KillCallCount++; + HasExited = true; + ExitCode = -1; + Exited?.Invoke(this, EventArgs.Empty); + } + + public bool WaitForExit(TimeSpan timeout) + { + if (!exitsWithinStopTimeout) + return false; + + HasExited = true; + ExitCode = 0; + Exited?.Invoke(this, EventArgs.Empty); + return true; + } + + public void Dispose() + { + } + } + + private sealed class RecordingTextWriter : StringWriter + { + public bool IsClosed { get; private set; } + + protected override void Dispose(bool disposing) + { + IsClosed = true; + base.Dispose(disposing); + } + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs new file mode 100644 index 00000000..49d8615b --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Launching/SessionConfigComposerTests.cs @@ -0,0 +1,269 @@ +using System.Text.Json.Nodes; +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Tests.Launching; + +/// +/// Golden-shape tests for against the +/// Campaign LA plan §LA3 pinned contract: exactly the listed keys, exact +/// camelCase names, character/policy presence rules per launch mode, and +/// (critically) no password anywhere in the document. +/// +public sealed class SessionConfigComposerTests +{ + private static readonly ApplicationPathSet Paths = new( + ConfigDirectory: "/cfg/acdream", + DataDirectory: "/data/acdream", + CacheDirectory: "/cache/acdream", + LegacyConfigDirectory: null); + + private static readonly LauncherInstallRecord Install = new( + DatDirectory: "/dats", + PreparedAssetPath: "/data/acdream/pak/acdream.pak"); + + private static ServerProfile Server() => + new() { Name = "Local ACE", Host = "127.0.0.1", Port = 9000 }; + + private static AccountProfile Account() => + new() { Account = "testaccount", Password = "S3cretPassw0rd!" }; + + private static CharacterProfile Character(LaunchMode mode, string? id = "0x5000000A") => + new() + { + Name = "+Acdream", + Id = id, + LaunchMode = mode, + Plugins = ["ExamplePlugin"], + LoginCommands = ["/tell someone, hi"], + }; + + [Fact] + public void GuiModeIncludesCharacterSelectorAndOmitsPolicy() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Gui), + Install, + Paths, + sessionId: "session-gui"); + + JsonObject session = SingleSession(composed); + + AssertKeys( + session, + "id", "endpoint", "account", "character", "credential", + "plugins", "loginCommands", "statusFile"); + + Assert.Equal("session-gui", (string?)session["id"]); + Assert.Equal("testaccount", (string?)session["account"]); + Assert.Equal(0x5000000Au, (uint?)session["character"]!["id"]); + Assert.Null(session["character"]!["name"]); + Assert.Null(session["character"]!["index"]); + Assert.Equal("standardInput", (string?)session["credential"]!["provider"]); + Assert.Equal("session", (string?)session["credential"]!["reference"]); + Assert.Equal( + new[] { "ExamplePlugin" }, + session["plugins"]!.AsArray().Select(n => (string?)n)); + Assert.Equal( + new[] { "/tell someone, hi" }, + session["loginCommands"]!.AsArray().Select(n => (string?)n)); + Assert.Equal( + Path.Combine(Paths.CacheDirectory, "launcher", "sessions", "session-gui", "status.jsonl"), + (string?)session["statusFile"]); + } + + [Fact] + public void GuiSelectModeOmitsCharacterFieldEntirely() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.GuiSelect), + Install, + Paths, + sessionId: "session-guiselect"); + + JsonObject session = SingleSession(composed); + + AssertKeys( + session, + "id", "endpoint", "account", "credential", + "plugins", "loginCommands", "statusFile"); + Assert.False(session.ContainsKey("character")); + Assert.False(session.ContainsKey("policy")); + } + + [Fact] + public void HeadlessModeIncludesCharacterAndIdlePolicy() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Headless), + Install, + Paths, + sessionId: "session-headless", + loginCommandDelayMs: 750); + + JsonObject session = SingleSession(composed); + + AssertKeys( + session, + "id", "endpoint", "account", "character", "policy", "credential", + "plugins", "loginCommands", "loginCommandDelayMs", "statusFile"); + Assert.Equal(0x5000000Au, (uint?)session["character"]!["id"]); + Assert.Equal("idle", (string?)session["policy"]!["id"]); + Assert.Equal(750, (int?)session["loginCommandDelayMs"]); + } + + [Fact] + public void GuiModeFallsBackToNameSelectorWhenIdIsMissing() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Gui, id: null), + Install, + Paths, + sessionId: "session-gui-name"); + + JsonObject session = SingleSession(composed); + Assert.Null(session["character"]!["id"]); + Assert.Equal("+Acdream", (string?)session["character"]!["name"]); + } + + [Fact] + public void PluginsAndLoginCommandsAreOmittedWhenEmptyRatherThanEmptyArrays() + { + CharacterProfile character = Character(LaunchMode.Gui); + character.Plugins = []; + character.LoginCommands = []; + + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + character, + Install, + Paths, + sessionId: "session-empty-lists"); + + JsonObject session = SingleSession(composed); + Assert.False(session.ContainsKey("plugins")); + Assert.False(session.ContainsKey("loginCommands")); + } + + [Fact] + public void ProcessContentCarriesInstallRecordAndPathsIsAlwaysPresent() + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + Account(), + Character(LaunchMode.Gui), + Install, + Paths, + sessionId: "session-content"); + + JsonObject root = ParseRoot(composed); + Assert.Equal(1, (int?)root["version"]); + JsonObject process = root["process"]!.AsObject(); + AssertKeys(process, "paths", "content"); + + // Paths is always present as an object; every member is omitted + // when unset (hosts resolve their own default ApplicationPathSet). + Assert.Empty(process["paths"]!.AsObject()); + + JsonObject content = process["content"]!.AsObject(); + AssertKeys(content, "datDirectory", "preparedAssetPath"); + Assert.Equal(Install.DatDirectory, (string?)content["datDirectory"]); + Assert.Equal(Install.PreparedAssetPath, (string?)content["preparedAssetPath"]); + } + + [Fact] + public void ComposedDocumentNeverContainsThePassword() + { + AccountProfile account = Account(); + + foreach (LaunchMode mode in new[] { LaunchMode.Gui, LaunchMode.GuiSelect, LaunchMode.Headless }) + { + ComposedSessionConfig composed = SessionConfigComposer.Compose( + Server(), + account, + Character(mode), + Install, + Paths, + sessionId: $"session-{mode}"); + + string json = SessionConfigComposer.Serialize(composed.Document); + Assert.DoesNotContain(account.Password, json, StringComparison.Ordinal); + } + } + + [Fact] + public void ComposeAndWriteWritesSessionJsonUnderTheExpectedPath() + { + string root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-composer-tests", + Guid.NewGuid().ToString("N")); + try + { + var paths = new ApplicationPathSet( + Path.Combine(root, "cfg"), + Path.Combine(root, "data"), + Path.Combine(root, "cache"), + null); + + ComposedSessionConfig composed = SessionConfigComposer.ComposeAndWrite( + Server(), + Account(), + Character(LaunchMode.Gui), + Install, + paths, + sessionId: "session-write"); + + string expectedPath = Path.Combine( + paths.CacheDirectory, "launcher", "sessions", "session-write", "session.json"); + Assert.Equal(expectedPath, composed.ConfigFilePath); + Assert.True(File.Exists(expectedPath)); + + string text = File.ReadAllText(expectedPath); + Assert.DoesNotContain(Account().Password, text, StringComparison.Ordinal); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + private static JsonObject ParseRoot(ComposedSessionConfig composed) + { + string json = SessionConfigComposer.Serialize(composed.Document); + return JsonNode.Parse(json)!.AsObject(); + } + + private static JsonObject SingleSession(ComposedSessionConfig composed) + { + JsonObject root = ParseRoot(composed); + JsonArray sessions = root["sessions"]!.AsArray(); + return Assert.Single(sessions)!.AsObject(); + } + + /// + /// Asserts the object's property set is EXACTLY the given keys — no + /// more, no fewer — without depending on reflection-based member + /// enumeration order (only the presence/absence of each pinned- + /// contract key is a guarantee this slice makes). + /// + private static void AssertKeys(JsonObject obj, params string[] expectedKeys) + { + var actual = new HashSet(obj.Select(kv => kv.Key), StringComparer.Ordinal); + var expected = new HashSet(expectedKeys, StringComparer.Ordinal); + Assert.Equal(expected, actual); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs new file mode 100644 index 00000000..fdade48b --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/CharacterIdFormatTests.cs @@ -0,0 +1,43 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Tests.Profiles; + +public sealed class CharacterIdFormatTests +{ + [Fact] + public void ToHexStringFormatsEightDigitUppercaseWithPrefix() + { + Assert.Equal("0x5000000A", CharacterIdFormat.ToHexString(0x5000000Au)); + Assert.Equal("0x00000001", CharacterIdFormat.ToHexString(1u)); + } + + [Theory] + [InlineData("0x5000000A", 0x5000000Au)] + [InlineData("0x5000000a", 0x5000000Au)] + [InlineData("5000000A", 0x5000000Au)] + public void TryParseAcceptsWithAndWithoutPrefixAndCase(string text, uint expected) + { + Assert.True(CharacterIdFormat.TryParse(text, out uint id)); + Assert.Equal(expected, id); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-hex")] + public void TryParseRejectsNullEmptyOrNonHex(string? text) + { + Assert.False(CharacterIdFormat.TryParse(text, out uint id)); + Assert.Equal(0u, id); + } + + [Fact] + public void RoundTripsThroughToHexStringAndTryParse() + { + const uint original = 0x5000000Au; + string text = CharacterIdFormat.ToHexString(original); + Assert.True(CharacterIdFormat.TryParse(text, out uint parsed)); + Assert.Equal(original, parsed); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs new file mode 100644 index 00000000..db6a672a --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/LauncherProfileStoreTests.cs @@ -0,0 +1,287 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Tests.Profiles; + +public sealed class LauncherProfileStoreTests : IDisposable +{ + private readonly string _root; + private readonly string _filePath; + + public LauncherProfileStoreTests() + { + _root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-profile-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _filePath = Path.Combine(_root, "launcher-profiles.json"); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void LoadOnMissingFileYieldsEmptyDocumentWithoutTouchingDisk() + { + var store = new LauncherProfileStore(_filePath); + + bool loaded = store.Load(); + + Assert.False(loaded); + Assert.False(File.Exists(_filePath)); + Assert.Equal(1, store.Document.Version); + Assert.Empty(store.Document.Servers); + } + + [Fact] + public void AddServerThenSaveThenReloadRoundTrips() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.Save(); + + Assert.True(File.Exists(_filePath)); + + var reloaded = new LauncherProfileStore(_filePath); + reloaded.Load(); + + ServerProfile server = Assert.Single(reloaded.Document.Servers); + Assert.Equal("Local ACE", server.Name); + Assert.Equal("127.0.0.1", server.Host); + Assert.Equal(9000, server.Port); + Assert.Empty(server.Accounts); + } + + [Fact] + public void AddServerRejectsDuplicateName() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + + var ex = Assert.Throws( + () => store.AddServer("Local ACE", "127.0.0.1", 9001)); + Assert.Contains("Local ACE", ex.Message); + } + + [Theory] + [InlineData(0)] + [InlineData(65536)] + [InlineData(-1)] + public void AddServerRejectsOutOfRangePort(int port) + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + + Assert.Throws( + () => store.AddServer("Local ACE", "127.0.0.1", port)); + } + + [Fact] + public void EditServerRenamesAndUpdatesHostAndPort() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + + store.EditServer("Local ACE", newName: "Home ACE", newHost: "10.0.0.5", newPort: 9001); + + ServerProfile server = Assert.Single(store.Document.Servers); + Assert.Equal("Home ACE", server.Name); + Assert.Equal("10.0.0.5", server.Host); + Assert.Equal(9001, server.Port); + } + + [Fact] + public void EditServerOnUnknownNameThrows() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + + Assert.Throws( + () => store.EditServer("Nope", newHost: "1.2.3.4")); + } + + [Fact] + public void RemoveServerRemovesIt() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + + store.RemoveServer("Local ACE"); + + Assert.Empty(store.Document.Servers); + } + + [Fact] + public void AddEditRemoveAccountRoundTrip() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + + store.AddAccount("Local ACE", "testaccount", "testpassword"); + AccountProfile account = Assert.Single( + store.Document.Servers.Single().Accounts); + Assert.Equal("testaccount", account.Account); + Assert.Equal("testpassword", account.Password); + + store.EditAccount( + "Local ACE", + "testaccount", + newAccount: "renamed", + newPassword: "newpass"); + account = Assert.Single(store.Document.Servers.Single().Accounts); + Assert.Equal("renamed", account.Account); + Assert.Equal("newpass", account.Password); + + store.RemoveAccount("Local ACE", "renamed"); + Assert.Empty(store.Document.Servers.Single().Accounts); + } + + [Fact] + public void AddAccountRejectsDuplicateAccountOnSameServer() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "pw"); + + Assert.Throws( + () => store.AddAccount("Local ACE", "testaccount", "pw2")); + } + + [Fact] + public void EditCharacterUpdatesLaunchModePluginsAndLoginCommandsOnly() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "pw"); + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + + store.EditCharacter( + "Local ACE", + "testaccount", + "+Acdream", + launchMode: LaunchMode.Headless, + plugins: ["ExamplePlugin"], + loginCommands: ["/tell someone, hi"]); + + CharacterProfile character = Assert.Single( + store.Document.Servers.Single().Accounts.Single().Characters); + Assert.Equal(LaunchMode.Headless, character.LaunchMode); + Assert.Equal(["ExamplePlugin"], character.Plugins); + Assert.Equal(["/tell someone, hi"], character.LoginCommands); + Assert.Equal("0x5000000A", character.Id); + } + + [Fact] + public void FullProfileWithServersAccountsAndCharactersRoundTripsThroughDisk() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "testpassword"); + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + store.EditCharacter( + "Local ACE", + "testaccount", + "+Acdream", + launchMode: LaunchMode.Gui, + plugins: ["ExamplePlugin"], + loginCommands: ["/vt start"]); + store.Save(); + + // Direct proof of the on-disk enum casing — a round-trip alone + // could mask a PascalCase regression if the reader ever became + // case-insensitive on enum values. + string text = File.ReadAllText(_filePath); + Assert.Contains("\"launchMode\":\"gui\"", text.Replace(" ", string.Empty)); + + var reloaded = new LauncherProfileStore(_filePath); + reloaded.Load(); + + ServerProfile server = Assert.Single(reloaded.Document.Servers); + AccountProfile account = Assert.Single(server.Accounts); + CharacterProfile character = Assert.Single(account.Characters); + Assert.Equal("+Acdream", character.Name); + Assert.Equal("0x5000000A", character.Id); + Assert.Equal(LaunchMode.Gui, character.LaunchMode); + Assert.Equal(["ExamplePlugin"], character.Plugins); + Assert.Equal(["/vt start"], character.LoginCommands); + } + + [Fact] + public void LoadRejectsUnsupportedVersion() + { + File.WriteAllText(_filePath, """{"version":2,"servers":[]}"""); + var store = new LauncherProfileStore(_filePath); + + Assert.Throws(() => store.Load()); + } + + [Fact] + public void LoadRejectsUnmappedMembersStrictly() + { + File.WriteAllText( + _filePath, + """{"version":1,"servers":[],"unexpectedField":true}"""); + var store = new LauncherProfileStore(_filePath); + + Assert.Throws(() => store.Load()); + } + + [Fact] + public void SaveWritesCamelCaseJson() + { + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.Save(); + + string text = File.ReadAllText(_filePath); + Assert.Contains("\"version\"", text); + Assert.Contains("\"servers\"", text); + Assert.Contains("\"host\"", text); + Assert.DoesNotContain("\"Version\"", text); + Assert.DoesNotContain("\"Servers\"", text); + } + + [Fact] + public void SaveSetsOwnerOnlyPermissionsOnLinux() + { + // Linux-conditional: 0600 is a Linux-only hygiene step (spec §5, + // decisions log item "Windows profile-file permissions"). A no-op + // pass on Windows/macOS, matching the repo's established + // OperatingSystem.IsLinux() early-return pattern (e.g. + // HeadlessCredentialResolverTests.LinuxRejectsGroupOrOtherCredentialPermissions). + if (!OperatingSystem.IsLinux()) + return; + + var store = new LauncherProfileStore(_filePath); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "testpassword"); + store.Save(); + + UnixFileMode mode = File.GetUnixFileMode(_filePath); + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite, + mode); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs new file mode 100644 index 00000000..9b967ea9 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Profiles/RosterMergeTests.cs @@ -0,0 +1,146 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Tests.Profiles; + +/// +/// The roster-merge matrix (Campaign LA plan §LA3 acceptance): a new +/// character, an existing character keeping its user settings, and a +/// character absent from a later roster snapshot being retained +/// (possibly pending-delete). +/// +public sealed class RosterMergeTests +{ + private static LauncherProfileStore NewStoreWithServerAndAccount() + { + var store = new LauncherProfileStore( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json")); + store.Load(); + store.AddServer("Local ACE", "127.0.0.1", 9000); + store.AddAccount("Local ACE", "testaccount", "testpassword"); + return store; + } + + [Fact] + public void FirstMergeAddsNewCharactersWithDefaultLaunchModeGuiSelect() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + + store.MergeRoster( + "Local ACE", + "testaccount", + [ + new CharacterRosterEntry(0x5000000A, "+Acdream", 0), + new CharacterRosterEntry(0x5000000B, "+Second", 0), + ]); + + List characters = + store.Document.Servers.Single().Accounts.Single().Characters; + Assert.Equal(2, characters.Count); + + CharacterProfile first = characters.Single(c => c.Name == "+Acdream"); + Assert.Equal("0x5000000A", first.Id); + Assert.Equal(LaunchMode.GuiSelect, first.LaunchMode); + Assert.Empty(first.Plugins); + Assert.Empty(first.LoginCommands); + + CharacterProfile second = characters.Single(c => c.Name == "+Second"); + Assert.Equal("0x5000000B", second.Id); + Assert.Equal(LaunchMode.GuiSelect, second.LaunchMode); + } + + [Fact] + public void SecondMergePreservesUserSettingsOnAnExistingCharacter() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + store.EditCharacter( + "Local ACE", + "testaccount", + "+Acdream", + launchMode: LaunchMode.Headless, + plugins: ["ExamplePlugin"], + loginCommands: ["/vt start"]); + + // A later probe reports the same character again (same id), with + // a renamed display — settings must survive untouched. + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + + CharacterProfile character = Assert.Single( + store.Document.Servers.Single().Accounts.Single().Characters); + Assert.Equal(LaunchMode.Headless, character.LaunchMode); + Assert.Equal(["ExamplePlugin"], character.Plugins); + Assert.Equal(["/vt start"], character.LoginCommands); + } + + [Fact] + public void MergeUpdatesNameWhenIdMatchesButDisplayNameChanged() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+OldName", 0)]); + + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+NewName", 0)]); + + CharacterProfile character = Assert.Single( + store.Document.Servers.Single().Accounts.Single().Characters); + Assert.Equal("+NewName", character.Name); + Assert.Equal("0x5000000A", character.Id); + } + + [Fact] + public void CharacterAbsentFromALaterRosterSnapshotIsRetained() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + store.MergeRoster( + "Local ACE", + "testaccount", + [ + new CharacterRosterEntry(0x5000000A, "+Acdream", 0), + new CharacterRosterEntry(0x5000000B, "+PendingDelete", 1), + ]); + + // A later probe's roster only reports one of the two — e.g. the + // other was deleted and is now in ACE's grace window / a + // partial snapshot. The store never removes rows on the + // caller's behalf. + store.MergeRoster( + "Local ACE", + "testaccount", + [new CharacterRosterEntry(0x5000000A, "+Acdream", 0)]); + + List characters = + store.Document.Servers.Single().Accounts.Single().Characters; + Assert.Equal(2, characters.Count); + Assert.Contains(characters, c => c.Name == "+Acdream"); + Assert.Contains(characters, c => c.Name == "+PendingDelete"); + } + + [Fact] + public void MergeThrowsForUnknownServerOrAccount() + { + LauncherProfileStore store = NewStoreWithServerAndAccount(); + + Assert.Throws( + () => store.MergeRoster( + "Nope", + "testaccount", + [new CharacterRosterEntry(1, "x", 0)])); + + Assert.Throws( + () => store.MergeRoster( + "Local ACE", + "nope", + [new CharacterRosterEntry(1, "x", 0)])); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs new file mode 100644 index 00000000..ef98becd --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusEventParserTests.cs @@ -0,0 +1,125 @@ +using AcDream.Launcher.Core.Status; + +namespace AcDream.Launcher.Core.Tests.Status; + +public sealed class StatusEventParserTests +{ + [Fact] + public void ParsesStarted() + { + var e = StatusEventParser.Parse( + """{"v":1,"e":"started","t":"2026-08-14T12:00:00Z","sessionId":"s1"}"""); + + var started = Assert.IsType(e); + Assert.Equal(1, started.V); + Assert.Equal("started", started.E); + Assert.Equal("s1", started.SessionId); + Assert.Equal( + DateTimeOffset.Parse("2026-08-14T12:00:00Z"), + started.T); + } + + [Fact] + public void ParsesConnected() + { + var e = StatusEventParser.Parse( + """{"v":1,"e":"connected","t":"2026-08-14T12:00:01Z","sessionId":"s1"}"""); + Assert.IsType(e); + } + + [Fact] + public void ParsesCharacterListWithMultipleCharacters() + { + var e = StatusEventParser.Parse( + """ + {"v":1,"e":"characterList","t":"2026-08-14T12:00:02Z","sessionId":"s1", + "accountName":"testaccount","slotCount":6, + "characters":[ + {"id":1342177290,"name":"+Acdream","secondsGreyedOut":0}, + {"id":1342177291,"name":"+Second","secondsGreyedOut":1} + ]} + """); + + var list = Assert.IsType(e); + Assert.Equal("testaccount", list.AccountName); + Assert.Equal(6, list.SlotCount); + Assert.Equal(2, list.Characters.Count); + Assert.Equal(1342177290u, list.Characters[0].Id); + Assert.Equal("+Acdream", list.Characters[0].Name); + Assert.Equal(0, list.Characters[0].SecondsGreyedOut); + Assert.Equal(1342177291u, list.Characters[1].Id); + Assert.Equal(1, list.Characters[1].SecondsGreyedOut); + } + + [Fact] + public void ParsesEnteredWorld() + { + var e = StatusEventParser.Parse( + """{"v":1,"e":"enteredWorld","t":"2026-08-14T12:00:03Z","sessionId":"s1","characterId":1342177290,"characterName":"+Acdream"}"""); + + var entered = Assert.IsType(e); + Assert.Equal(1342177290u, entered.CharacterId); + Assert.Equal("+Acdream", entered.CharacterName); + } + + [Fact] + public void ParsesPluginLoadedAndPluginFailed() + { + var loaded = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"pluginLoaded","t":"2026-08-14T12:00:04Z","sessionId":"s1","plugin":"ExamplePlugin"}""")); + Assert.Equal("ExamplePlugin", loaded.Plugin); + + var failed = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"pluginFailed","t":"2026-08-14T12:00:05Z","sessionId":"s1","plugin":"BadPlugin","error":"boom"}""")); + Assert.Equal("BadPlugin", failed.Plugin); + Assert.Equal("boom", failed.Error); + } + + [Fact] + public void ParsesDisconnectedAndExited() + { + var disconnected = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"disconnected","t":"2026-08-14T12:00:06Z","sessionId":"s1","reason":"serverClosed"}""")); + Assert.Equal("serverClosed", disconnected.Reason); + + var exited = Assert.IsType( + StatusEventParser.Parse( + """{"v":1,"e":"exited","t":"2026-08-14T12:00:07Z","sessionId":"s1","code":0,"reason":"graceful"}""")); + Assert.Equal(0, exited.Code); + Assert.Equal("graceful", exited.Reason); + } + + [Fact] + public void UnknownEValueSurfacesAsUnknownEventRatherThanThrowing() + { + var e = StatusEventParser.Parse( + """{"v":1,"e":"someFutureEvent","t":"2026-08-14T12:00:08Z","sessionId":"s1","extra":true}"""); + + var unknown = Assert.IsType(e); + Assert.Equal("someFutureEvent", unknown.E); + Assert.Equal("s1", unknown.SessionId); + Assert.Contains("someFutureEvent", unknown.RawJson); + } + + [Fact] + public void MalformedJsonSurfacesAsUnknownEventRatherThanThrowing() + { + var e = StatusEventParser.Parse("{not json"); + + Assert.IsType(e); + } + + [Fact] + public void KnownEValueWithMissingRequiredFieldSurfacesAsUnknownEventRatherThanThrowing() + { + // characterList without "characters" — a shape mismatch, not + // just an unrecognized e value. + var e = StatusEventParser.Parse( + """{"v":1,"e":"characterList","t":"2026-08-14T12:00:09Z","sessionId":"s1","accountName":"a","slotCount":6}"""); + + Assert.IsType(e); + } +} diff --git a/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs new file mode 100644 index 00000000..f908ec06 --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Status/StatusFileTailerTests.cs @@ -0,0 +1,172 @@ +using System.Text; +using AcDream.Launcher.Core.Status; + +namespace AcDream.Launcher.Core.Tests.Status; + +public sealed class StatusFileTailerTests : IDisposable +{ + private readonly string _root; + private readonly string _path; + + public StatusFileTailerTests() + { + _root = Path.Combine( + Path.GetTempPath(), + "acdream-launcher-tailer-tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + _path = Path.Combine(_root, "status.jsonl"); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + [Fact] + public void ReturnsNoEventsWhenTheFileDoesNotExistYet() + { + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Empty(events); + } + + [Fact] + public void ReturnsNoEventsWhenNothingHasBeenAppendedSinceTheLastPoll() + { + AppendShared(Line("started", "s1")); + var tailer = new StatusFileTailer(_path); + Assert.Single(tailer.ReadNewEvents()); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Empty(events); + } + + [Fact] + public void ReadsMultipleCompleteLinesInOnePoll() + { + AppendShared(Line("started", "s1") + Line("connected", "s1")); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Equal(2, events.Count); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + } + + [Fact] + public void TolerateAPartialLastLineAndCompletesItOnALaterPoll() + { + string full = Line("started", "s1"); + int splitAt = full.Length - 10; // cut mid-object, before the closing brace/newline + AppendShared(full[..splitAt]); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList firstPoll = tailer.ReadNewEvents(); + Assert.Empty(firstPoll); + + AppendShared(full[splitAt..]); + IReadOnlyList secondPoll = tailer.ReadNewEvents(); + + StatusEvent onlyEvent = Assert.Single(secondPoll); + Assert.IsType(onlyEvent); + } + + [Fact] + public void APartialLineFollowedByAFullLineOnlyEmitsTheCompleteOne() + { + AppendShared(Line("started", "s1")); + string partial = """{"v":1,"e":"connected","t":"2026-08-14T12:00:00Z","sessionId":"s1"""; // no closing + AppendShared(partial); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + StatusEvent onlyEvent = Assert.Single(events); + Assert.IsType(onlyEvent); + + // Completing the second line on a later poll produces exactly + // one more event, proving the partial bytes were retained (not + // dropped and not double-counted). + AppendShared("\"}\n"); + IReadOnlyList secondPoll = tailer.ReadNewEvents(); + StatusEvent completed = Assert.Single(secondPoll); + Assert.IsType(completed); + } + + [Fact] + public void SkipsBlankLines() + { + AppendShared("\n" + Line("started", "s1") + "\n" + Line("connected", "s1")); + var tailer = new StatusFileTailer(_path); + + IReadOnlyList events = tailer.ReadNewEvents(); + + Assert.Equal(2, events.Count); + } + + [Fact] + public void ReadsWithAWriterHoldingTheFileOpenForAppend() + { + // Share-tolerant reads: the writer's handle stays open the whole + // time (FileShare.ReadWrite on both sides), matching a live host + // process appending status.jsonl while the launcher tails it. + using var writer = new FileStream( + _path, + FileMode.Create, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + var tailer = new StatusFileTailer(_path); + + byte[] first = Encoding.UTF8.GetBytes(Line("started", "s1")); + writer.Write(first, 0, first.Length); + writer.Flush(); + + IReadOnlyList firstPoll = tailer.ReadNewEvents(); + Assert.Single(firstPoll); + + byte[] second = Encoding.UTF8.GetBytes(Line("connected", "s1")); + writer.Write(second, 0, second.Length); + writer.Flush(); + + IReadOnlyList secondPoll = tailer.ReadNewEvents(); + Assert.Single(secondPoll); + Assert.IsType(secondPoll[0]); + } + + [Fact] + public void RestartsFromTheTopWhenTheFileIsTruncatedOrReplaced() + { + AppendShared(Line("started", "s1") + Line("connected", "s1")); + var tailer = new StatusFileTailer(_path); + Assert.Equal(2, tailer.ReadNewEvents().Count); + + File.Delete(_path); + AppendShared(Line("started", "s2")); + + IReadOnlyList events = tailer.ReadNewEvents(); + StatusEvent onlyEvent = Assert.Single(events); + Assert.Equal("s2", onlyEvent.SessionId); + } + + private static string Line(string e, string sessionId) => + $$"""{"v":1,"e":"{{e}}","t":"2026-08-14T12:00:00Z","sessionId":"{{sessionId}}"}""" + "\n"; + + private void AppendShared(string text) + { + using var stream = new FileStream( + _path, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + byte[] bytes = Encoding.UTF8.GetBytes(text); + stream.Write(bytes, 0, bytes.Length); + } +}