docs: Campaign LA — pinned launch-contract schema COMMITTED into plan LA1
The LA3 Opus review process note was right: the contract both sides implement lived only in orchestrator prompts, which is exactly the drift mode the pin exists to prevent (and it produced the paths-key CRITICAL). The schema, field rules, probe-mode discriminator, and status vocabulary are now a binding plan section; amendments change this text first, implementations second. Ledger: LA3 fix round dispatched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0bcc7ba3a3
commit
db9ad53c1c
38 changed files with 2397 additions and 40 deletions
|
|
@ -108,6 +108,69 @@ referencing only `AcDream.Platform`.
|
|||
|
||||
## LA1 — launch contract (client side)
|
||||
|
||||
### Pinned launch-contract schema (v1, BINDING — committed per LA3 review)
|
||||
|
||||
This text is the single source of truth for the launcher↔host file
|
||||
contract. Both host readers (LA1), the composer (LA3), and the probe
|
||||
loader (LA2) implement EXACTLY this; any change is an amendment to THIS
|
||||
section first, implementations second. The LA1+LA3 merge adds a
|
||||
cross-assembly test feeding a composer-produced document to both host
|
||||
loaders — that test is the seam's permanent enforcement.
|
||||
|
||||
Session-config document (System.Text.Json, camelCase,
|
||||
`UnmappedMemberHandling.Disallow`, camelCase string enums):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"process": {
|
||||
"content": { "datDirectory": "...", "preparedAssetPath": "..." }
|
||||
},
|
||||
"sessions": [{
|
||||
"id": "sess-1",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "testaccount",
|
||||
"mode": "probe",
|
||||
"character": { "id": 1342177290 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "standardInput", "reference": "session" },
|
||||
"plugins": ["ExamplePlugin"],
|
||||
"loginCommands": ["/vt start"],
|
||||
"loginCommandDelayMs": 500,
|
||||
"statusFile": ".../launcher/sessions/sess-1/status.jsonl"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
- `process.paths` is OMITTED unless a caller genuinely supplies overrides
|
||||
(never an empty object — the App reader has no `paths` member and
|
||||
strict parsing rejects unknown keys; LA3 review finding 1).
|
||||
- `mode`: ABSENT for normal play sessions; `"probe"` for the LA2 probe
|
||||
(connect → characterList → graceful disconnect, no EnterWorld). The
|
||||
headless loader accepts the field starting at LA2.
|
||||
- `character`: exactly ONE of index|id|name; OMITTED entirely (not null)
|
||||
for guiSelect and for probe sessions.
|
||||
- `policy`: `{ "id": "idle" }` for headless play sessions ONLY; omitted
|
||||
for gui/guiSelect/probe.
|
||||
- `credential`: always `{ "provider": "standardInput", "reference":
|
||||
"session" }` for launcher-composed configs.
|
||||
- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
|
||||
omitted-when-unset (never null, never `[]` for empty). Absent
|
||||
`loginCommandDelayMs` means 500.
|
||||
|
||||
Status stream (`statusFile`, one JSON object per line, writer flushes per
|
||||
line, writer opens `FileShare.Read`, tailer opens
|
||||
`Read/FileShare.ReadWrite|Delete`): events `started`, `connected`,
|
||||
`characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}`,
|
||||
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
|
||||
`pluginFailed{plugin,error}`, `disconnected{reason}`,
|
||||
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
|
||||
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
|
||||
sides. Unknown `e` values must parse to a typed Unknown event, never
|
||||
throw; a known `e` with a wrong payload shape should be distinguishable
|
||||
from an unknown `e` (LA3 review finding 12).
|
||||
|
||||
Three pieces, one slice, because they share the session-config/status seam:
|
||||
|
||||
1. **App `--session-config <path>`:** parsed once in `Program.cs` into
|
||||
|
|
@ -416,7 +479,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|
|||
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
|
||||
| LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields |
|
||||
| LA2 | — | | | |
|
||||
| LA3 | implemented (`37d74e44`, campaign-la3); Opus review in flight | `37d74e44` | review in flight | 71/71 Windows AND 71/71 WSL (0600 test real on Linux); reviewer checking contract token-fidelity + tailer fix |
|
||||
| LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge |
|
||||
| LA4 | — | | | |
|
||||
| LA5 | — | | | |
|
||||
| LA6 | — | | | |
|
||||
|
|
|
|||
|
|
@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies(
|
|||
CombatAttackOperationsSlot CombatAttackOperations,
|
||||
CombatFeedbackSlot CombatFeedback,
|
||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||
Action<string> Log)
|
||||
Action<string> Log,
|
||||
/// <summary>Campaign LA slice LA1: the shared per-session status-event
|
||||
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
|
||||
/// not configured.</summary>
|
||||
SessionStatusWriter StatusWriter)
|
||||
{
|
||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||
|
||||
|
|
@ -1124,7 +1128,9 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
acceptedPositionDrive,
|
||||
remotePlacementDrive),
|
||||
liveSessionCommands,
|
||||
d.Log);
|
||||
d.Log,
|
||||
d.StatusWriter,
|
||||
d.Options.SessionId ?? "app");
|
||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
||||
liveSession,
|
||||
new LiveSessionConnectOptions(
|
||||
|
|
@ -1132,7 +1138,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.Options.LiveHost,
|
||||
d.Options.LivePort,
|
||||
d.Options.LiveUser ?? string.Empty,
|
||||
d.Options.LivePass ?? string.Empty));
|
||||
d.Options.LivePass ?? string.Empty,
|
||||
d.Options.LiveCharacterSelector));
|
||||
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
||||
|
||||
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Composition;
|
||||
|
||||
internal sealed record SessionStartDependencies(
|
||||
Action<string> Log);
|
||||
Action<string> Log,
|
||||
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
|
||||
/// configured.</summary>
|
||||
SessionStatusWriter StatusWriter,
|
||||
string SessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Terminal startup phase. Every callback, command target, and frame root is
|
||||
|
|
@ -21,6 +26,9 @@ internal sealed class SessionStartCompositionPhase
|
|||
public void Start(FrameRootResult frame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
// Campaign LA slice LA1: "started" = session host start — the
|
||||
// earliest point the graphical host actually attempts to connect.
|
||||
_dependencies.StatusWriter.Started(_dependencies.SessionId);
|
||||
RuntimeSessionStartResult result =
|
||||
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
|
||||
Report(result, _dependencies.Log);
|
||||
|
|
|
|||
142
src/AcDream.App/Configuration/SessionConfiguration.cs
Normal file
142
src/AcDream.App/Configuration/SessionConfiguration.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: the graphical host's reader for the pinned
|
||||
/// session-config document shape shared with
|
||||
/// <c>AcDream.Headless.Configuration.HeadlessConfiguration</c> — see
|
||||
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 and
|
||||
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6.
|
||||
///
|
||||
/// <para>
|
||||
/// This is a DELIBERATELY independent DTO set, not a shared type reused from
|
||||
/// <c>AcDream.Headless</c> — Headless's config types are internal, tied to
|
||||
/// its own OP7 <c>characterOptions</c> allow-list semantics, and Headless is
|
||||
/// not a project App references. The two readers are cross-checked instead
|
||||
/// by a shared fixture document both test suites parse
|
||||
/// (<c>SessionConfigurationSharedFixtureTests</c> /
|
||||
/// <c>HeadlessConfigurationSharedFixtureTests</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Differences from the Headless reader, all intentional per the pinned
|
||||
/// contract: <see cref="SessionDescriptor.Character"/> is OPTIONAL here
|
||||
/// (absent = today's first-available fallback; the character-select screen
|
||||
/// is LA7, not this slice); <see cref="SessionDescriptor.Policy"/> is parsed
|
||||
/// but never consulted (App has no bot-policy concept); exactly ONE session
|
||||
/// is required, not "one or more".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class SessionConfiguration
|
||||
{
|
||||
[JsonRequired]
|
||||
public int Version { get; init; }
|
||||
|
||||
public SessionProcessSettings? Process { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public List<SessionDescriptor?> Sessions { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class SessionProcessSettings
|
||||
{
|
||||
public SessionContentDescriptor? Content { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class SessionContentDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public string DatDirectory { get; init; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string PreparedAssetPath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record SessionDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public SessionEndpointDescriptor Endpoint { get; init; } = new();
|
||||
|
||||
[JsonRequired]
|
||||
public string Account { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Optional for the graphical host: absent means today's
|
||||
/// existing first-available fallback stays in effect. The retail
|
||||
/// character-select screen (LA7) is what actually consumes "no
|
||||
/// selector" as "stop and let the user pick".</summary>
|
||||
public SessionCharacterSelectorDescriptor? Character { get; init; }
|
||||
|
||||
/// <summary>Accepted so the SAME document also satisfies the Headless
|
||||
/// loader's <c>JsonRequired</c> policy field — parsed and ignored here;
|
||||
/// App has no bot-policy concept.</summary>
|
||||
public SessionPolicyDescriptor? Policy { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public SessionCredentialDescriptor Credential { get; init; } = new();
|
||||
|
||||
/// <summary>Accepted-but-ignored by App; Headless's own loader owns the
|
||||
/// allow-list semantics for this field (OP7 D8).</summary>
|
||||
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
||||
|
||||
/// <summary>LA1: plugin ids to load. Absent = load all (LA5 consumes
|
||||
/// this; parsed and carried here now per the pinned launch contract).</summary>
|
||||
public List<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>LA1: ordered chat-typed strings run after entering world
|
||||
/// (LA6 consumes this; parsed and carried here now).</summary>
|
||||
public List<string>? LoginCommands { get; init; }
|
||||
|
||||
/// <summary>LA1: inter-command delay for <see cref="LoginCommands"/>,
|
||||
/// milliseconds. Matches the pinned contract default of 500 ms.</summary>
|
||||
public int LoginCommandDelayMs { get; init; } = 500;
|
||||
|
||||
/// <summary>LA1: absolute path for the status-event JSONL stream.
|
||||
/// Absent = no writer constructed.</summary>
|
||||
public string? StatusFile { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class SessionEndpointDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public string Host { get; init; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public int Port { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class SessionCharacterSelectorDescriptor
|
||||
{
|
||||
public int? Index { get; init; }
|
||||
public uint? Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Loose by design: App never inspects the policy's shape beyond
|
||||
/// "does this document parse" — <c>Id</c>/<c>Role</c> stay untyped strings so
|
||||
/// this DTO never has to track Headless's own policy-id/role vocabulary.</summary>
|
||||
internal sealed class SessionPolicyDescriptor
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Role { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<SessionCredentialProviderKind>))]
|
||||
internal enum SessionCredentialProviderKind
|
||||
{
|
||||
Environment,
|
||||
StandardInput,
|
||||
File,
|
||||
}
|
||||
|
||||
internal sealed class SessionCredentialDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public SessionCredentialProviderKind Provider { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public string Reference { get; init; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
namespace AcDream.App.Configuration;
|
||||
|
||||
/// <summary>Mirrors <c>AcDream.Headless.Configuration.HeadlessConfigurationException</c>
|
||||
/// — a semantic validation failure of an already well-typed session-config
|
||||
/// document (a type-SHAPE violation fails earlier, as a raw
|
||||
/// <see cref="System.Text.Json.JsonException"/> during deserialization).</summary>
|
||||
internal sealed class SessionConfigurationException : Exception
|
||||
{
|
||||
internal SessionConfigurationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
internal SessionConfigurationException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
158
src/AcDream.App/Configuration/SessionConfigurationLoader.cs
Normal file
158
src/AcDream.App/Configuration/SessionConfigurationLoader.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: loads and validates the <c>--session-config</c>
|
||||
/// document for the graphical host. Same strictness as
|
||||
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c>
|
||||
/// (camelCase, <see cref="JsonUnmappedMemberHandling.Disallow"/>, camelCase
|
||||
/// string enums) — see that type's own doc for why the two readers are
|
||||
/// independent DTOs rather than a shared type.
|
||||
/// </summary>
|
||||
internal static class SessionConfigurationLoader
|
||||
{
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(
|
||||
JsonNamingPolicy.CamelCase,
|
||||
allowIntegerValues: false),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Loads the document and returns the exact one configured
|
||||
/// <see cref="SessionDescriptor"/> the graphical host runs — the
|
||||
/// document itself may only ever declare exactly one session.</summary>
|
||||
internal static (SessionConfiguration Configuration, SessionDescriptor Session) Load(
|
||||
string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
using FileStream stream = File.OpenRead(fullPath);
|
||||
SessionConfiguration? configuration =
|
||||
JsonSerializer.Deserialize<SessionConfiguration>(stream, Options);
|
||||
|
||||
if (configuration is null)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"The configuration document is empty.");
|
||||
}
|
||||
|
||||
if (configuration.Version != CurrentVersion)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Unsupported configuration version {configuration.Version}; "
|
||||
+ $"expected {CurrentVersion}.");
|
||||
}
|
||||
|
||||
if (configuration.Sessions is null
|
||||
|| configuration.Sessions.Count != 1)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"The graphical host requires exactly one configured session.");
|
||||
}
|
||||
|
||||
SessionDescriptor session = configuration.Sessions[0]
|
||||
?? throw new SessionConfigurationException(
|
||||
"The configured session cannot be null.");
|
||||
|
||||
ValidateContent(configuration.Process?.Content);
|
||||
ValidateSession(session);
|
||||
|
||||
return (configuration, session);
|
||||
}
|
||||
|
||||
private static void ValidateContent(SessionContentDescriptor? content)
|
||||
{
|
||||
if (content is null)
|
||||
return;
|
||||
if (string.IsNullOrWhiteSpace(content.DatDirectory)
|
||||
|| string.IsNullOrWhiteSpace(content.PreparedAssetPath))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"process.content requires non-empty datDirectory and preparedAssetPath.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSession(SessionDescriptor session)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(session.Id))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"The session requires a non-empty id.");
|
||||
}
|
||||
|
||||
if (session.Endpoint is null
|
||||
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|
||||
|| session.Endpoint.Port is < 1 or > 65535)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' requires a host and a port from 1 through 65535.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(session.Account))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' requires a non-empty account.");
|
||||
}
|
||||
|
||||
if (session.Character is { } selector)
|
||||
{
|
||||
int selectorCount =
|
||||
(selector.Index.HasValue ? 1 : 0)
|
||||
+ (selector.Id.HasValue ? 1 : 0)
|
||||
+ (!string.IsNullOrWhiteSpace(selector.Name) ? 1 : 0);
|
||||
if (selectorCount != 1
|
||||
|| selector.Index is < 0
|
||||
|| selector.Id == 0u)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' character selector must specify "
|
||||
+ "exactly one valid index, id, or name.");
|
||||
}
|
||||
}
|
||||
|
||||
if (session.Credential is null
|
||||
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' requires a credential reference.");
|
||||
}
|
||||
|
||||
if (session.Plugins is { } plugins)
|
||||
{
|
||||
foreach (string? plugin in plugins)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plugin))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' plugins entries must be non-empty strings.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (session.LoginCommandDelayMs < 0)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
|
||||
}
|
||||
|
||||
if (session.StatusFile is not null
|
||||
&& string.IsNullOrWhiteSpace(session.StatusFile))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/AcDream.App/Credentials/AppCredentialResolver.cs
Normal file
155
src/AcDream.App/Credentials/AppCredentialResolver.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Platform;
|
||||
|
||||
namespace AcDream.App.Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: resolves a <c>--session-config</c> session's
|
||||
/// credential reference — the App-side mirror of
|
||||
/// <c>AcDream.Headless.Credentials.HeadlessCredentialResolver</c> (see that
|
||||
/// type's own file for why this is an independent port rather than a shared
|
||||
/// reference). Supports the same three providers with the same semantics:
|
||||
/// <c>environment</c> (read an env var), <c>standardInput</c> (read one line
|
||||
/// from stdin, mirroring <c>HeadlessCredentialResolver.ResolveStandardInput</c>),
|
||||
/// and <c>file</c> (read a credential file relative to a base directory,
|
||||
/// rejecting symlinks and, on Linux, group/other-readable permissions).
|
||||
/// </summary>
|
||||
internal sealed class AppCredentialResolver
|
||||
{
|
||||
private const UnixFileMode NonUserPermissionMask =
|
||||
UnixFileMode.GroupRead
|
||||
| UnixFileMode.GroupWrite
|
||||
| UnixFileMode.GroupExecute
|
||||
| UnixFileMode.OtherRead
|
||||
| UnixFileMode.OtherWrite
|
||||
| UnixFileMode.OtherExecute;
|
||||
|
||||
private readonly TextReader _standardInput;
|
||||
private readonly string _credentialBaseDirectory;
|
||||
private readonly bool _isLinux;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="isLinux"/> is caller-supplied, never detected in this
|
||||
/// file — <c>LinuxPlatformBoundaryTests</c>'s platform-owner guard
|
||||
/// requires every OS-family check to live under <c>Platform/</c>;
|
||||
/// callers pass <c>GraphicalHostPlatformServices</c>'s already-detected
|
||||
/// value instead of this file re-detecting it itself.
|
||||
/// </summary>
|
||||
internal AppCredentialResolver(
|
||||
TextReader standardInput,
|
||||
string credentialBaseDirectory,
|
||||
bool isLinux)
|
||||
{
|
||||
_standardInput = standardInput
|
||||
?? throw new ArgumentNullException(nameof(standardInput));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(credentialBaseDirectory);
|
||||
_credentialBaseDirectory = Path.GetFullPath(credentialBaseDirectory);
|
||||
_isLinux = isLinux;
|
||||
}
|
||||
|
||||
internal AppCredentialSecret Resolve(
|
||||
string sessionId,
|
||||
SessionCredentialDescriptor credential)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
|
||||
string value;
|
||||
try
|
||||
{
|
||||
value = credential.Provider switch
|
||||
{
|
||||
SessionCredentialProviderKind.Environment =>
|
||||
ResolveEnvironment(credential.Reference),
|
||||
SessionCredentialProviderKind.StandardInput =>
|
||||
ResolveStandardInput(credential.Reference),
|
||||
SessionCredentialProviderKind.File =>
|
||||
ResolveFile(credential.Reference),
|
||||
_ => throw new AppCredentialException(
|
||||
$"Session '{sessionId}' uses an unsupported credential provider."),
|
||||
};
|
||||
}
|
||||
catch (AppCredentialException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
when (error is IOException
|
||||
or UnauthorizedAccessException
|
||||
or ArgumentException
|
||||
or NotSupportedException)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential '{credential.Reference}' for session '{sessionId}' could not be resolved.",
|
||||
error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new AppCredentialSecret(credential.Reference, value.AsSpan());
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The BCL returns immutable strings from environment, TextReader,
|
||||
// and File APIs. Do not retain another copy in the resolver; the
|
||||
// erasable char[] owner becomes the sole explicit retained copy.
|
||||
value = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveEnvironment(string reference)
|
||||
{
|
||||
string? value = Environment.GetEnvironmentVariable(reference);
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential environment reference '{reference}' is unavailable.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private string ResolveStandardInput(string reference)
|
||||
{
|
||||
string? value = _standardInput.ReadLine();
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential standard-input reference '{reference}' is unavailable.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private string ResolveFile(string reference)
|
||||
{
|
||||
string path = Path.GetFullPath(reference, _credentialBaseDirectory);
|
||||
var file = new FileInfo(path);
|
||||
if (file.LinkTarget is not null)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential file reference '{reference}' cannot be a symbolic link.");
|
||||
}
|
||||
|
||||
// RuntimePlatformGuard.IsLinuxRuntime is the CA1416-recognized guard
|
||||
// for File.GetUnixFileMode below; _isLinux is the separate,
|
||||
// caller-injected value tests use for deterministic cross-platform
|
||||
// coverage (see the constructor's own doc).
|
||||
if (RuntimePlatformGuard.IsLinuxRuntime && _isLinux)
|
||||
{
|
||||
UnixFileMode mode = File.GetUnixFileMode(path);
|
||||
if ((mode & NonUserPermissionMask) != 0
|
||||
|| (mode & UnixFileMode.UserRead) == 0)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential file reference '{reference}' must be readable only by its owner.");
|
||||
}
|
||||
}
|
||||
|
||||
string value = File.ReadAllText(path).TrimEnd('\r', '\n');
|
||||
if (value.Length == 0)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential file reference '{reference}' is empty.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
65
src/AcDream.App/Credentials/AppCredentialSecret.cs
Normal file
65
src/AcDream.App/Credentials/AppCredentialSecret.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System.Security.Cryptography;
|
||||
|
||||
namespace AcDream.App.Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: retains a resolved <c>--session-config</c>
|
||||
/// credential in erasable memory — the App-side mirror of
|
||||
/// <c>AcDream.Headless.Credentials.HeadlessCredentialSecret</c> (that type is
|
||||
/// internal to the Headless project, so this is a minimal, independent port
|
||||
/// rather than a shared reference). The network boundary still requires one
|
||||
/// short-lived immutable string; callers must not retain that value beyond
|
||||
/// constructing the connect request.
|
||||
/// </summary>
|
||||
internal sealed class AppCredentialSecret : IDisposable
|
||||
{
|
||||
private char[]? _buffer;
|
||||
|
||||
internal AppCredentialSecret(string referenceId, ReadOnlySpan<char> value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(referenceId);
|
||||
if (value.IsEmpty)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential '{referenceId}' resolved to an empty secret.");
|
||||
}
|
||||
|
||||
ReferenceId = referenceId;
|
||||
_buffer = value.ToArray();
|
||||
}
|
||||
|
||||
internal string ReferenceId { get; }
|
||||
internal bool IsDisposed => _buffer is null;
|
||||
|
||||
internal string Reveal()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_buffer is null, this);
|
||||
return new string(_buffer);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
char[]? buffer = Interlocked.Exchange(ref _buffer, null);
|
||||
if (buffer is null)
|
||||
return;
|
||||
CryptographicOperations.ZeroMemory(
|
||||
System.Runtime.InteropServices.MemoryMarshal.AsBytes(
|
||||
buffer.AsSpan()));
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"[redacted:{ReferenceId}]";
|
||||
}
|
||||
|
||||
internal sealed class AppCredentialException : Exception
|
||||
{
|
||||
internal AppCredentialException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
internal AppCredentialException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +104,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private readonly LiveSessionCommandSurface _commands;
|
||||
private readonly Action<string> _log;
|
||||
private readonly LiveMovementStatsApplier _movementStats;
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
private readonly string _sessionId;
|
||||
|
||||
public LiveSessionRuntimeFactory(
|
||||
LiveSessionPlayerRuntime player,
|
||||
|
|
@ -112,7 +114,9 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
LiveSessionInteractionRuntime interaction,
|
||||
LiveSessionWorldRuntime world,
|
||||
LiveSessionCommandSurface commands,
|
||||
Action<string> log)
|
||||
Action<string> log,
|
||||
SessionStatusWriter? statusWriter = null,
|
||||
string sessionId = "app")
|
||||
{
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
||||
|
|
@ -122,6 +126,10 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
// Campaign LA slice LA1: a no-op instance when the caller has no
|
||||
// status file configured — every call site below stays unconditional.
|
||||
_statusWriter = statusWriter ?? new SessionStatusWriter(null);
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
||||
// typed application seam; App keeps zero direct controller mutations.
|
||||
_movementStats = new LiveMovementStatsApplier(
|
||||
|
|
@ -176,9 +184,17 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
$"connecting to {host}:{port} as {user}",
|
||||
chatType: 1),
|
||||
Connected: () =>
|
||||
{
|
||||
_domain.Communication.Chat.OnSystemMessage(
|
||||
"connected — character list received",
|
||||
chatType: 1)),
|
||||
chatType: 1);
|
||||
_statusWriter.Connected(_sessionId);
|
||||
},
|
||||
Roster: roster => _statusWriter.CharacterList(_sessionId, roster),
|
||||
CharacterEntered: selection => _statusWriter.EnteredWorld(
|
||||
_sessionId,
|
||||
selection.CharacterId,
|
||||
selection.CharacterName)),
|
||||
connectOptions);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Platform;
|
||||
|
||||
|
|
@ -10,6 +11,21 @@ internal enum GraphicalHostOperatingSystem
|
|||
Linux,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: a <c>[SupportedOSPlatformGuard]</c>-annotated
|
||||
/// runtime-OS check, for code OUTSIDE <c>Platform/</c> that needs a
|
||||
/// CA1416-recognized guard around a Linux-only API (e.g.
|
||||
/// <c>AppCredentialResolver</c>'s <c>File.GetUnixFileMode</c> call) without
|
||||
/// re-detecting the OS itself — <c>LinuxPlatformBoundaryTests
|
||||
/// .OperatingSystemChecksRemainInsidePlatformOwners</c> requires every such
|
||||
/// check to live under this folder.
|
||||
/// </summary>
|
||||
internal static class RuntimePlatformGuard
|
||||
{
|
||||
[SupportedOSPlatformGuard("linux")]
|
||||
internal static bool IsLinuxRuntime => System.OperatingSystem.IsLinux();
|
||||
}
|
||||
|
||||
internal sealed record GraphicalNativeDependency(
|
||||
string Feature,
|
||||
string PublishedFileName);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using AcDream.App;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Credentials;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Platform;
|
||||
using AcDream.App.Rendering;
|
||||
|
|
@ -32,17 +34,96 @@ Log.Information(
|
|||
dependency =>
|
||||
$"{dependency.Feature}={dependency.PublishedFileName}")));
|
||||
|
||||
var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (string.IsNullOrWhiteSpace(datDir))
|
||||
{
|
||||
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
|
||||
return 2;
|
||||
}
|
||||
// Campaign LA slice LA1: --session-config <path> is purely additive — the
|
||||
// existing one positional dat-dir argument and every ACDREAM_* env var keep
|
||||
// working exactly as before when the flag is absent. See
|
||||
// docs/plans/2026-08-14-launcher-campaign.md LA1.
|
||||
string? sessionConfigFlagPath = ExtractFlagValue(args, "--session-config");
|
||||
string[] positionalArgs = WithoutFlagAndValue(args, "--session-config");
|
||||
|
||||
var datDirArg = positionalArgs.FirstOrDefault();
|
||||
var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
|
||||
// Single read of the startup-time process environment. Every downstream
|
||||
// consumer (GameWindow + collaborators) reads the typed bundle, not the
|
||||
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
|
||||
var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||
RuntimeOptions runtimeOptions;
|
||||
if (sessionConfigFlagPath is not null)
|
||||
{
|
||||
SessionConfiguration sessionConfig;
|
||||
SessionDescriptor session;
|
||||
try
|
||||
{
|
||||
(sessionConfig, session) = SessionConfigurationLoader.Load(sessionConfigFlagPath);
|
||||
}
|
||||
catch (Exception error)
|
||||
when (error is IOException
|
||||
or UnauthorizedAccessException
|
||||
or ArgumentException
|
||||
or NotSupportedException
|
||||
or System.Text.Json.JsonException
|
||||
or SessionConfigurationException)
|
||||
{
|
||||
Log.Error("--session-config invalid: {Error}", error.Message);
|
||||
return 2;
|
||||
}
|
||||
|
||||
string? resolvedDatDir =
|
||||
NullIfEmpty(sessionConfig.Process?.Content?.DatDirectory)
|
||||
?? NullIfEmpty(datDirArg)
|
||||
?? NullIfEmpty(envDatDir);
|
||||
if (resolvedDatDir is null)
|
||||
{
|
||||
Log.Error(
|
||||
"usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR, "
|
||||
+ "or supply process.content.datDirectory in --session-config)");
|
||||
return 2;
|
||||
}
|
||||
|
||||
AppCredentialSecret? secret = null;
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
Console.In,
|
||||
applicationPaths.ConfigDirectory,
|
||||
graphicalPlatform.OperatingSystem
|
||||
== GraphicalHostOperatingSystem.Linux);
|
||||
secret = resolver.Resolve(session.Id, session.Credential);
|
||||
runtimeOptions = RuntimeOptions.FromSessionConfig(
|
||||
resolvedDatDir,
|
||||
Environment.GetEnvironmentVariable,
|
||||
sessionConfigFlagPath,
|
||||
sessionConfig,
|
||||
session,
|
||||
secret.Reveal());
|
||||
}
|
||||
catch (AppCredentialException error)
|
||||
{
|
||||
Log.Error("--session-config credential unavailable: {Error}", error.Message);
|
||||
return 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
secret?.Dispose();
|
||||
}
|
||||
|
||||
// Env-var flow untouched when the flag is absent; when both are present
|
||||
// the flag wins — this line makes that explicit rather than silent.
|
||||
Log.Information(
|
||||
"--session-config {Path} present; overriding ACDREAM_LIVE*/ACDREAM_TEST_* "
|
||||
+ "env-var live-session settings",
|
||||
sessionConfigFlagPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
var datDir = datDirArg ?? envDatDir;
|
||||
if (string.IsNullOrWhiteSpace(datDir))
|
||||
{
|
||||
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
|
||||
return 2;
|
||||
}
|
||||
runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||
}
|
||||
|
||||
if (runtimeOptions.DevTools)
|
||||
{
|
||||
|
|
@ -158,3 +239,35 @@ finally
|
|||
}
|
||||
|
||||
return 0;
|
||||
|
||||
// Campaign LA slice LA1: --session-config <path> parsing helpers. Kept
|
||||
// local/minimal rather than a general-purpose CLI parser — App has exactly
|
||||
// one optional flag-with-value today; the positional dat-dir argument must
|
||||
// stay untouched by its presence (see the comment above the flag parse).
|
||||
static string? ExtractFlagValue(string[] arguments, string flag)
|
||||
{
|
||||
for (int i = 0; i < arguments.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
||||
return arguments[i + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static string[] WithoutFlagAndValue(string[] arguments, string flag)
|
||||
{
|
||||
var result = new List<string>(arguments.Length);
|
||||
for (int i = 0; i < arguments.Length; i++)
|
||||
{
|
||||
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
||||
{
|
||||
i++; // also skip the flag's value
|
||||
continue;
|
||||
}
|
||||
result.Add(arguments[i]);
|
||||
}
|
||||
return [.. result];
|
||||
}
|
||||
|
||||
static string? NullIfEmpty(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ public sealed class GameWindow :
|
|||
/ (double)System.Diagnostics.Stopwatch.Frequency;
|
||||
|
||||
private readonly AcDream.App.RuntimeOptions _options;
|
||||
// Campaign LA slice LA1: no-op instance when --session-config didn't
|
||||
// configure a statusFile (or the env-var launch path was used at all).
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
private readonly AnimationPresentationDiagnostics _animationDiagnostics;
|
||||
private readonly string _datDir;
|
||||
private readonly WorldGameState _worldGameState;
|
||||
|
|
@ -615,6 +618,7 @@ public sealed class GameWindow :
|
|||
GraphicalHostPlatformServices platformServices)
|
||||
{
|
||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||
_statusWriter = new SessionStatusWriter(options.StatusFilePath);
|
||||
_platformServices = platformServices
|
||||
?? throw new ArgumentNullException(nameof(platformServices));
|
||||
_applicationPaths = _platformServices.Paths;
|
||||
|
|
@ -1489,7 +1493,8 @@ public sealed class GameWindow :
|
|||
_combatAttackOperations,
|
||||
_combatFeedback,
|
||||
_portalTunnelFallback,
|
||||
Console.WriteLine),
|
||||
Console.WriteLine,
|
||||
_statusWriter),
|
||||
this).Compose(
|
||||
hostInputCamera,
|
||||
contentEffectsAudio,
|
||||
|
|
@ -1548,7 +1553,10 @@ public sealed class GameWindow :
|
|||
livePresentation,
|
||||
sessionPlayer),
|
||||
frameRoots => new SessionStartCompositionPhase(
|
||||
new SessionStartDependencies(Console.WriteLine))
|
||||
new SessionStartDependencies(
|
||||
Console.WriteLine,
|
||||
_statusWriter,
|
||||
_options.SessionId ?? "app"))
|
||||
.Start(frameRoots));
|
||||
}
|
||||
|
||||
|
|
@ -1636,13 +1644,30 @@ public sealed class GameWindow :
|
|||
private void CompleteShutdown(bool releaseNativeWindow)
|
||||
{
|
||||
if (!_lifetime.HasShutdownRoots)
|
||||
{
|
||||
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
|
||||
// by the time teardown completes, IsInWorld is always false
|
||||
// regardless of whether a real session was ever connected.
|
||||
// OnClosing() and Dispose() both funnel through this method;
|
||||
// HasShutdownRoots's own guard means this fires exactly once,
|
||||
// from whichever of the two reaches it first.
|
||||
if (_runtime.Session.IsInWorld)
|
||||
_statusWriter.Disconnected(_options.SessionId ?? "app", "stopped");
|
||||
_lifetime.PublishShutdownRoots(CaptureShutdownRoots());
|
||||
}
|
||||
|
||||
GameWindowLifetimeReport report = releaseNativeWindow
|
||||
? _lifetime.CompleteAndReleaseNativeWindow()
|
||||
: _lifetime.TryComplete();
|
||||
if (report.Status == GameWindowLifetimeStatus.Complete)
|
||||
{
|
||||
// "exited" = terminal — only the true Dispose() call (not the
|
||||
// OnClosing() native-window-close-request pass) represents the
|
||||
// process actually being done.
|
||||
if (releaseNativeWindow)
|
||||
_statusWriter.Exited(_options.SessionId ?? "app", 0, "disposed");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
|
||||
|
|
@ -1655,6 +1680,14 @@ public sealed class GameWindow :
|
|||
|
||||
if (report.Error is not null)
|
||||
Console.Error.WriteLine($"[shutdown] {report.Error}");
|
||||
|
||||
if (releaseNativeWindow)
|
||||
{
|
||||
_statusWriter.Exited(
|
||||
_options.SessionId ?? "app",
|
||||
1,
|
||||
"shutdown-incomplete");
|
||||
}
|
||||
}
|
||||
|
||||
private GameWindowShutdownRoots CaptureShutdownRoots() => new(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Rendering.Residency;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App;
|
||||
|
||||
|
|
@ -62,7 +65,34 @@ public sealed record RuntimeOptions(
|
|||
string? VulkanDeviceOverride,
|
||||
string? VulkanForcedUnsupportedFeature,
|
||||
bool VulkanCapabilityProbe,
|
||||
int VulkanCapabilityProbeFrames)
|
||||
int VulkanCapabilityProbeFrames,
|
||||
/// <summary>Campaign LA slice LA1: the raw <c>--session-config</c> path,
|
||||
/// or <see langword="null"/> when the flag was not supplied (the env-var
|
||||
/// dev flow). Kept for diagnostics/logging only.</summary>
|
||||
string? SessionConfigPath,
|
||||
/// <summary>Campaign LA slice LA1: the configured session's id, used as
|
||||
/// the <c>sessionId</c> field on every status-stream event. Defaults to
|
||||
/// <c>"app"</c> at every call site when unset (env-var flow).</summary>
|
||||
string? SessionId,
|
||||
/// <summary>Campaign LA slice LA1: the session-config character
|
||||
/// selector, or <see langword="null"/> for today's existing
|
||||
/// first-available fallback (absent selector = LA7's char-select screen
|
||||
/// stop point once that slice lands; this slice does not build the
|
||||
/// screen).</summary>
|
||||
LiveSessionCharacterSelector? LiveCharacterSelector,
|
||||
/// <summary>Campaign LA slice LA1: absolute path for the status-event
|
||||
/// JSONL stream. <see langword="null"/> = no writer constructed.</summary>
|
||||
string? StatusFilePath,
|
||||
/// <summary>Campaign LA slice LA1: plugin ids to load.
|
||||
/// <see langword="null"/> = load every discovered plugin (today's
|
||||
/// behavior). Consumed by LA5; parsed and carried now.</summary>
|
||||
IReadOnlyList<string>? Plugins,
|
||||
/// <summary>Campaign LA slice LA1: ordered chat-typed strings run once
|
||||
/// entered-world. Consumed by LA6; parsed and carried now.</summary>
|
||||
IReadOnlyList<string> LoginCommands,
|
||||
/// <summary>Campaign LA slice LA1: inter-command delay for
|
||||
/// <see cref="LoginCommands"/>, milliseconds.</summary>
|
||||
int LoginCommandDelayMs)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
|
|
@ -170,9 +200,72 @@ public sealed record RuntimeOptions(
|
|||
// closes the window. Zero -- unset, unparseable, or an explicit 0 --
|
||||
// keeps the interactive behaviour, so no existing invocation changes.
|
||||
VulkanCapabilityProbeFrames:
|
||||
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0);
|
||||
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0,
|
||||
// Campaign LA slice LA1: the env-var dev flow never carries a
|
||||
// session-config document — every new field below stays at its
|
||||
// "nothing configured" default. RuntimeOptions.FromSessionConfig
|
||||
// overlays the real values on top of this base.
|
||||
SessionConfigPath: null,
|
||||
SessionId: null,
|
||||
LiveCharacterSelector: null,
|
||||
StatusFilePath: null,
|
||||
Plugins: null,
|
||||
LoginCommands: [],
|
||||
LoginCommandDelayMs: 500);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: builds options for the <c>--session-config</c>
|
||||
/// launch path. Starts from the same env-var parse as
|
||||
/// <see cref="FromEnvironment"/> (diagnostic/dev flags are still
|
||||
/// env-controlled — only the LIVE session settings and the five new LA1
|
||||
/// fields come from the document) and overlays the resolved session.
|
||||
/// <paramref name="resolvedPassword"/> is revealed into
|
||||
/// <see cref="LivePass"/> exactly as wide as the existing env-var flow —
|
||||
/// see that field's own doc.
|
||||
/// </summary>
|
||||
internal static RuntimeOptions FromSessionConfig(
|
||||
string datDir,
|
||||
Func<string, string?> env,
|
||||
string sessionConfigPath,
|
||||
SessionConfiguration config,
|
||||
SessionDescriptor session,
|
||||
string? resolvedPassword)
|
||||
{
|
||||
if (config is null) throw new ArgumentNullException(nameof(config));
|
||||
if (session is null) throw new ArgumentNullException(nameof(session));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionConfigPath);
|
||||
|
||||
RuntimeOptions baseOptions = Parse(datDir, env);
|
||||
SessionContentDescriptor? content = config.Process?.Content;
|
||||
return baseOptions with
|
||||
{
|
||||
PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath)
|
||||
?? baseOptions.PreparedAssetPath,
|
||||
LiveMode = true,
|
||||
LiveHost = session.Endpoint.Host,
|
||||
LivePort = session.Endpoint.Port,
|
||||
LiveUser = session.Account,
|
||||
LivePass = resolvedPassword,
|
||||
SessionConfigPath = sessionConfigPath,
|
||||
SessionId = session.Id,
|
||||
LiveCharacterSelector = MapCharacterSelector(session.Character),
|
||||
StatusFilePath = NullIfEmpty(session.StatusFile),
|
||||
Plugins = session.Plugins,
|
||||
LoginCommands = (IReadOnlyList<string>?)session.LoginCommands ?? [],
|
||||
LoginCommandDelayMs = session.LoginCommandDelayMs,
|
||||
};
|
||||
}
|
||||
|
||||
private static LiveSessionCharacterSelector? MapCharacterSelector(
|
||||
SessionCharacterSelectorDescriptor? selector) =>
|
||||
selector is null
|
||||
? null
|
||||
: new LiveSessionCharacterSelector(
|
||||
selector.Index,
|
||||
selector.Id,
|
||||
selector.Name);
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
public bool HasLiveCredentials =>
|
||||
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
||||
|
|
|
|||
|
|
@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor
|
|||
/// legal no-ops.
|
||||
/// </summary>
|
||||
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
|
||||
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
|
||||
/// Absent means load every discovered plugin (today's dev behavior);
|
||||
/// LA5 wires this into an actual allow-list filter. Parsed and carried
|
||||
/// here now so the session-config shape is stable before LA5 lands.
|
||||
/// </summary>
|
||||
public List<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: ordered chat-typed strings run once the
|
||||
/// session enters world. LA6 wires actual execution; parsed and carried
|
||||
/// here now.
|
||||
/// </summary>
|
||||
public List<string>? LoginCommands { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: inter-command delay for
|
||||
/// <see cref="LoginCommands"/>, in milliseconds. Matches the pinned
|
||||
/// launch-contract default (500 ms) when the field is absent from the
|
||||
/// document.
|
||||
/// </summary>
|
||||
public int LoginCommandDelayMs { get; init; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: absolute path for this session's status-event
|
||||
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
|
||||
/// §6). Absent means no <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>
|
||||
/// is constructed for this session.
|
||||
/// </summary>
|
||||
public string? StatusFile { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class HeadlessEndpointDescriptor
|
||||
|
|
|
|||
|
|
@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader
|
|||
}
|
||||
|
||||
ValidateCharacterOptions(session);
|
||||
ValidateLaunchContractFields(session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: validates the four new optional per-session
|
||||
/// fields shared with the App session-config reader (see
|
||||
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's pinned
|
||||
/// contract). All four stay optional; only their SHAPE is checked here
|
||||
/// — parsing/executing <c>plugins</c>/<c>loginCommands</c> is LA5/LA6.
|
||||
/// </summary>
|
||||
private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session)
|
||||
{
|
||||
if (session.Plugins is { } plugins)
|
||||
{
|
||||
foreach (string? plugin in plugins)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plugin))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' plugins entries must be non-empty strings.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (session.LoginCommandDelayMs < 0)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
|
||||
}
|
||||
|
||||
if (session.StatusFile is not null
|
||||
&& string.IsNullOrWhiteSpace(session.StatusFile))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -113,6 +113,19 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private readonly HeadlessCredentialSecret _credential;
|
||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: a SEPARATE per-session sink from
|
||||
/// <see cref="_diagnostics"/> — a no-op instance when
|
||||
/// <see cref="HeadlessSessionDescriptor.StatusFile"/> was not configured.
|
||||
/// See <see cref="SessionStatusWriter"/>'s own doc for why this is not a
|
||||
/// rework of the shared-stdout diagnostics writer.
|
||||
/// </summary>
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
/// <summary>Guards <see cref="Stop"/>'s <c>disconnected</c> status event
|
||||
/// so a Stop() on a session that never actually reached Connected (e.g.
|
||||
/// disposing a fresh, never-started host) does not report a spurious
|
||||
/// disconnect.</summary>
|
||||
private bool _hasConnected;
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP7 (2026-08-11), D8: the parsed
|
||||
/// <c>characterOptions</c> block — empty when the config omitted it.
|
||||
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
|
||||
|
|
@ -271,6 +284,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
var commands = new DirectGameRuntimeCommandAdapter(
|
||||
runtime,
|
||||
bridge);
|
||||
// Campaign LA slice LA1: no-op instance when
|
||||
// descriptor.StatusFile is unset — every call site below stays
|
||||
// unconditional.
|
||||
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
||||
var liveSession = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
|
|
@ -318,14 +335,25 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
descriptor.Id,
|
||||
$"connecting:{host}:{port}:{user}",
|
||||
runtime.Generation.Value),
|
||||
() => diagnostics.Message(
|
||||
() =>
|
||||
{
|
||||
diagnostics.Message(
|
||||
descriptor.Id,
|
||||
"connected",
|
||||
runtime.Generation.Value);
|
||||
statusWriter.Connected(descriptor.Id);
|
||||
_hasConnected = true;
|
||||
},
|
||||
roster => statusWriter.CharacterList(descriptor.Id, roster),
|
||||
selection => statusWriter.EnteredWorld(
|
||||
descriptor.Id,
|
||||
"connected",
|
||||
runtime.Generation.Value)));
|
||||
selection.CharacterId,
|
||||
selection.CharacterName)));
|
||||
|
||||
Runtime = runtime;
|
||||
Commands = commands;
|
||||
_liveSession = liveSession;
|
||||
_statusWriter = statusWriter;
|
||||
_localPlayerFrame =
|
||||
runtime.CreateLocalPlayerFrameController(
|
||||
new HeadlessLocalPlayerFrameHost(
|
||||
|
|
@ -421,8 +449,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_pendingConfirmation = null;
|
||||
}
|
||||
|
||||
internal RuntimeSessionStartResult Start() =>
|
||||
Commands.Session.Start(Runtime.Generation);
|
||||
internal RuntimeSessionStartResult Start()
|
||||
{
|
||||
// Campaign LA slice LA1: "started" = session host start — the
|
||||
// earliest point this session actually attempts to connect.
|
||||
_statusWriter.Started(_descriptor.Id);
|
||||
return Commands.Session.Start(Runtime.Generation);
|
||||
}
|
||||
|
||||
internal RuntimeSessionStartResult Reconnect() =>
|
||||
Commands.Session.Reconnect(Runtime.Generation);
|
||||
|
|
@ -470,6 +503,15 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// (possibly disposed) WorldSession in the window between this Stop
|
||||
// and the next CreateEventRoute call.
|
||||
_currentSession = null;
|
||||
// Campaign LA slice LA1: only report a disconnect for a session that
|
||||
// actually reached Connected — a Stop() on a never-started or
|
||||
// never-connected host (e.g. immediate Dispose()) is not a real
|
||||
// disconnect.
|
||||
if (_hasConnected)
|
||||
{
|
||||
_hasConnected = false;
|
||||
_statusWriter.Disconnected(_descriptor.Id, "stopped");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -592,6 +634,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_descriptor.Id,
|
||||
"disposed",
|
||||
_stoppedGeneration);
|
||||
// Campaign LA slice LA1: "exited" = terminal — the sole
|
||||
// point every disposal path (graceful and post-
|
||||
// quarantine) converges on.
|
||||
_statusWriter.Exited(
|
||||
_descriptor.Id,
|
||||
_faulted ? 1 : 0,
|
||||
_faulted ? "fault" : "disposed");
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,32 @@ public sealed record LiveSessionStartResult(
|
|||
LiveSessionCharacterSelection? Selection = null,
|
||||
Exception? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: one roster entry as reported by
|
||||
/// <see cref="CharacterList.Parsed"/> — decoupled from the wire type so the
|
||||
/// lifecycle-host seam does not leak <c>AcDream.Core.Net.Messages</c> shapes
|
||||
/// into every consumer.
|
||||
/// </summary>
|
||||
public readonly record struct LiveSessionRosterEntry(
|
||||
uint Id,
|
||||
string Name,
|
||||
uint SecondsGreyedOut);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: the account's active-character roster, reported to
|
||||
/// <see cref="ILiveSessionLifecycleHost.ReportRoster"/> right after
|
||||
/// <c>CharacterList</c> arrives and BEFORE selection — see
|
||||
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 item 2. Hosts forward
|
||||
/// this to their status stream (<c>characterList</c> event) and, later
|
||||
/// (LA7/LA8), to the character-select screen. Deleted characters are
|
||||
/// deliberately excluded — the same candidate set
|
||||
/// <see cref="CharacterList.TrySelectFirstAvailable"/> already uses.
|
||||
/// </summary>
|
||||
public sealed record LiveSessionRosterReport(
|
||||
string AccountName,
|
||||
int SlotCount,
|
||||
IReadOnlyList<LiveSessionRosterEntry> Entries);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime boundary for the domain and presentation sinks attached to one
|
||||
/// exact <see cref="WorldSession"/> generation. The controller owns the
|
||||
|
|
@ -61,6 +87,10 @@ public interface ILiveSessionLifecycleHost
|
|||
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
|
||||
void ReportConnecting(string host, int port, string user);
|
||||
void ReportConnected();
|
||||
/// <summary>Campaign LA slice LA1: reported once per successful
|
||||
/// <c>CharacterList</c> receipt, right before character selection. See
|
||||
/// <see cref="LiveSessionRosterReport"/>.</summary>
|
||||
void ReportRoster(LiveSessionRosterReport roster);
|
||||
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
||||
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
|
||||
void DetachSession(WorldSession session);
|
||||
|
|
@ -610,6 +640,13 @@ public sealed class LiveSessionController
|
|||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||
if (characters is not null)
|
||||
{
|
||||
host.ReportRoster(BuildRosterReport(characters));
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
|
||||
if (characters is null
|
||||
|| !TrySelectCharacter(
|
||||
characters,
|
||||
|
|
@ -838,6 +875,29 @@ public sealed class LiveSessionController
|
|||
private LiveSessionStartResult ConnectedResult()
|
||||
=> new(LiveSessionStartStatus.Connected, _activeSelection);
|
||||
|
||||
/// <summary>Campaign LA slice LA1: projects the wire-shaped
|
||||
/// <see cref="CharacterList.Parsed"/> into the decoupled
|
||||
/// <see cref="LiveSessionRosterReport"/>. Deleted characters are
|
||||
/// excluded, matching <see cref="CharacterList.TrySelectFirstAvailable"/>'s
|
||||
/// candidate set.</summary>
|
||||
private static LiveSessionRosterReport BuildRosterReport(
|
||||
CharacterList.Parsed characters)
|
||||
{
|
||||
var entries = new LiveSessionRosterEntry[characters.Characters.Count];
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
CharacterList.Character character = characters.Characters[i];
|
||||
entries[i] = new LiveSessionRosterEntry(
|
||||
character.Id,
|
||||
character.Name,
|
||||
character.SecondsGreyedOut);
|
||||
}
|
||||
return new LiveSessionRosterReport(
|
||||
characters.AccountName,
|
||||
characters.SlotCount,
|
||||
entries);
|
||||
}
|
||||
|
||||
private static bool TrySelectCharacter(
|
||||
CharacterList.Parsed characters,
|
||||
LiveSessionCharacterSelector? selector,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,18 @@ public sealed record LiveSessionHostBindings(
|
|||
LiveSessionSelectionBindings Selection,
|
||||
LiveSessionEnteredWorldBindings EnteredWorld,
|
||||
Action<string, int, string> Connecting,
|
||||
Action Connected);
|
||||
Action Connected,
|
||||
/// <summary>Campaign LA slice LA1: reported once per successful
|
||||
/// <c>CharacterList</c> receipt, right before character selection — see
|
||||
/// <see cref="LiveSessionRosterReport"/>. Hosts forward this to their
|
||||
/// status stream's <c>characterList</c> event.</summary>
|
||||
Action<LiveSessionRosterReport> Roster,
|
||||
/// <summary>Campaign LA slice LA1: reported once entered-world state is
|
||||
/// applied, carrying the full selection (id + name) — unlike
|
||||
/// <see cref="EnteredWorld"/>'s narrow <c>SetActiveCharacter(string)</c>
|
||||
/// fan-out, this exists so a status writer can emit the
|
||||
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
|
||||
Action<LiveSessionCharacterSelection> CharacterEntered);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
|
||||
|
|
@ -84,6 +95,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
private readonly LiveSessionRoutingFactories _routing;
|
||||
private readonly LiveSessionSelectionBindings _selection;
|
||||
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
||||
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
|
||||
private readonly Action<RuntimeGenerationToken> _reset;
|
||||
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||
private PendingRouteRollback? _pendingRouteRollback;
|
||||
|
|
@ -100,11 +112,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
|
||||
_enteredWorld = bindings.EnteredWorld
|
||||
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
||||
_characterEntered = bindings.CharacterEntered
|
||||
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Roster);
|
||||
Validate(_selection, _enteredWorld);
|
||||
|
||||
_reset = bindings.Reset;
|
||||
|
|
@ -113,6 +128,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
Reset: ResetSessionState,
|
||||
Connecting: bindings.Connecting,
|
||||
Connected: bindings.Connected,
|
||||
Roster: bindings.Roster,
|
||||
Selected: ApplySelection,
|
||||
Entered: ApplyEnteredWorld));
|
||||
}
|
||||
|
|
@ -217,6 +233,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
_enteredWorld.SyncToolbar();
|
||||
_enteredWorld.LoadCharacterSettings(name);
|
||||
_enteredWorld.ArmPlayerModeAutoEntry();
|
||||
_characterEntered(selection);
|
||||
}
|
||||
|
||||
private void RethrowWithRetryableRollback(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ public sealed record LiveSessionLifecycleBindings(
|
|||
Action<RuntimeGenerationToken> Reset,
|
||||
Action<string, int, string> Connecting,
|
||||
Action Connected,
|
||||
Action<LiveSessionRosterReport> Roster,
|
||||
Action<LiveSessionCharacterSelection> Selected,
|
||||
Action<LiveSessionCharacterSelection> Entered);
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
|||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Roster);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Selected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Entered);
|
||||
}
|
||||
|
|
@ -51,6 +53,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
|||
|
||||
public void ReportConnected() => _bindings.Connected();
|
||||
|
||||
public void ReportRoster(LiveSessionRosterReport roster) =>
|
||||
_bindings.Roster(roster);
|
||||
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
|
||||
_bindings.Selected(selection);
|
||||
|
||||
|
|
|
|||
162
src/AcDream.Runtime/Session/SessionStatusWriter.cs
Normal file
162
src/AcDream.Runtime/Session/SessionStatusWriter.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: appends one JSON object per line to a per-session
|
||||
/// status-event file the launcher tails
|
||||
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1,
|
||||
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6).
|
||||
///
|
||||
/// <para>
|
||||
/// This is a SEPARATE sink from <c>HeadlessDiagnosticWriter</c> — that class
|
||||
/// is a single shared-stdout JSONL diagnostics stream with no per-session
|
||||
/// file; this class writes one file per session, meant to be read by an
|
||||
/// external process (the launcher) rather than scraped from console output.
|
||||
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind
|
||||
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without
|
||||
/// breaking an existing reader.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Every write opens the file in append mode with <see cref="FileShare.Read"/>
|
||||
/// so an external tailer can read the file concurrently, writes exactly one
|
||||
/// line, flushes, and closes — there is no long-lived file handle to leak or
|
||||
/// to dispose. A writer constructed with a <see langword="null"/> or blank
|
||||
/// path is a permanent no-op: every method becomes a cheap null-check, so
|
||||
/// callers never need to guard construction sites on whether a status file
|
||||
/// was configured.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Never write credential material into this stream.</strong> Every
|
||||
/// event method below takes only identifiers, names, and counts — there is no
|
||||
/// parameter shape that could carry a password, by construction.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SessionStatusWriter
|
||||
{
|
||||
private const int VocabularyVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private readonly string? _path;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_path = string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when this writer has a configured path and will actually append
|
||||
/// events. Lets a caller with an expensive report to build (e.g. the
|
||||
/// roster projection) skip that work entirely when nobody configured a
|
||||
/// status file for this session.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _path is not null;
|
||||
|
||||
public void Started(string sessionId) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "started",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
public void Connected(string sessionId) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "connected",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
public void CharacterList(string sessionId, LiveSessionRosterReport roster)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(roster);
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "characterList",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
accountName = roster.AccountName,
|
||||
slotCount = roster.SlotCount,
|
||||
characters = roster.Entries
|
||||
.Select(static entry => new
|
||||
{
|
||||
id = entry.Id,
|
||||
name = entry.Name,
|
||||
secondsGreyedOut = entry.SecondsGreyedOut,
|
||||
})
|
||||
.ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
public void EnteredWorld(string sessionId, uint characterId, string characterName) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "enteredWorld",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
characterId,
|
||||
characterName,
|
||||
});
|
||||
|
||||
public void Disconnected(string sessionId, string reason) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "disconnected",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
reason,
|
||||
});
|
||||
|
||||
public void Exited(string sessionId, int code, string reason) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "exited",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
code,
|
||||
reason,
|
||||
});
|
||||
|
||||
private string Now() =>
|
||||
_timeProvider.GetUtcNow().ToString(
|
||||
"O",
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private void Write<T>(T value)
|
||||
{
|
||||
if (_path is not { } path)
|
||||
return;
|
||||
|
||||
string line = JsonSerializer.Serialize(value, JsonOptions);
|
||||
lock (_gate)
|
||||
{
|
||||
using FileStream stream = new(
|
||||
path,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.Read);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.WriteLine(line);
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
using AcDream.App;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: round-trip tests for
|
||||
/// <see cref="RuntimeOptions.FromSessionConfig"/> — the overlay that turns a
|
||||
/// parsed <see cref="SessionConfiguration"/>/<see cref="SessionDescriptor"/>
|
||||
/// into the same typed bundle the env-var dev flow produces.
|
||||
/// </summary>
|
||||
public sealed class RuntimeOptionsSessionConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void SessionConfigOverridesLiveSettingsAndCarriesAllFiveNewFields()
|
||||
{
|
||||
var config = new SessionConfiguration { Version = 1 };
|
||||
var session = new SessionDescriptor
|
||||
{
|
||||
Id = "gui-session",
|
||||
Endpoint = new SessionEndpointDescriptor
|
||||
{
|
||||
Host = "192.168.1.50",
|
||||
Port = 9123,
|
||||
},
|
||||
Account = "guiaccount",
|
||||
Character = new SessionCharacterSelectorDescriptor { Name = "GuiToon" },
|
||||
Credential = new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "IGNORED",
|
||||
},
|
||||
Plugins = ["PluginA", "PluginB"],
|
||||
LoginCommands = ["/tell x, hi"],
|
||||
LoginCommandDelayMs = 900,
|
||||
StatusFile = "status.jsonl",
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
|
||||
"D:\\dat",
|
||||
_ => null,
|
||||
"session.json",
|
||||
config,
|
||||
session,
|
||||
"resolved-password");
|
||||
|
||||
Assert.True(options.LiveMode);
|
||||
Assert.Equal("192.168.1.50", options.LiveHost);
|
||||
Assert.Equal(9123, options.LivePort);
|
||||
Assert.Equal("guiaccount", options.LiveUser);
|
||||
Assert.Equal("resolved-password", options.LivePass);
|
||||
Assert.Equal("session.json", options.SessionConfigPath);
|
||||
Assert.Equal("gui-session", options.SessionId);
|
||||
Assert.Equal(
|
||||
new LiveSessionCharacterSelector(null, null, "GuiToon"),
|
||||
options.LiveCharacterSelector);
|
||||
Assert.Equal("status.jsonl", options.StatusFilePath);
|
||||
Assert.Equal(["PluginA", "PluginB"], options.Plugins);
|
||||
Assert.Equal(["/tell x, hi"], options.LoginCommands);
|
||||
Assert.Equal(900, options.LoginCommandDelayMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentCharacterSelectorLeavesFirstAvailableFallbackInEffect()
|
||||
{
|
||||
var config = new SessionConfiguration { Version = 1 };
|
||||
var session = new SessionDescriptor
|
||||
{
|
||||
Id = "no-selector",
|
||||
Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 },
|
||||
Account = "account",
|
||||
Credential = new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "X",
|
||||
},
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
|
||||
"D:\\dat",
|
||||
_ => null,
|
||||
"session.json",
|
||||
config,
|
||||
session,
|
||||
"password");
|
||||
|
||||
Assert.Null(options.LiveCharacterSelector);
|
||||
Assert.Null(options.Plugins);
|
||||
Assert.Empty(options.LoginCommands);
|
||||
Assert.Equal(500, options.LoginCommandDelayMs);
|
||||
Assert.Null(options.StatusFilePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessContentOverridesDatDirectoryAndPreparedAssetPath()
|
||||
{
|
||||
var config = new SessionConfiguration
|
||||
{
|
||||
Version = 1,
|
||||
Process = new SessionProcessSettings
|
||||
{
|
||||
Content = new SessionContentDescriptor
|
||||
{
|
||||
DatDirectory = "D:\\configured-dats",
|
||||
PreparedAssetPath = "D:\\configured-dats\\acdream.pak",
|
||||
},
|
||||
},
|
||||
};
|
||||
var session = new SessionDescriptor
|
||||
{
|
||||
Id = "content-session",
|
||||
Endpoint = new SessionEndpointDescriptor { Host = "127.0.0.1", Port = 9000 },
|
||||
Account = "account",
|
||||
Credential = new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "X",
|
||||
},
|
||||
};
|
||||
|
||||
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
|
||||
"D:\\configured-dats",
|
||||
_ => null,
|
||||
"session.json",
|
||||
config,
|
||||
session,
|
||||
"password");
|
||||
|
||||
Assert.Equal(
|
||||
"D:\\configured-dats\\acdream.pak",
|
||||
options.PreparedAssetPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnvironmentFlowLeavesEveryNewFieldAtItsNothingConfiguredDefault()
|
||||
{
|
||||
RuntimeOptions options = RuntimeOptions.Parse("D:\\dat", _ => null);
|
||||
|
||||
Assert.Null(options.SessionConfigPath);
|
||||
Assert.Null(options.SessionId);
|
||||
Assert.Null(options.LiveCharacterSelector);
|
||||
Assert.Null(options.StatusFilePath);
|
||||
Assert.Null(options.Plugins);
|
||||
Assert.Empty(options.LoginCommands);
|
||||
Assert.Equal(500, options.LoginCommandDelayMs);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using AcDream.App.Configuration;
|
||||
|
||||
namespace AcDream.App.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: proves the App config reader accepts the EXACT
|
||||
/// document the Headless reader also accepts —
|
||||
/// <c>tests/Fixtures/campaign-la/session-config-shared-fixture.json</c> is
|
||||
/// parsed by both <see cref="SessionConfigurationLoader"/> here and
|
||||
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c> in
|
||||
/// <c>AcDream.Headless.Tests</c>'s twin of this test. This is the
|
||||
/// pinned-contract acceptance test from
|
||||
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1: "a SHARED fixture
|
||||
/// JSON parsed by both test suites proving the two readers accept the
|
||||
/// identical document." If either reader's DTO shape drifts from the pinned
|
||||
/// contract, ONE of these two tests fails.
|
||||
/// </summary>
|
||||
public sealed class SessionConfigurationSharedFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void AppReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
|
||||
{
|
||||
(SessionConfiguration configuration, SessionDescriptor session) =
|
||||
SessionConfigurationLoader.Load(SharedFixturePath());
|
||||
|
||||
Assert.Equal(1, configuration.Version);
|
||||
Assert.Equal("shared-fixture", session.Id);
|
||||
Assert.Equal("127.0.0.1", session.Endpoint.Host);
|
||||
Assert.Equal(9000, session.Endpoint.Port);
|
||||
Assert.Equal("sharedaccount", session.Account);
|
||||
Assert.Equal("SharedToon", session.Character?.Name);
|
||||
// App parses the policy field structurally but never consults it —
|
||||
// the pinned contract's "parsed-and-ignored" clause.
|
||||
Assert.Equal("idle", session.Policy?.Id);
|
||||
Assert.Equal(
|
||||
SessionCredentialProviderKind.Environment,
|
||||
session.Credential.Provider);
|
||||
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference);
|
||||
|
||||
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
|
||||
Assert.Equal(
|
||||
["/tell someone, hi", "/vt start"],
|
||||
session.LoginCommands);
|
||||
Assert.Equal(750, session.LoginCommandDelayMs);
|
||||
Assert.Equal("shared-fixture-status.jsonl", session.StatusFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentLaunchContractFieldsFallBackToPinnedDefaults()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "no-launch-contract-fields",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "X" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
(_, SessionDescriptor session) = SessionConfigurationLoader.Load(file.Path);
|
||||
|
||||
Assert.Null(session.Character);
|
||||
Assert.Null(session.Plugins);
|
||||
Assert.Null(session.LoginCommands);
|
||||
Assert.Equal(500, session.LoginCommandDelayMs);
|
||||
Assert.Null(session.StatusFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MoreThanOneSessionFailsLoadForTheGraphicalHost()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "one",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "A" }
|
||||
},
|
||||
{
|
||||
"id": "two",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9001 },
|
||||
"account": "account2",
|
||||
"credential": { "provider": "environment", "reference": "B" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<SessionConfigurationException>(
|
||||
() => SessionConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyPluginsEntryFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-plugins",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"plugins": ["Ok", " "]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<SessionConfigurationException>(
|
||||
() => SessionConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeLoginCommandDelayFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-delay",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"loginCommandDelayMs": -1
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<SessionConfigurationException>(
|
||||
() => SessionConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankStatusFileFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-status-file",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"statusFile": " "
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<SessionConfigurationException>(
|
||||
() => SessionConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
internal static string SharedFixturePath(
|
||||
[CallerFilePath] string sourcePath = "") =>
|
||||
Path.Combine(
|
||||
FindRepositoryRoot(sourcePath),
|
||||
"tests",
|
||||
"Fixtures",
|
||||
"campaign-la",
|
||||
"session-config-shared-fixture.json");
|
||||
|
||||
private static string FindRepositoryRoot(string sourcePath)
|
||||
{
|
||||
string[] starts =
|
||||
{
|
||||
Path.GetDirectoryName(sourcePath) ?? string.Empty,
|
||||
Directory.GetCurrentDirectory(),
|
||||
AppContext.BaseDirectory,
|
||||
};
|
||||
foreach (string start in starts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(start))
|
||||
continue;
|
||||
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not find AcDream.slnx above the working or output directory.");
|
||||
}
|
||||
|
||||
private sealed class TemporaryFile : IDisposable
|
||||
{
|
||||
private TemporaryFile(string path) => Path = path;
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
internal static TemporaryFile Create(string json)
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-app-la1-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, json);
|
||||
return new TemporaryFile(path);
|
||||
}
|
||||
|
||||
public void Dispose() => File.Delete(Path);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Credentials;
|
||||
|
||||
namespace AcDream.App.Tests.Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: <see cref="AppCredentialResolver"/> is a minimal
|
||||
/// port of <c>AcDream.Headless.Credentials.HeadlessCredentialResolver</c>
|
||||
/// scoped to the App session-config credential shape — see that file's own
|
||||
/// doc for why it is an independent port rather than a shared reference.
|
||||
/// Mirrors <c>HeadlessCredentialResolverTests</c>'s coverage.
|
||||
/// </summary>
|
||||
public sealed class AppCredentialResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnvironmentSecretIsRedactedAndErasable()
|
||||
{
|
||||
const string variable = "ACDREAM_LA1_TEST_ENV_SECRET";
|
||||
const string secretValue = "test-secret-value";
|
||||
Environment.SetEnvironmentVariable(variable, secretValue);
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
TextReader.Null,
|
||||
Environment.CurrentDirectory,
|
||||
isLinux: false);
|
||||
|
||||
AppCredentialSecret secret = resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = variable,
|
||||
});
|
||||
|
||||
Assert.Equal(secretValue, secret.Reveal());
|
||||
Assert.DoesNotContain(secretValue, secret.ToString());
|
||||
secret.Dispose();
|
||||
Assert.True(secret.IsDisposed);
|
||||
Assert.Throws<ObjectDisposedException>(secret.Reveal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StandardInputConsumesOneSecretWithoutEchoingIt()
|
||||
{
|
||||
const string secretValue = "stdin-secret";
|
||||
var resolver = new AppCredentialResolver(
|
||||
new StringReader(secretValue + Environment.NewLine),
|
||||
Environment.CurrentDirectory,
|
||||
isLinux: false);
|
||||
|
||||
using AppCredentialSecret secret = resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.StandardInput,
|
||||
Reference = "session-stdin",
|
||||
});
|
||||
|
||||
Assert.Equal(secretValue, secret.Reveal());
|
||||
Assert.DoesNotContain(secretValue, secret.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CredentialFileIsResolvedRelativeToConfiguredDirectory()
|
||||
{
|
||||
string directory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-app-credentials-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
string path = Path.Combine(directory, "session.pass");
|
||||
File.WriteAllText(path, "file-secret" + Environment.NewLine);
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
TextReader.Null,
|
||||
directory,
|
||||
isLinux: false);
|
||||
|
||||
using AppCredentialSecret secret = resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.File,
|
||||
Reference = "session.pass",
|
||||
});
|
||||
|
||||
Assert.Equal("file-secret", secret.Reveal());
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingSecretErrorNeverContainsAnotherSecret()
|
||||
{
|
||||
const string variable = "ACDREAM_LA1_TEST_OTHER_SECRET";
|
||||
const string unrelatedSecret = "must-not-leak";
|
||||
Environment.SetEnvironmentVariable(variable, unrelatedSecret);
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
new StringReader(string.Empty),
|
||||
Environment.CurrentDirectory,
|
||||
isLinux: false);
|
||||
|
||||
AppCredentialException error =
|
||||
Assert.Throws<AppCredentialException>(() =>
|
||||
resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.Environment,
|
||||
Reference = "ACDREAM_LA1_TEST_DOES_NOT_EXIST",
|
||||
}));
|
||||
|
||||
Assert.DoesNotContain(unrelatedSecret, error.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(variable, null);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinuxRejectsGroupOrOtherCredentialPermissions()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
return;
|
||||
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-app-credential-{Guid.NewGuid():N}");
|
||||
File.WriteAllText(path, "linux-secret");
|
||||
File.SetUnixFileMode(
|
||||
path,
|
||||
UnixFileMode.UserRead | UnixFileMode.GroupRead);
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
TextReader.Null,
|
||||
Path.GetDirectoryName(path)!,
|
||||
isLinux: true);
|
||||
|
||||
Assert.Throws<AppCredentialException>(() =>
|
||||
resolver.Resolve(
|
||||
"session",
|
||||
new SessionCredentialDescriptor
|
||||
{
|
||||
Provider = SessionCredentialProviderKind.File,
|
||||
Reference = Path.GetFileName(path),
|
||||
}));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,6 +97,7 @@ public sealed class LiveSessionShutdownIntegrationTests
|
|||
RuntimeGenerationToken retiringGeneration) { }
|
||||
public void ReportConnecting(string host, int port, string user) { }
|
||||
public void ReportConnected() { }
|
||||
public void ReportRoster(LiveSessionRosterReport roster) { }
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
|
||||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { }
|
||||
public void DetachSession(WorldSession session) { }
|
||||
|
|
|
|||
|
|
@ -917,7 +917,9 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
new LiveSessionConnectOptions(
|
||||
true,
|
||||
"127.0.0.1",
|
||||
|
|
|
|||
|
|
@ -222,7 +222,9 @@ public sealed class HeadlessSessionEventRouteRetryPendingTests
|
|||
new LiveSessionEnteredWorldBindings(
|
||||
_ => { }, () => { }, () => { }, _ => { }, () => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Immutable;
|
|||
using System.Net;
|
||||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
|
@ -61,6 +62,95 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.DoesNotContain("AcDream.App", diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: proves the status-event writer fires the
|
||||
/// pinned lifecycle vocabulary — started/connected/characterList/
|
||||
/// enteredWorld/disconnected/exited — in order, from a real
|
||||
/// <see cref="HeadlessSessionHost"/> start+dispose cycle, and that the
|
||||
/// roster surfaced matches <see cref="FixtureSessionOperations.GetCharacters"/>
|
||||
/// exactly (before selection has happened — the roster is reported for
|
||||
/// BOTH candidates, not just the selected one).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StatusFileReceivesThePinnedLifecycleEventsInOrder()
|
||||
{
|
||||
string statusPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream-headless-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(statusFile: statusPath),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult started = host.Start();
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
|
||||
host.Dispose();
|
||||
|
||||
string[] lines = File.ReadAllLines(statusPath);
|
||||
string[] eventNames = lines
|
||||
.Select(line => JsonDocument.Parse(line)
|
||||
.RootElement.GetProperty("e").GetString()!)
|
||||
.ToArray();
|
||||
Assert.Equal(
|
||||
[
|
||||
"started", "connected", "characterList", "enteredWorld",
|
||||
"disconnected", "exited",
|
||||
],
|
||||
eventNames);
|
||||
|
||||
using JsonDocument characterListDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "characterList")]);
|
||||
JsonElement characterList = characterListDoc.RootElement;
|
||||
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
|
||||
Assert.Equal(2, characterList.GetProperty("characters").GetArrayLength());
|
||||
|
||||
using JsonDocument enteredWorldDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "enteredWorld")]);
|
||||
Assert.Equal(
|
||||
0x50000002u,
|
||||
enteredWorldDoc.RootElement.GetProperty("characterId").GetUInt32());
|
||||
|
||||
using JsonDocument exitedDoc = JsonDocument.Parse(
|
||||
lines[Array.IndexOf(eventNames, "exited")]);
|
||||
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
|
||||
|
||||
string contents = File.ReadAllText(statusPath);
|
||||
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(statusPath))
|
||||
File.Delete(statusPath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentStatusFileConstructsANoOpWriter()
|
||||
{
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var diagnosticsOutput = new StringWriter();
|
||||
using var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||
operations);
|
||||
|
||||
RuntimeSessionStartResult started = host.Start();
|
||||
|
||||
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
|
||||
// No exception, and (implicitly) no file was ever touched — the
|
||||
// writer is a permanent no-op with no configured path.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
||||
{
|
||||
|
|
@ -1937,7 +2027,9 @@ public sealed class HeadlessSessionHostTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }));
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }));
|
||||
}
|
||||
|
||||
private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations
|
||||
|
|
@ -1967,7 +2059,8 @@ public sealed class HeadlessSessionHostTests
|
|||
HeadlessCredentialProviderKind provider =
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
string credentialReference = "BOT_PASSWORD",
|
||||
Dictionary<string, bool>? characterOptions = null) => new()
|
||||
Dictionary<string, bool>? characterOptions = null,
|
||||
string? statusFile = null) => new()
|
||||
{
|
||||
Id = "bot",
|
||||
Endpoint = new HeadlessEndpointDescriptor
|
||||
|
|
@ -1990,6 +2083,7 @@ public sealed class HeadlessSessionHostTests
|
|||
Reference = credentialReference,
|
||||
},
|
||||
CharacterOptions = characterOptions,
|
||||
StatusFile = statusFile,
|
||||
};
|
||||
|
||||
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using AcDream.Headless.Configuration;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: proves the Headless config reader accepts the
|
||||
/// EXACT document the App reader also accepts —
|
||||
/// <c>tests/Fixtures/campaign-la/session-config-shared-fixture.json</c> is
|
||||
/// parsed by both <see cref="HeadlessConfigurationLoader"/> here and
|
||||
/// <c>AcDream.App.Configuration.SessionConfigurationLoader</c> in
|
||||
/// <c>AcDream.App.Tests</c>'s twin of this test. This is the pinned-contract
|
||||
/// acceptance test from <c>docs/plans/2026-08-14-launcher-campaign.md</c>
|
||||
/// LA1: "a SHARED fixture JSON parsed by both test suites proving the two
|
||||
/// readers accept the identical document." If either reader's DTO shape
|
||||
/// drifts from the pinned contract, ONE of these two tests fails.
|
||||
/// </summary>
|
||||
public sealed class SessionConfigurationSharedFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void HeadlessReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
|
||||
{
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(SharedFixturePath());
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
||||
Assert.Equal("shared-fixture", session.Id);
|
||||
Assert.Equal("127.0.0.1", session.Endpoint.Host);
|
||||
Assert.Equal(9000, session.Endpoint.Port);
|
||||
Assert.Equal("sharedaccount", session.Account);
|
||||
Assert.Equal("SharedToon", session.Character.Name);
|
||||
Assert.Equal("idle", session.Policy.Id);
|
||||
Assert.Equal(
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
session.Credential.Provider);
|
||||
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference);
|
||||
|
||||
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
|
||||
Assert.Equal(
|
||||
["/tell someone, hi", "/vt start"],
|
||||
session.LoginCommands);
|
||||
Assert.Equal(750, session.LoginCommandDelayMs);
|
||||
Assert.Equal("shared-fixture-status.jsonl", session.StatusFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentLaunchContractFieldsFallBackToPinnedDefaults()
|
||||
{
|
||||
// Every LA1 field is optional; a document that omits all five must
|
||||
// still load, with loginCommandDelayMs defaulting to the pinned
|
||||
// 500 ms and the rest defaulting to "nothing configured".
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "no-launch-contract-fields",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(file.Path);
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
||||
Assert.Null(session.Plugins);
|
||||
Assert.Null(session.LoginCommands);
|
||||
Assert.Equal(500, session.LoginCommandDelayMs);
|
||||
Assert.Null(session.StatusFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyPluginsEntryFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-plugins",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"plugins": ["Ok", " "]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeLoginCommandDelayFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-delay",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"loginCommandDelayMs": -1
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankStatusFileFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-status-file",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"statusFile": " "
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
internal static string SharedFixturePath(
|
||||
[CallerFilePath] string sourcePath = "") =>
|
||||
Path.Combine(
|
||||
FindRepositoryRoot(sourcePath),
|
||||
"tests",
|
||||
"Fixtures",
|
||||
"campaign-la",
|
||||
"session-config-shared-fixture.json");
|
||||
|
||||
private static string FindRepositoryRoot(string sourcePath)
|
||||
{
|
||||
string[] starts =
|
||||
{
|
||||
Path.GetDirectoryName(sourcePath) ?? string.Empty,
|
||||
Directory.GetCurrentDirectory(),
|
||||
AppContext.BaseDirectory,
|
||||
};
|
||||
foreach (string start in starts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(start))
|
||||
continue;
|
||||
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not find AcDream.slnx above the working or output directory.");
|
||||
}
|
||||
|
||||
private sealed class TemporaryFile : IDisposable
|
||||
{
|
||||
private TemporaryFile(string path) => Path = path;
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
internal static TemporaryFile Create(string json)
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-headless-la1-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, json);
|
||||
return new TemporaryFile(path);
|
||||
}
|
||||
|
||||
public void Dispose() => File.Delete(Path);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
||||
var trace = new RuntimeTraceRecorder();
|
||||
|
|
@ -752,7 +754,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
||||
_ = adapter.Session.Start(runtime.Generation);
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ public sealed class LiveSessionControllerTests
|
|||
public Action? OnReset { get; set; }
|
||||
public Action? OnConnecting { get; set; }
|
||||
public Action? OnConnected { get; set; }
|
||||
public Action? OnRoster { get; set; }
|
||||
public Action? OnSelected { get; set; }
|
||||
public Action? OnActivate { get; set; }
|
||||
public Action? OnEntered { get; set; }
|
||||
|
|
@ -146,6 +147,7 @@ public sealed class LiveSessionControllerTests
|
|||
public bool ThrowOnBind { get; set; }
|
||||
public bool ThrowOnConnecting { get; set; }
|
||||
public bool ThrowOnConnected { get; set; }
|
||||
public bool ThrowOnRoster { get; set; }
|
||||
public bool ThrowOnSelected { get; set; }
|
||||
public bool ThrowOnActivate { get; set; }
|
||||
public bool ThrowOnEntered { get; set; }
|
||||
|
|
@ -158,6 +160,7 @@ public sealed class LiveSessionControllerTests
|
|||
public List<TestCommandBus> CommandBuses { get; } = [];
|
||||
public List<LiveSessionCharacterSelection> Selections { get; } = [];
|
||||
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
|
||||
public List<LiveSessionRosterReport> Rosters { get; } = [];
|
||||
|
||||
public LiveSessionBinding BindSession(WorldSession session)
|
||||
{
|
||||
|
|
@ -231,6 +234,15 @@ public sealed class LiveSessionControllerTests
|
|||
throw new InvalidOperationException("connected failure");
|
||||
}
|
||||
|
||||
public void ReportRoster(LiveSessionRosterReport roster)
|
||||
{
|
||||
calls.Add("roster");
|
||||
Rosters.Add(roster);
|
||||
OnRoster?.Invoke();
|
||||
if (ThrowOnRoster)
|
||||
throw new InvalidOperationException("roster failure");
|
||||
}
|
||||
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
|
||||
{
|
||||
calls.Add("selected");
|
||||
|
|
@ -290,7 +302,7 @@ public sealed class LiveSessionControllerTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"reset", "resolve", "create", "bind", "report-connecting",
|
||||
"connect", "report-connected", "selected", "enter:1",
|
||||
"connect", "report-connected", "roster", "selected", "enter:1",
|
||||
"activate", "entered",
|
||||
],
|
||||
calls);
|
||||
|
|
@ -302,6 +314,32 @@ public sealed class LiveSessionControllerTests
|
|||
Assert.True(host.CommandBuses[0].Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_ReportsRosterFromCharacterListBeforeSelection()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var operations = new TestOperations(calls);
|
||||
var host = new TestHost(calls);
|
||||
var controller = new LiveSessionController(operations);
|
||||
|
||||
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||
LiveSessionRosterReport roster = Assert.Single(host.Rosters);
|
||||
Assert.Equal("Canonical", roster.AccountName);
|
||||
Assert.Equal(11, roster.SlotCount);
|
||||
Assert.Equal(
|
||||
[
|
||||
new LiveSessionRosterEntry(0x50000001u, "Grey", 10u),
|
||||
new LiveSessionRosterEntry(0x50000002u, "Ready", 0u),
|
||||
],
|
||||
roster.Entries);
|
||||
// "roster" must land strictly before "selected" — the launcher's
|
||||
// char-select screen (LA7/LA8) will read the roster before any
|
||||
// selection has been made.
|
||||
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
|
||||
{
|
||||
|
|
@ -488,7 +526,7 @@ public sealed class LiveSessionControllerTests
|
|||
[
|
||||
"deactivate", "detach-events", "dispose-session", "detach-session",
|
||||
"reset", "resolve", "create", "bind", "report-connecting",
|
||||
"connect", "report-connected", "selected", "enter:1",
|
||||
"connect", "report-connected", "roster", "selected", "enter:1",
|
||||
"activate", "entered",
|
||||
],
|
||||
calls);
|
||||
|
|
@ -742,6 +780,7 @@ public sealed class LiveSessionControllerTests
|
|||
[InlineData("connecting")]
|
||||
[InlineData("connected")]
|
||||
[InlineData("characters")]
|
||||
[InlineData("roster")]
|
||||
[InlineData("selected")]
|
||||
[InlineData("activate")]
|
||||
[InlineData("entered")]
|
||||
|
|
@ -755,6 +794,7 @@ public sealed class LiveSessionControllerTests
|
|||
case "connecting": host.ThrowOnConnecting = true; break;
|
||||
case "connected": host.ThrowOnConnected = true; break;
|
||||
case "characters": operations.ThrowOnCharacters = true; break;
|
||||
case "roster": host.ThrowOnRoster = true; break;
|
||||
case "selected": host.ThrowOnSelected = true; break;
|
||||
case "activate": host.ThrowOnActivate = true; break;
|
||||
case "entered": host.ThrowOnEntered = true; break;
|
||||
|
|
|
|||
|
|
@ -35,12 +35,13 @@ public sealed class LiveSessionHostTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"reset", "resolve", "create", "events", "attach-events", "commands",
|
||||
"connecting", "connect", "connected",
|
||||
"connecting", "connect", "connected", "roster:Canonical",
|
||||
"player:1342177282", "vitals:1342177282",
|
||||
"chat:1342177282", "persistent:1342177282",
|
||||
"vanish:1342177282", "clear-combat", "enter:1",
|
||||
"activate", "active:Ready", "restore-layout",
|
||||
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
|
||||
"character-entered:1342177282",
|
||||
],
|
||||
calls);
|
||||
Assert.Same(controller.CurrentSession, host.CurrentSession);
|
||||
|
|
@ -236,7 +237,10 @@ public sealed class LiveSessionHostTests
|
|||
name => calls.Add($"load-settings:{name}"),
|
||||
() => calls.Add("arm-auto-entry")),
|
||||
Connecting: (_, _, _) => calls.Add("connecting"),
|
||||
Connected: () => calls.Add("connected")));
|
||||
Connected: () => calls.Add("connected"),
|
||||
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
|
||||
CharacterEntered: selection =>
|
||||
calls.Add($"character-entered:{selection.CharacterId}")));
|
||||
|
||||
private static LiveSessionConnectOptions LiveOptions(
|
||||
bool live = true,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ public sealed class LiveSessionLifecycleHostTests
|
|||
host.ResetSessionState(RuntimeGenerationToken.Initial);
|
||||
host.ReportConnecting("host", 9000, "user");
|
||||
host.ReportConnected();
|
||||
host.ReportRoster(new LiveSessionRosterReport("account", 11, []));
|
||||
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
|
||||
host.ApplySelectedCharacter(selection);
|
||||
binding.ActivateCommands();
|
||||
|
|
@ -31,8 +32,8 @@ public sealed class LiveSessionLifecycleHostTests
|
|||
Assert.Equal(
|
||||
[
|
||||
"bind", "reset", "connecting:host:9000:user",
|
||||
"connected", "selected:toon", "activate", "entered:toon",
|
||||
"deactivate", "detach-events", "bind",
|
||||
"connected", "roster:account", "selected:toon", "activate",
|
||||
"entered:toon", "deactivate", "detach-events", "bind",
|
||||
],
|
||||
calls);
|
||||
replacement.Dispose();
|
||||
|
|
@ -71,6 +72,7 @@ public sealed class LiveSessionLifecycleHostTests
|
|||
Connecting: (host, port, user) =>
|
||||
calls.Add($"connecting:{host}:{port}:{user}"),
|
||||
Connected: () => calls.Add("connected"),
|
||||
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
|
||||
Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
|
||||
Entered: selection => calls.Add($"entered:{selection.CharacterName}")));
|
||||
|
||||
|
|
|
|||
|
|
@ -2342,7 +2342,9 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
|
|
|
|||
|
|
@ -1049,7 +1049,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ public sealed class RuntimeLiveSessionNoWindowTests
|
|||
_ => { },
|
||||
() => { }),
|
||||
(_, _, _) => calls.Add("connecting"),
|
||||
() => calls.Add("connected")),
|
||||
() => calls.Add("connected"),
|
||||
_ => calls.Add("roster"),
|
||||
selection => calls.Add($"character-entered:{selection.CharacterId}")),
|
||||
new LiveSessionConnectOptions(
|
||||
true,
|
||||
"127.0.0.1",
|
||||
|
|
@ -61,10 +63,12 @@ public sealed class RuntimeLiveSessionNoWindowTests
|
|||
"connect",
|
||||
"connected",
|
||||
"characters",
|
||||
"roster",
|
||||
"player:1342177281",
|
||||
"enter:0",
|
||||
"activate-commands",
|
||||
"entered:Runtime",
|
||||
"character-entered:1342177281",
|
||||
"deactivate-commands",
|
||||
"detach-events",
|
||||
"dispose-session",
|
||||
|
|
|
|||
177
tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
Normal file
177
tests/AcDream.Runtime.Tests/Session/SessionStatusWriterTests.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: pins the exact JSONL status-stream contract both
|
||||
/// the App and Headless hosts write into, and the launcher (a process we
|
||||
/// don't own) reads — see <c>docs/plans/2026-08-14-launcher-campaign.md</c>
|
||||
/// LA1 and <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
|
||||
/// §6.
|
||||
/// </summary>
|
||||
public sealed class SessionStatusWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void EachEventWritesTheExactPinnedShapeInOrder()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create();
|
||||
var writer = new SessionStatusWriter(file.Path);
|
||||
|
||||
writer.Started("s1");
|
||||
writer.Connected("s1");
|
||||
writer.CharacterList(
|
||||
"s1",
|
||||
new LiveSessionRosterReport(
|
||||
"account",
|
||||
11,
|
||||
[
|
||||
new LiveSessionRosterEntry(0x50000001u, "Ready", 0u),
|
||||
new LiveSessionRosterEntry(0x50000002u, "Grey", 10u),
|
||||
]));
|
||||
writer.EnteredWorld("s1", 0x50000001u, "Ready");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
string[] lines = File.ReadAllLines(file.Path);
|
||||
Assert.Equal(6, lines.Length);
|
||||
|
||||
JsonElement started = Parse(lines[0]);
|
||||
Assert.Equal(1, started.GetProperty("v").GetInt32());
|
||||
Assert.Equal("started", started.GetProperty("e").GetString());
|
||||
Assert.True(started.TryGetProperty("t", out _));
|
||||
Assert.Equal("s1", started.GetProperty("sessionId").GetString());
|
||||
|
||||
JsonElement connected = Parse(lines[1]);
|
||||
Assert.Equal("connected", connected.GetProperty("e").GetString());
|
||||
Assert.Equal("s1", connected.GetProperty("sessionId").GetString());
|
||||
|
||||
JsonElement characterList = Parse(lines[2]);
|
||||
Assert.Equal("characterList", characterList.GetProperty("e").GetString());
|
||||
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
|
||||
Assert.Equal(11, characterList.GetProperty("slotCount").GetInt32());
|
||||
JsonElement characters = characterList.GetProperty("characters");
|
||||
Assert.Equal(2, characters.GetArrayLength());
|
||||
JsonElement first = characters[0];
|
||||
Assert.Equal(0x50000001u, first.GetProperty("id").GetUInt32());
|
||||
Assert.Equal("Ready", first.GetProperty("name").GetString());
|
||||
Assert.Equal(0u, first.GetProperty("secondsGreyedOut").GetUInt32());
|
||||
|
||||
JsonElement enteredWorld = Parse(lines[3]);
|
||||
Assert.Equal("enteredWorld", enteredWorld.GetProperty("e").GetString());
|
||||
Assert.Equal(0x50000001u, enteredWorld.GetProperty("characterId").GetUInt32());
|
||||
Assert.Equal("Ready", enteredWorld.GetProperty("characterName").GetString());
|
||||
|
||||
JsonElement disconnected = Parse(lines[4]);
|
||||
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
|
||||
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
|
||||
|
||||
JsonElement exited = Parse(lines[5]);
|
||||
Assert.Equal("exited", exited.GetProperty("e").GetString());
|
||||
Assert.Equal(0, exited.GetProperty("code").GetInt32());
|
||||
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoOpWriterNeverCreatesAFile()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Reserve();
|
||||
var writer = new SessionStatusWriter(null);
|
||||
|
||||
writer.Started("s1");
|
||||
writer.Connected("s1");
|
||||
writer.Disconnected("s1", "stopped");
|
||||
writer.Exited("s1", 0, "disposed");
|
||||
|
||||
Assert.False(writer.IsEnabled);
|
||||
Assert.False(File.Exists(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankPathIsTreatedAsAbsent()
|
||||
{
|
||||
var writer = new SessionStatusWriter(" ");
|
||||
|
||||
Assert.False(writer.IsEnabled);
|
||||
// Must not throw even though there is no real path behind it.
|
||||
writer.Started("s1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PasswordNeverAppearsInTheStatusStream()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create();
|
||||
var writer = new SessionStatusWriter(file.Path);
|
||||
|
||||
writer.Started("bot");
|
||||
writer.Connected("bot");
|
||||
writer.CharacterList(
|
||||
"bot",
|
||||
new LiveSessionRosterReport(
|
||||
"account-name",
|
||||
11,
|
||||
[new LiveSessionRosterEntry(0x50000001u, "Ready", 0u)]));
|
||||
writer.EnteredWorld("bot", 0x50000001u, "Ready");
|
||||
writer.Disconnected("bot", "stopped");
|
||||
writer.Exited("bot", 0, "disposed");
|
||||
|
||||
string contents = File.ReadAllText(file.Path);
|
||||
Assert.DoesNotContain("hunter2", contents, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("password", contents, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileIsOpenedShareReadSoAConcurrentTailerCanReadWhileAppending()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create();
|
||||
var writer = new SessionStatusWriter(file.Path);
|
||||
writer.Started("s1");
|
||||
|
||||
// A concurrent reader (the launcher's tailer) must be able to open
|
||||
// the file for read while the writer holds it — FileShare.Read on
|
||||
// the writer side is what this test is pinning.
|
||||
using FileStream tailer = new(
|
||||
file.Path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite);
|
||||
using var tailerReader = new StreamReader(tailer);
|
||||
string? firstLine = tailerReader.ReadLine();
|
||||
Assert.NotNull(firstLine);
|
||||
Assert.Contains("\"started\"", firstLine);
|
||||
|
||||
// The writer keeps working while the tailer's handle is still open.
|
||||
writer.Connected("s1");
|
||||
string? secondLine = tailerReader.ReadLine();
|
||||
Assert.NotNull(secondLine);
|
||||
Assert.Contains("\"connected\"", secondLine);
|
||||
}
|
||||
|
||||
private static JsonElement Parse(string line) =>
|
||||
JsonDocument.Parse(line).RootElement;
|
||||
|
||||
private sealed class TemporaryFile : IDisposable
|
||||
{
|
||||
private TemporaryFile(string path) => Path = path;
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
internal static TemporaryFile Create()
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-status-{Guid.NewGuid():N}.jsonl");
|
||||
return new TemporaryFile(path);
|
||||
}
|
||||
|
||||
/// <summary>A path that is never actually created — used by the
|
||||
/// no-op test to assert the writer truly never touches disk.</summary>
|
||||
internal static TemporaryFile Reserve() => Create();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(Path))
|
||||
File.Delete(Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -72,7 +72,9 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
|
|||
host,
|
||||
port,
|
||||
connectingUser),
|
||||
_operations.RecordConnected),
|
||||
_operations.RecordConnected,
|
||||
_operations.RecordRoster,
|
||||
_operations.RecordCharacterEntered),
|
||||
new LiveSessionConnectOptions(
|
||||
true,
|
||||
"127.0.0.1",
|
||||
|
|
@ -693,6 +695,13 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
|
|||
Trace.Add($"connecting:{host}:{port}:{user}");
|
||||
|
||||
public void RecordConnected() => Trace.Add("connected");
|
||||
|
||||
public void RecordRoster(LiveSessionRosterReport roster) =>
|
||||
Trace.Add($"roster:{roster.AccountName}");
|
||||
|
||||
public void RecordCharacterEntered(
|
||||
LiveSessionCharacterSelection selection) =>
|
||||
Trace.Add($"character-entered:{selection.CharacterId}");
|
||||
}
|
||||
|
||||
private sealed class FixtureTransport : IWorldSessionTransport
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "shared-fixture",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "sharedaccount",
|
||||
"character": { "name": "SharedToon" },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": {
|
||||
"provider": "environment",
|
||||
"reference": "SHARED_FIXTURE_PASSWORD"
|
||||
},
|
||||
"plugins": ["ExamplePlugin", "AnotherPlugin"],
|
||||
"loginCommands": ["/tell someone, hi", "/vt start"],
|
||||
"loginCommandDelayMs": 750,
|
||||
"statusFile": "shared-fixture-status.jsonl"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue