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)
|
## LA1 — launch contract (client side)
|
||||||
|
|
||||||
|
### Pinned launch-contract schema (v1, BINDING — committed per LA3 review)
|
||||||
|
|
||||||
|
This text is the single source of truth for the launcher↔host file
|
||||||
|
contract. Both host readers (LA1), the composer (LA3), and the probe
|
||||||
|
loader (LA2) implement EXACTLY this; any change is an amendment to THIS
|
||||||
|
section first, implementations second. The LA1+LA3 merge adds a
|
||||||
|
cross-assembly test feeding a composer-produced document to both host
|
||||||
|
loaders — that test is the seam's permanent enforcement.
|
||||||
|
|
||||||
|
Session-config document (System.Text.Json, camelCase,
|
||||||
|
`UnmappedMemberHandling.Disallow`, camelCase string enums):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"process": {
|
||||||
|
"content": { "datDirectory": "...", "preparedAssetPath": "..." }
|
||||||
|
},
|
||||||
|
"sessions": [{
|
||||||
|
"id": "sess-1",
|
||||||
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||||
|
"account": "testaccount",
|
||||||
|
"mode": "probe",
|
||||||
|
"character": { "id": 1342177290 },
|
||||||
|
"policy": { "id": "idle" },
|
||||||
|
"credential": { "provider": "standardInput", "reference": "session" },
|
||||||
|
"plugins": ["ExamplePlugin"],
|
||||||
|
"loginCommands": ["/vt start"],
|
||||||
|
"loginCommandDelayMs": 500,
|
||||||
|
"statusFile": ".../launcher/sessions/sess-1/status.jsonl"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field rules:
|
||||||
|
- `process.paths` is OMITTED unless a caller genuinely supplies overrides
|
||||||
|
(never an empty object — the App reader has no `paths` member and
|
||||||
|
strict parsing rejects unknown keys; LA3 review finding 1).
|
||||||
|
- `mode`: ABSENT for normal play sessions; `"probe"` for the LA2 probe
|
||||||
|
(connect → characterList → graceful disconnect, no EnterWorld). The
|
||||||
|
headless loader accepts the field starting at LA2.
|
||||||
|
- `character`: exactly ONE of index|id|name; OMITTED entirely (not null)
|
||||||
|
for guiSelect and for probe sessions.
|
||||||
|
- `policy`: `{ "id": "idle" }` for headless play sessions ONLY; omitted
|
||||||
|
for gui/guiSelect/probe.
|
||||||
|
- `credential`: always `{ "provider": "standardInput", "reference":
|
||||||
|
"session" }` for launcher-composed configs.
|
||||||
|
- `plugins`/`loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
|
||||||
|
omitted-when-unset (never null, never `[]` for empty). Absent
|
||||||
|
`loginCommandDelayMs` means 500.
|
||||||
|
|
||||||
|
Status stream (`statusFile`, one JSON object per line, writer flushes per
|
||||||
|
line, writer opens `FileShare.Read`, tailer opens
|
||||||
|
`Read/FileShare.ReadWrite|Delete`): events `started`, `connected`,
|
||||||
|
`characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}`,
|
||||||
|
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
|
||||||
|
`pluginFailed{plugin,error}`, `disconnected{reason}`,
|
||||||
|
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
|
||||||
|
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
|
||||||
|
sides. Unknown `e` values must parse to a typed Unknown event, never
|
||||||
|
throw; a known `e` with a wrong payload shape should be distinguishable
|
||||||
|
from an unknown `e` (LA3 review finding 12).
|
||||||
|
|
||||||
Three pieces, one slice, because they share the session-config/status seam:
|
Three pieces, one slice, because they share the session-config/status seam:
|
||||||
|
|
||||||
1. **App `--session-config <path>`:** parsed once in `Program.cs` into
|
1. **App `--session-config <path>`:** parsed once in `Program.cs` into
|
||||||
|
|
@ -416,7 +479,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|
||||||
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
|
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
|
||||||
| LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields |
|
| LA1 | in flight (Sonnet) | | | pinned contract v1 + 5 optional fields |
|
||||||
| LA2 | — | | | |
|
| LA2 | — | | | |
|
||||||
| LA3 | implemented (`37d74e44`, campaign-la3); Opus review in flight | `37d74e44` | review in flight | 71/71 Windows AND 71/71 WSL (0600 test real on Linux); reviewer checking contract token-fidelity + tailer fix |
|
| LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge |
|
||||||
| LA4 | — | | | |
|
| LA4 | — | | | |
|
||||||
| LA5 | — | | | |
|
| LA5 | — | | | |
|
||||||
| LA6 | — | | | |
|
| LA6 | — | | | |
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies(
|
||||||
CombatAttackOperationsSlot CombatAttackOperations,
|
CombatAttackOperationsSlot CombatAttackOperations,
|
||||||
CombatFeedbackSlot CombatFeedback,
|
CombatFeedbackSlot CombatFeedback,
|
||||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||||
Action<string> Log)
|
Action<string> Log,
|
||||||
|
/// <summary>Campaign LA slice LA1: the shared per-session status-event
|
||||||
|
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
|
||||||
|
/// not configured.</summary>
|
||||||
|
SessionStatusWriter StatusWriter)
|
||||||
{
|
{
|
||||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||||
|
|
||||||
|
|
@ -1124,7 +1128,9 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
acceptedPositionDrive,
|
acceptedPositionDrive,
|
||||||
remotePlacementDrive),
|
remotePlacementDrive),
|
||||||
liveSessionCommands,
|
liveSessionCommands,
|
||||||
d.Log);
|
d.Log,
|
||||||
|
d.StatusWriter,
|
||||||
|
d.Options.SessionId ?? "app");
|
||||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
||||||
liveSession,
|
liveSession,
|
||||||
new LiveSessionConnectOptions(
|
new LiveSessionConnectOptions(
|
||||||
|
|
@ -1132,7 +1138,8 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
d.Options.LiveHost,
|
d.Options.LiveHost,
|
||||||
d.Options.LivePort,
|
d.Options.LivePort,
|
||||||
d.Options.LiveUser ?? string.Empty,
|
d.Options.LiveUser ?? string.Empty,
|
||||||
d.Options.LivePass ?? string.Empty));
|
d.Options.LivePass ?? string.Empty,
|
||||||
|
d.Options.LiveCharacterSelector));
|
||||||
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
||||||
|
|
||||||
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
namespace AcDream.App.Composition;
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
internal sealed record SessionStartDependencies(
|
internal sealed record SessionStartDependencies(
|
||||||
Action<string> Log);
|
Action<string> Log,
|
||||||
|
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
|
||||||
|
/// configured.</summary>
|
||||||
|
SessionStatusWriter StatusWriter,
|
||||||
|
string SessionId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Terminal startup phase. Every callback, command target, and frame root is
|
/// Terminal startup phase. Every callback, command target, and frame root is
|
||||||
|
|
@ -21,6 +26,9 @@ internal sealed class SessionStartCompositionPhase
|
||||||
public void Start(FrameRootResult frame)
|
public void Start(FrameRootResult frame)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(frame);
|
ArgumentNullException.ThrowIfNull(frame);
|
||||||
|
// Campaign LA slice LA1: "started" = session host start — the
|
||||||
|
// earliest point the graphical host actually attempts to connect.
|
||||||
|
_dependencies.StatusWriter.Started(_dependencies.SessionId);
|
||||||
RuntimeSessionStartResult result =
|
RuntimeSessionStartResult result =
|
||||||
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
|
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
|
||||||
Report(result, _dependencies.Log);
|
Report(result, _dependencies.Log);
|
||||||
|
|
|
||||||
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 LiveSessionCommandSurface _commands;
|
||||||
private readonly Action<string> _log;
|
private readonly Action<string> _log;
|
||||||
private readonly LiveMovementStatsApplier _movementStats;
|
private readonly LiveMovementStatsApplier _movementStats;
|
||||||
|
private readonly SessionStatusWriter _statusWriter;
|
||||||
|
private readonly string _sessionId;
|
||||||
|
|
||||||
public LiveSessionRuntimeFactory(
|
public LiveSessionRuntimeFactory(
|
||||||
LiveSessionPlayerRuntime player,
|
LiveSessionPlayerRuntime player,
|
||||||
|
|
@ -112,7 +114,9 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
LiveSessionInteractionRuntime interaction,
|
LiveSessionInteractionRuntime interaction,
|
||||||
LiveSessionWorldRuntime world,
|
LiveSessionWorldRuntime world,
|
||||||
LiveSessionCommandSurface commands,
|
LiveSessionCommandSurface commands,
|
||||||
Action<string> log)
|
Action<string> log,
|
||||||
|
SessionStatusWriter? statusWriter = null,
|
||||||
|
string sessionId = "app")
|
||||||
{
|
{
|
||||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||||
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
||||||
|
|
@ -122,6 +126,10 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||||
|
// Campaign LA slice LA1: a no-op instance when the caller has no
|
||||||
|
// status file configured — every call site below stays unconditional.
|
||||||
|
_statusWriter = statusWriter ?? new SessionStatusWriter(null);
|
||||||
|
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||||
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
||||||
// typed application seam; App keeps zero direct controller mutations.
|
// typed application seam; App keeps zero direct controller mutations.
|
||||||
_movementStats = new LiveMovementStatsApplier(
|
_movementStats = new LiveMovementStatsApplier(
|
||||||
|
|
@ -176,9 +184,17 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
$"connecting to {host}:{port} as {user}",
|
$"connecting to {host}:{port} as {user}",
|
||||||
chatType: 1),
|
chatType: 1),
|
||||||
Connected: () =>
|
Connected: () =>
|
||||||
|
{
|
||||||
_domain.Communication.Chat.OnSystemMessage(
|
_domain.Communication.Chat.OnSystemMessage(
|
||||||
"connected — character list received",
|
"connected — character list received",
|
||||||
chatType: 1)),
|
chatType: 1);
|
||||||
|
_statusWriter.Connected(_sessionId);
|
||||||
|
},
|
||||||
|
Roster: roster => _statusWriter.CharacterList(_sessionId, roster),
|
||||||
|
CharacterEntered: selection => _statusWriter.EnteredWorld(
|
||||||
|
_sessionId,
|
||||||
|
selection.CharacterId,
|
||||||
|
selection.CharacterName)),
|
||||||
connectOptions);
|
connectOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.Platform;
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
|
@ -10,6 +11,21 @@ internal enum GraphicalHostOperatingSystem
|
||||||
Linux,
|
Linux,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: a <c>[SupportedOSPlatformGuard]</c>-annotated
|
||||||
|
/// runtime-OS check, for code OUTSIDE <c>Platform/</c> that needs a
|
||||||
|
/// CA1416-recognized guard around a Linux-only API (e.g.
|
||||||
|
/// <c>AppCredentialResolver</c>'s <c>File.GetUnixFileMode</c> call) without
|
||||||
|
/// re-detecting the OS itself — <c>LinuxPlatformBoundaryTests
|
||||||
|
/// .OperatingSystemChecksRemainInsidePlatformOwners</c> requires every such
|
||||||
|
/// check to live under this folder.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RuntimePlatformGuard
|
||||||
|
{
|
||||||
|
[SupportedOSPlatformGuard("linux")]
|
||||||
|
internal static bool IsLinuxRuntime => System.OperatingSystem.IsLinux();
|
||||||
|
}
|
||||||
|
|
||||||
internal sealed record GraphicalNativeDependency(
|
internal sealed record GraphicalNativeDependency(
|
||||||
string Feature,
|
string Feature,
|
||||||
string PublishedFileName);
|
string PublishedFileName);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using AcDream.App;
|
using AcDream.App;
|
||||||
|
using AcDream.App.Configuration;
|
||||||
|
using AcDream.App.Credentials;
|
||||||
using AcDream.App.Plugins;
|
using AcDream.App.Plugins;
|
||||||
using AcDream.App.Platform;
|
using AcDream.App.Platform;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
|
|
@ -32,17 +34,96 @@ Log.Information(
|
||||||
dependency =>
|
dependency =>
|
||||||
$"{dependency.Feature}={dependency.PublishedFileName}")));
|
$"{dependency.Feature}={dependency.PublishedFileName}")));
|
||||||
|
|
||||||
var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
// Campaign LA slice LA1: --session-config <path> is purely additive — the
|
||||||
if (string.IsNullOrWhiteSpace(datDir))
|
// existing one positional dat-dir argument and every ACDREAM_* env var keep
|
||||||
{
|
// working exactly as before when the flag is absent. See
|
||||||
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
|
// docs/plans/2026-08-14-launcher-campaign.md LA1.
|
||||||
return 2;
|
string? sessionConfigFlagPath = ExtractFlagValue(args, "--session-config");
|
||||||
}
|
string[] positionalArgs = WithoutFlagAndValue(args, "--session-config");
|
||||||
|
|
||||||
|
var datDirArg = positionalArgs.FirstOrDefault();
|
||||||
|
var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||||
|
|
||||||
// Single read of the startup-time process environment. Every downstream
|
// Single read of the startup-time process environment. Every downstream
|
||||||
// consumer (GameWindow + collaborators) reads the typed bundle, not the
|
// consumer (GameWindow + collaborators) reads the typed bundle, not the
|
||||||
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
|
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
|
||||||
var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
RuntimeOptions runtimeOptions;
|
||||||
|
if (sessionConfigFlagPath is not null)
|
||||||
|
{
|
||||||
|
SessionConfiguration sessionConfig;
|
||||||
|
SessionDescriptor session;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(sessionConfig, session) = SessionConfigurationLoader.Load(sessionConfigFlagPath);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
when (error is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or ArgumentException
|
||||||
|
or NotSupportedException
|
||||||
|
or System.Text.Json.JsonException
|
||||||
|
or SessionConfigurationException)
|
||||||
|
{
|
||||||
|
Log.Error("--session-config invalid: {Error}", error.Message);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? resolvedDatDir =
|
||||||
|
NullIfEmpty(sessionConfig.Process?.Content?.DatDirectory)
|
||||||
|
?? NullIfEmpty(datDirArg)
|
||||||
|
?? NullIfEmpty(envDatDir);
|
||||||
|
if (resolvedDatDir is null)
|
||||||
|
{
|
||||||
|
Log.Error(
|
||||||
|
"usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR, "
|
||||||
|
+ "or supply process.content.datDirectory in --session-config)");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppCredentialSecret? secret = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolver = new AppCredentialResolver(
|
||||||
|
Console.In,
|
||||||
|
applicationPaths.ConfigDirectory,
|
||||||
|
graphicalPlatform.OperatingSystem
|
||||||
|
== GraphicalHostOperatingSystem.Linux);
|
||||||
|
secret = resolver.Resolve(session.Id, session.Credential);
|
||||||
|
runtimeOptions = RuntimeOptions.FromSessionConfig(
|
||||||
|
resolvedDatDir,
|
||||||
|
Environment.GetEnvironmentVariable,
|
||||||
|
sessionConfigFlagPath,
|
||||||
|
sessionConfig,
|
||||||
|
session,
|
||||||
|
secret.Reveal());
|
||||||
|
}
|
||||||
|
catch (AppCredentialException error)
|
||||||
|
{
|
||||||
|
Log.Error("--session-config credential unavailable: {Error}", error.Message);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
secret?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env-var flow untouched when the flag is absent; when both are present
|
||||||
|
// the flag wins — this line makes that explicit rather than silent.
|
||||||
|
Log.Information(
|
||||||
|
"--session-config {Path} present; overriding ACDREAM_LIVE*/ACDREAM_TEST_* "
|
||||||
|
+ "env-var live-session settings",
|
||||||
|
sessionConfigFlagPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var datDir = datDirArg ?? envDatDir;
|
||||||
|
if (string.IsNullOrWhiteSpace(datDir))
|
||||||
|
{
|
||||||
|
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||||
|
}
|
||||||
|
|
||||||
if (runtimeOptions.DevTools)
|
if (runtimeOptions.DevTools)
|
||||||
{
|
{
|
||||||
|
|
@ -158,3 +239,35 @@ finally
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
|
// Campaign LA slice LA1: --session-config <path> parsing helpers. Kept
|
||||||
|
// local/minimal rather than a general-purpose CLI parser — App has exactly
|
||||||
|
// one optional flag-with-value today; the positional dat-dir argument must
|
||||||
|
// stay untouched by its presence (see the comment above the flag parse).
|
||||||
|
static string? ExtractFlagValue(string[] arguments, string flag)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < arguments.Length - 1; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
||||||
|
return arguments[i + 1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string[] WithoutFlagAndValue(string[] arguments, string flag)
|
||||||
|
{
|
||||||
|
var result = new List<string>(arguments.Length);
|
||||||
|
for (int i = 0; i < arguments.Length; i++)
|
||||||
|
{
|
||||||
|
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
i++; // also skip the flag's value
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.Add(arguments[i]);
|
||||||
|
}
|
||||||
|
return [.. result];
|
||||||
|
}
|
||||||
|
|
||||||
|
static string? NullIfEmpty(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ public sealed class GameWindow :
|
||||||
/ (double)System.Diagnostics.Stopwatch.Frequency;
|
/ (double)System.Diagnostics.Stopwatch.Frequency;
|
||||||
|
|
||||||
private readonly AcDream.App.RuntimeOptions _options;
|
private readonly AcDream.App.RuntimeOptions _options;
|
||||||
|
// Campaign LA slice LA1: no-op instance when --session-config didn't
|
||||||
|
// configure a statusFile (or the env-var launch path was used at all).
|
||||||
|
private readonly SessionStatusWriter _statusWriter;
|
||||||
private readonly AnimationPresentationDiagnostics _animationDiagnostics;
|
private readonly AnimationPresentationDiagnostics _animationDiagnostics;
|
||||||
private readonly string _datDir;
|
private readonly string _datDir;
|
||||||
private readonly WorldGameState _worldGameState;
|
private readonly WorldGameState _worldGameState;
|
||||||
|
|
@ -615,6 +618,7 @@ public sealed class GameWindow :
|
||||||
GraphicalHostPlatformServices platformServices)
|
GraphicalHostPlatformServices platformServices)
|
||||||
{
|
{
|
||||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||||
|
_statusWriter = new SessionStatusWriter(options.StatusFilePath);
|
||||||
_platformServices = platformServices
|
_platformServices = platformServices
|
||||||
?? throw new ArgumentNullException(nameof(platformServices));
|
?? throw new ArgumentNullException(nameof(platformServices));
|
||||||
_applicationPaths = _platformServices.Paths;
|
_applicationPaths = _platformServices.Paths;
|
||||||
|
|
@ -1489,7 +1493,8 @@ public sealed class GameWindow :
|
||||||
_combatAttackOperations,
|
_combatAttackOperations,
|
||||||
_combatFeedback,
|
_combatFeedback,
|
||||||
_portalTunnelFallback,
|
_portalTunnelFallback,
|
||||||
Console.WriteLine),
|
Console.WriteLine,
|
||||||
|
_statusWriter),
|
||||||
this).Compose(
|
this).Compose(
|
||||||
hostInputCamera,
|
hostInputCamera,
|
||||||
contentEffectsAudio,
|
contentEffectsAudio,
|
||||||
|
|
@ -1548,7 +1553,10 @@ public sealed class GameWindow :
|
||||||
livePresentation,
|
livePresentation,
|
||||||
sessionPlayer),
|
sessionPlayer),
|
||||||
frameRoots => new SessionStartCompositionPhase(
|
frameRoots => new SessionStartCompositionPhase(
|
||||||
new SessionStartDependencies(Console.WriteLine))
|
new SessionStartDependencies(
|
||||||
|
Console.WriteLine,
|
||||||
|
_statusWriter,
|
||||||
|
_options.SessionId ?? "app"))
|
||||||
.Start(frameRoots));
|
.Start(frameRoots));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1636,13 +1644,30 @@ public sealed class GameWindow :
|
||||||
private void CompleteShutdown(bool releaseNativeWindow)
|
private void CompleteShutdown(bool releaseNativeWindow)
|
||||||
{
|
{
|
||||||
if (!_lifetime.HasShutdownRoots)
|
if (!_lifetime.HasShutdownRoots)
|
||||||
|
{
|
||||||
|
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
|
||||||
|
// by the time teardown completes, IsInWorld is always false
|
||||||
|
// regardless of whether a real session was ever connected.
|
||||||
|
// OnClosing() and Dispose() both funnel through this method;
|
||||||
|
// HasShutdownRoots's own guard means this fires exactly once,
|
||||||
|
// from whichever of the two reaches it first.
|
||||||
|
if (_runtime.Session.IsInWorld)
|
||||||
|
_statusWriter.Disconnected(_options.SessionId ?? "app", "stopped");
|
||||||
_lifetime.PublishShutdownRoots(CaptureShutdownRoots());
|
_lifetime.PublishShutdownRoots(CaptureShutdownRoots());
|
||||||
|
}
|
||||||
|
|
||||||
GameWindowLifetimeReport report = releaseNativeWindow
|
GameWindowLifetimeReport report = releaseNativeWindow
|
||||||
? _lifetime.CompleteAndReleaseNativeWindow()
|
? _lifetime.CompleteAndReleaseNativeWindow()
|
||||||
: _lifetime.TryComplete();
|
: _lifetime.TryComplete();
|
||||||
if (report.Status == GameWindowLifetimeStatus.Complete)
|
if (report.Status == GameWindowLifetimeStatus.Complete)
|
||||||
|
{
|
||||||
|
// "exited" = terminal — only the true Dispose() call (not the
|
||||||
|
// OnClosing() native-window-close-request pass) represents the
|
||||||
|
// process actually being done.
|
||||||
|
if (releaseNativeWindow)
|
||||||
|
_statusWriter.Exited(_options.SessionId ?? "app", 0, "disposed");
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
|
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
|
||||||
|
|
@ -1655,6 +1680,14 @@ public sealed class GameWindow :
|
||||||
|
|
||||||
if (report.Error is not null)
|
if (report.Error is not null)
|
||||||
Console.Error.WriteLine($"[shutdown] {report.Error}");
|
Console.Error.WriteLine($"[shutdown] {report.Error}");
|
||||||
|
|
||||||
|
if (releaseNativeWindow)
|
||||||
|
{
|
||||||
|
_statusWriter.Exited(
|
||||||
|
_options.SessionId ?? "app",
|
||||||
|
1,
|
||||||
|
"shutdown-incomplete");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private GameWindowShutdownRoots CaptureShutdownRoots() => new(
|
private GameWindowShutdownRoots CaptureShutdownRoots() => new(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using AcDream.App.Configuration;
|
||||||
using AcDream.App.Rendering.Residency;
|
using AcDream.App.Rendering.Residency;
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
namespace AcDream.App;
|
namespace AcDream.App;
|
||||||
|
|
||||||
|
|
@ -62,7 +65,34 @@ public sealed record RuntimeOptions(
|
||||||
string? VulkanDeviceOverride,
|
string? VulkanDeviceOverride,
|
||||||
string? VulkanForcedUnsupportedFeature,
|
string? VulkanForcedUnsupportedFeature,
|
||||||
bool VulkanCapabilityProbe,
|
bool VulkanCapabilityProbe,
|
||||||
int VulkanCapabilityProbeFrames)
|
int VulkanCapabilityProbeFrames,
|
||||||
|
/// <summary>Campaign LA slice LA1: the raw <c>--session-config</c> path,
|
||||||
|
/// or <see langword="null"/> when the flag was not supplied (the env-var
|
||||||
|
/// dev flow). Kept for diagnostics/logging only.</summary>
|
||||||
|
string? SessionConfigPath,
|
||||||
|
/// <summary>Campaign LA slice LA1: the configured session's id, used as
|
||||||
|
/// the <c>sessionId</c> field on every status-stream event. Defaults to
|
||||||
|
/// <c>"app"</c> at every call site when unset (env-var flow).</summary>
|
||||||
|
string? SessionId,
|
||||||
|
/// <summary>Campaign LA slice LA1: the session-config character
|
||||||
|
/// selector, or <see langword="null"/> for today's existing
|
||||||
|
/// first-available fallback (absent selector = LA7's char-select screen
|
||||||
|
/// stop point once that slice lands; this slice does not build the
|
||||||
|
/// screen).</summary>
|
||||||
|
LiveSessionCharacterSelector? LiveCharacterSelector,
|
||||||
|
/// <summary>Campaign LA slice LA1: absolute path for the status-event
|
||||||
|
/// JSONL stream. <see langword="null"/> = no writer constructed.</summary>
|
||||||
|
string? StatusFilePath,
|
||||||
|
/// <summary>Campaign LA slice LA1: plugin ids to load.
|
||||||
|
/// <see langword="null"/> = load every discovered plugin (today's
|
||||||
|
/// behavior). Consumed by LA5; parsed and carried now.</summary>
|
||||||
|
IReadOnlyList<string>? Plugins,
|
||||||
|
/// <summary>Campaign LA slice LA1: ordered chat-typed strings run once
|
||||||
|
/// entered-world. Consumed by LA6; parsed and carried now.</summary>
|
||||||
|
IReadOnlyList<string> LoginCommands,
|
||||||
|
/// <summary>Campaign LA slice LA1: inter-command delay for
|
||||||
|
/// <see cref="LoginCommands"/>, milliseconds.</summary>
|
||||||
|
int LoginCommandDelayMs)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build options from the process environment. Used by
|
/// Build options from the process environment. Used by
|
||||||
|
|
@ -170,9 +200,72 @@ public sealed record RuntimeOptions(
|
||||||
// closes the window. Zero -- unset, unparseable, or an explicit 0 --
|
// closes the window. Zero -- unset, unparseable, or an explicit 0 --
|
||||||
// keeps the interactive behaviour, so no existing invocation changes.
|
// keeps the interactive behaviour, so no existing invocation changes.
|
||||||
VulkanCapabilityProbeFrames:
|
VulkanCapabilityProbeFrames:
|
||||||
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0);
|
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0,
|
||||||
|
// Campaign LA slice LA1: the env-var dev flow never carries a
|
||||||
|
// session-config document — every new field below stays at its
|
||||||
|
// "nothing configured" default. RuntimeOptions.FromSessionConfig
|
||||||
|
// overlays the real values on top of this base.
|
||||||
|
SessionConfigPath: null,
|
||||||
|
SessionId: null,
|
||||||
|
LiveCharacterSelector: null,
|
||||||
|
StatusFilePath: null,
|
||||||
|
Plugins: null,
|
||||||
|
LoginCommands: [],
|
||||||
|
LoginCommandDelayMs: 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: builds options for the <c>--session-config</c>
|
||||||
|
/// launch path. Starts from the same env-var parse as
|
||||||
|
/// <see cref="FromEnvironment"/> (diagnostic/dev flags are still
|
||||||
|
/// env-controlled — only the LIVE session settings and the five new LA1
|
||||||
|
/// fields come from the document) and overlays the resolved session.
|
||||||
|
/// <paramref name="resolvedPassword"/> is revealed into
|
||||||
|
/// <see cref="LivePass"/> exactly as wide as the existing env-var flow —
|
||||||
|
/// see that field's own doc.
|
||||||
|
/// </summary>
|
||||||
|
internal static RuntimeOptions FromSessionConfig(
|
||||||
|
string datDir,
|
||||||
|
Func<string, string?> env,
|
||||||
|
string sessionConfigPath,
|
||||||
|
SessionConfiguration config,
|
||||||
|
SessionDescriptor session,
|
||||||
|
string? resolvedPassword)
|
||||||
|
{
|
||||||
|
if (config is null) throw new ArgumentNullException(nameof(config));
|
||||||
|
if (session is null) throw new ArgumentNullException(nameof(session));
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(sessionConfigPath);
|
||||||
|
|
||||||
|
RuntimeOptions baseOptions = Parse(datDir, env);
|
||||||
|
SessionContentDescriptor? content = config.Process?.Content;
|
||||||
|
return baseOptions with
|
||||||
|
{
|
||||||
|
PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath)
|
||||||
|
?? baseOptions.PreparedAssetPath,
|
||||||
|
LiveMode = true,
|
||||||
|
LiveHost = session.Endpoint.Host,
|
||||||
|
LivePort = session.Endpoint.Port,
|
||||||
|
LiveUser = session.Account,
|
||||||
|
LivePass = resolvedPassword,
|
||||||
|
SessionConfigPath = sessionConfigPath,
|
||||||
|
SessionId = session.Id,
|
||||||
|
LiveCharacterSelector = MapCharacterSelector(session.Character),
|
||||||
|
StatusFilePath = NullIfEmpty(session.StatusFile),
|
||||||
|
Plugins = session.Plugins,
|
||||||
|
LoginCommands = (IReadOnlyList<string>?)session.LoginCommands ?? [],
|
||||||
|
LoginCommandDelayMs = session.LoginCommandDelayMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LiveSessionCharacterSelector? MapCharacterSelector(
|
||||||
|
SessionCharacterSelectorDescriptor? selector) =>
|
||||||
|
selector is null
|
||||||
|
? null
|
||||||
|
: new LiveSessionCharacterSelector(
|
||||||
|
selector.Index,
|
||||||
|
selector.Id,
|
||||||
|
selector.Name);
|
||||||
|
|
||||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||||
public bool HasLiveCredentials =>
|
public bool HasLiveCredentials =>
|
||||||
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor
|
||||||
/// legal no-ops.
|
/// legal no-ops.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
|
||||||
|
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
|
||||||
|
/// Absent means load every discovered plugin (today's dev behavior);
|
||||||
|
/// LA5 wires this into an actual allow-list filter. Parsed and carried
|
||||||
|
/// here now so the session-config shape is stable before LA5 lands.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? Plugins { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: ordered chat-typed strings run once the
|
||||||
|
/// session enters world. LA6 wires actual execution; parsed and carried
|
||||||
|
/// here now.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? LoginCommands { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: inter-command delay for
|
||||||
|
/// <see cref="LoginCommands"/>, in milliseconds. Matches the pinned
|
||||||
|
/// launch-contract default (500 ms) when the field is absent from the
|
||||||
|
/// document.
|
||||||
|
/// </summary>
|
||||||
|
public int LoginCommandDelayMs { get; init; } = 500;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: absolute path for this session's status-event
|
||||||
|
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
|
||||||
|
/// §6). Absent means no <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>
|
||||||
|
/// is constructed for this session.
|
||||||
|
/// </summary>
|
||||||
|
public string? StatusFile { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class HeadlessEndpointDescriptor
|
internal sealed class HeadlessEndpointDescriptor
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidateCharacterOptions(session);
|
ValidateCharacterOptions(session);
|
||||||
|
ValidateLaunchContractFields(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: validates the four new optional per-session
|
||||||
|
/// fields shared with the App session-config reader (see
|
||||||
|
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's pinned
|
||||||
|
/// contract). All four stay optional; only their SHAPE is checked here
|
||||||
|
/// — parsing/executing <c>plugins</c>/<c>loginCommands</c> is LA5/LA6.
|
||||||
|
/// </summary>
|
||||||
|
private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session)
|
||||||
|
{
|
||||||
|
if (session.Plugins is { } plugins)
|
||||||
|
{
|
||||||
|
foreach (string? plugin in plugins)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(plugin))
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
$"Session '{session.Id}' plugins entries must be non-empty strings.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.LoginCommandDelayMs < 0)
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.StatusFile is not null
|
||||||
|
&& string.IsNullOrWhiteSpace(session.StatusFile))
|
||||||
|
{
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,19 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
private readonly HeadlessCredentialSecret _credential;
|
private readonly HeadlessCredentialSecret _credential;
|
||||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: a SEPARATE per-session sink from
|
||||||
|
/// <see cref="_diagnostics"/> — a no-op instance when
|
||||||
|
/// <see cref="HeadlessSessionDescriptor.StatusFile"/> was not configured.
|
||||||
|
/// See <see cref="SessionStatusWriter"/>'s own doc for why this is not a
|
||||||
|
/// rework of the shared-stdout diagnostics writer.
|
||||||
|
/// </summary>
|
||||||
|
private readonly SessionStatusWriter _statusWriter;
|
||||||
|
/// <summary>Guards <see cref="Stop"/>'s <c>disconnected</c> status event
|
||||||
|
/// so a Stop() on a session that never actually reached Connected (e.g.
|
||||||
|
/// disposing a fresh, never-started host) does not report a spurious
|
||||||
|
/// disconnect.</summary>
|
||||||
|
private bool _hasConnected;
|
||||||
|
/// <summary>
|
||||||
/// Campaign OP slice OP7 (2026-08-11), D8: the parsed
|
/// Campaign OP slice OP7 (2026-08-11), D8: the parsed
|
||||||
/// <c>characterOptions</c> block — empty when the config omitted it.
|
/// <c>characterOptions</c> block — empty when the config omitted it.
|
||||||
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
|
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
|
||||||
|
|
@ -271,6 +284,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
var commands = new DirectGameRuntimeCommandAdapter(
|
var commands = new DirectGameRuntimeCommandAdapter(
|
||||||
runtime,
|
runtime,
|
||||||
bridge);
|
bridge);
|
||||||
|
// Campaign LA slice LA1: no-op instance when
|
||||||
|
// descriptor.StatusFile is unset — every call site below stays
|
||||||
|
// unconditional.
|
||||||
|
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
||||||
var liveSession = new LiveSessionHost(
|
var liveSession = new LiveSessionHost(
|
||||||
runtime.Session,
|
runtime.Session,
|
||||||
new LiveSessionHostBindings(
|
new LiveSessionHostBindings(
|
||||||
|
|
@ -318,14 +335,25 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
descriptor.Id,
|
descriptor.Id,
|
||||||
$"connecting:{host}:{port}:{user}",
|
$"connecting:{host}:{port}:{user}",
|
||||||
runtime.Generation.Value),
|
runtime.Generation.Value),
|
||||||
() => diagnostics.Message(
|
() =>
|
||||||
|
{
|
||||||
|
diagnostics.Message(
|
||||||
descriptor.Id,
|
descriptor.Id,
|
||||||
"connected",
|
"connected",
|
||||||
runtime.Generation.Value)));
|
runtime.Generation.Value);
|
||||||
|
statusWriter.Connected(descriptor.Id);
|
||||||
|
_hasConnected = true;
|
||||||
|
},
|
||||||
|
roster => statusWriter.CharacterList(descriptor.Id, roster),
|
||||||
|
selection => statusWriter.EnteredWorld(
|
||||||
|
descriptor.Id,
|
||||||
|
selection.CharacterId,
|
||||||
|
selection.CharacterName)));
|
||||||
|
|
||||||
Runtime = runtime;
|
Runtime = runtime;
|
||||||
Commands = commands;
|
Commands = commands;
|
||||||
_liveSession = liveSession;
|
_liveSession = liveSession;
|
||||||
|
_statusWriter = statusWriter;
|
||||||
_localPlayerFrame =
|
_localPlayerFrame =
|
||||||
runtime.CreateLocalPlayerFrameController(
|
runtime.CreateLocalPlayerFrameController(
|
||||||
new HeadlessLocalPlayerFrameHost(
|
new HeadlessLocalPlayerFrameHost(
|
||||||
|
|
@ -421,8 +449,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_pendingConfirmation = null;
|
_pendingConfirmation = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal RuntimeSessionStartResult Start() =>
|
internal RuntimeSessionStartResult Start()
|
||||||
Commands.Session.Start(Runtime.Generation);
|
{
|
||||||
|
// Campaign LA slice LA1: "started" = session host start — the
|
||||||
|
// earliest point this session actually attempts to connect.
|
||||||
|
_statusWriter.Started(_descriptor.Id);
|
||||||
|
return Commands.Session.Start(Runtime.Generation);
|
||||||
|
}
|
||||||
|
|
||||||
internal RuntimeSessionStartResult Reconnect() =>
|
internal RuntimeSessionStartResult Reconnect() =>
|
||||||
Commands.Session.Reconnect(Runtime.Generation);
|
Commands.Session.Reconnect(Runtime.Generation);
|
||||||
|
|
@ -470,6 +503,15 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
// (possibly disposed) WorldSession in the window between this Stop
|
// (possibly disposed) WorldSession in the window between this Stop
|
||||||
// and the next CreateEventRoute call.
|
// and the next CreateEventRoute call.
|
||||||
_currentSession = null;
|
_currentSession = null;
|
||||||
|
// Campaign LA slice LA1: only report a disconnect for a session that
|
||||||
|
// actually reached Connected — a Stop() on a never-started or
|
||||||
|
// never-connected host (e.g. immediate Dispose()) is not a real
|
||||||
|
// disconnect.
|
||||||
|
if (_hasConnected)
|
||||||
|
{
|
||||||
|
_hasConnected = false;
|
||||||
|
_statusWriter.Disconnected(_descriptor.Id, "stopped");
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,6 +634,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
_descriptor.Id,
|
_descriptor.Id,
|
||||||
"disposed",
|
"disposed",
|
||||||
_stoppedGeneration);
|
_stoppedGeneration);
|
||||||
|
// Campaign LA slice LA1: "exited" = terminal — the sole
|
||||||
|
// point every disposal path (graceful and post-
|
||||||
|
// quarantine) converges on.
|
||||||
|
_statusWriter.Exited(
|
||||||
|
_descriptor.Id,
|
||||||
|
_faulted ? 1 : 0,
|
||||||
|
_faulted ? "fault" : "disposed");
|
||||||
_disposeStage++;
|
_disposeStage++;
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,32 @@ public sealed record LiveSessionStartResult(
|
||||||
LiveSessionCharacterSelection? Selection = null,
|
LiveSessionCharacterSelection? Selection = null,
|
||||||
Exception? Error = null);
|
Exception? Error = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: one roster entry as reported by
|
||||||
|
/// <see cref="CharacterList.Parsed"/> — decoupled from the wire type so the
|
||||||
|
/// lifecycle-host seam does not leak <c>AcDream.Core.Net.Messages</c> shapes
|
||||||
|
/// into every consumer.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct LiveSessionRosterEntry(
|
||||||
|
uint Id,
|
||||||
|
string Name,
|
||||||
|
uint SecondsGreyedOut);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: the account's active-character roster, reported to
|
||||||
|
/// <see cref="ILiveSessionLifecycleHost.ReportRoster"/> right after
|
||||||
|
/// <c>CharacterList</c> arrives and BEFORE selection — see
|
||||||
|
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 item 2. Hosts forward
|
||||||
|
/// this to their status stream (<c>characterList</c> event) and, later
|
||||||
|
/// (LA7/LA8), to the character-select screen. Deleted characters are
|
||||||
|
/// deliberately excluded — the same candidate set
|
||||||
|
/// <see cref="CharacterList.TrySelectFirstAvailable"/> already uses.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record LiveSessionRosterReport(
|
||||||
|
string AccountName,
|
||||||
|
int SlotCount,
|
||||||
|
IReadOnlyList<LiveSessionRosterEntry> Entries);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runtime boundary for the domain and presentation sinks attached to one
|
/// Runtime boundary for the domain and presentation sinks attached to one
|
||||||
/// exact <see cref="WorldSession"/> generation. The controller owns the
|
/// exact <see cref="WorldSession"/> generation. The controller owns the
|
||||||
|
|
@ -61,6 +87,10 @@ public interface ILiveSessionLifecycleHost
|
||||||
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
|
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
|
||||||
void ReportConnecting(string host, int port, string user);
|
void ReportConnecting(string host, int port, string user);
|
||||||
void ReportConnected();
|
void ReportConnected();
|
||||||
|
/// <summary>Campaign LA slice LA1: reported once per successful
|
||||||
|
/// <c>CharacterList</c> receipt, right before character selection. See
|
||||||
|
/// <see cref="LiveSessionRosterReport"/>.</summary>
|
||||||
|
void ReportRoster(LiveSessionRosterReport roster);
|
||||||
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
||||||
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
|
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
|
||||||
void DetachSession(WorldSession session);
|
void DetachSession(WorldSession session);
|
||||||
|
|
@ -610,6 +640,13 @@ public sealed class LiveSessionController
|
||||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
|
|
||||||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||||
|
if (characters is not null)
|
||||||
|
{
|
||||||
|
host.ReportRoster(BuildRosterReport(characters));
|
||||||
|
if (!IsCurrent(scope, generation))
|
||||||
|
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||||
|
}
|
||||||
|
|
||||||
if (characters is null
|
if (characters is null
|
||||||
|| !TrySelectCharacter(
|
|| !TrySelectCharacter(
|
||||||
characters,
|
characters,
|
||||||
|
|
@ -838,6 +875,29 @@ public sealed class LiveSessionController
|
||||||
private LiveSessionStartResult ConnectedResult()
|
private LiveSessionStartResult ConnectedResult()
|
||||||
=> new(LiveSessionStartStatus.Connected, _activeSelection);
|
=> new(LiveSessionStartStatus.Connected, _activeSelection);
|
||||||
|
|
||||||
|
/// <summary>Campaign LA slice LA1: projects the wire-shaped
|
||||||
|
/// <see cref="CharacterList.Parsed"/> into the decoupled
|
||||||
|
/// <see cref="LiveSessionRosterReport"/>. Deleted characters are
|
||||||
|
/// excluded, matching <see cref="CharacterList.TrySelectFirstAvailable"/>'s
|
||||||
|
/// candidate set.</summary>
|
||||||
|
private static LiveSessionRosterReport BuildRosterReport(
|
||||||
|
CharacterList.Parsed characters)
|
||||||
|
{
|
||||||
|
var entries = new LiveSessionRosterEntry[characters.Characters.Count];
|
||||||
|
for (int i = 0; i < entries.Length; i++)
|
||||||
|
{
|
||||||
|
CharacterList.Character character = characters.Characters[i];
|
||||||
|
entries[i] = new LiveSessionRosterEntry(
|
||||||
|
character.Id,
|
||||||
|
character.Name,
|
||||||
|
character.SecondsGreyedOut);
|
||||||
|
}
|
||||||
|
return new LiveSessionRosterReport(
|
||||||
|
characters.AccountName,
|
||||||
|
characters.SlotCount,
|
||||||
|
entries);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TrySelectCharacter(
|
private static bool TrySelectCharacter(
|
||||||
CharacterList.Parsed characters,
|
CharacterList.Parsed characters,
|
||||||
LiveSessionCharacterSelector? selector,
|
LiveSessionCharacterSelector? selector,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,18 @@ public sealed record LiveSessionHostBindings(
|
||||||
LiveSessionSelectionBindings Selection,
|
LiveSessionSelectionBindings Selection,
|
||||||
LiveSessionEnteredWorldBindings EnteredWorld,
|
LiveSessionEnteredWorldBindings EnteredWorld,
|
||||||
Action<string, int, string> Connecting,
|
Action<string, int, string> Connecting,
|
||||||
Action Connected);
|
Action Connected,
|
||||||
|
/// <summary>Campaign LA slice LA1: reported once per successful
|
||||||
|
/// <c>CharacterList</c> receipt, right before character selection — see
|
||||||
|
/// <see cref="LiveSessionRosterReport"/>. Hosts forward this to their
|
||||||
|
/// status stream's <c>characterList</c> event.</summary>
|
||||||
|
Action<LiveSessionRosterReport> Roster,
|
||||||
|
/// <summary>Campaign LA slice LA1: reported once entered-world state is
|
||||||
|
/// applied, carrying the full selection (id + name) — unlike
|
||||||
|
/// <see cref="EnteredWorld"/>'s narrow <c>SetActiveCharacter(string)</c>
|
||||||
|
/// fan-out, this exists so a status writer can emit the
|
||||||
|
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
|
||||||
|
Action<LiveSessionCharacterSelection> CharacterEntered);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
|
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
|
||||||
|
|
@ -84,6 +95,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||||
private readonly LiveSessionRoutingFactories _routing;
|
private readonly LiveSessionRoutingFactories _routing;
|
||||||
private readonly LiveSessionSelectionBindings _selection;
|
private readonly LiveSessionSelectionBindings _selection;
|
||||||
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
||||||
|
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
|
||||||
private readonly Action<RuntimeGenerationToken> _reset;
|
private readonly Action<RuntimeGenerationToken> _reset;
|
||||||
private readonly LiveSessionLifecycleHost _lifecycle;
|
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||||
private PendingRouteRollback? _pendingRouteRollback;
|
private PendingRouteRollback? _pendingRouteRollback;
|
||||||
|
|
@ -100,11 +112,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||||
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
|
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
|
||||||
_enteredWorld = bindings.EnteredWorld
|
_enteredWorld = bindings.EnteredWorld
|
||||||
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
||||||
|
_characterEntered = bindings.CharacterEntered
|
||||||
|
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
|
||||||
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
||||||
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
||||||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings.Roster);
|
||||||
Validate(_selection, _enteredWorld);
|
Validate(_selection, _enteredWorld);
|
||||||
|
|
||||||
_reset = bindings.Reset;
|
_reset = bindings.Reset;
|
||||||
|
|
@ -113,6 +128,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||||
Reset: ResetSessionState,
|
Reset: ResetSessionState,
|
||||||
Connecting: bindings.Connecting,
|
Connecting: bindings.Connecting,
|
||||||
Connected: bindings.Connected,
|
Connected: bindings.Connected,
|
||||||
|
Roster: bindings.Roster,
|
||||||
Selected: ApplySelection,
|
Selected: ApplySelection,
|
||||||
Entered: ApplyEnteredWorld));
|
Entered: ApplyEnteredWorld));
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +233,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
||||||
_enteredWorld.SyncToolbar();
|
_enteredWorld.SyncToolbar();
|
||||||
_enteredWorld.LoadCharacterSettings(name);
|
_enteredWorld.LoadCharacterSettings(name);
|
||||||
_enteredWorld.ArmPlayerModeAutoEntry();
|
_enteredWorld.ArmPlayerModeAutoEntry();
|
||||||
|
_characterEntered(selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RethrowWithRetryableRollback(
|
private void RethrowWithRetryableRollback(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ public sealed record LiveSessionLifecycleBindings(
|
||||||
Action<RuntimeGenerationToken> Reset,
|
Action<RuntimeGenerationToken> Reset,
|
||||||
Action<string, int, string> Connecting,
|
Action<string, int, string> Connecting,
|
||||||
Action Connected,
|
Action Connected,
|
||||||
|
Action<LiveSessionRosterReport> Roster,
|
||||||
Action<LiveSessionCharacterSelection> Selected,
|
Action<LiveSessionCharacterSelection> Selected,
|
||||||
Action<LiveSessionCharacterSelection> Entered);
|
Action<LiveSessionCharacterSelection> Entered);
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
||||||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||||
|
ArgumentNullException.ThrowIfNull(bindings.Roster);
|
||||||
ArgumentNullException.ThrowIfNull(bindings.Selected);
|
ArgumentNullException.ThrowIfNull(bindings.Selected);
|
||||||
ArgumentNullException.ThrowIfNull(bindings.Entered);
|
ArgumentNullException.ThrowIfNull(bindings.Entered);
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +53,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
||||||
|
|
||||||
public void ReportConnected() => _bindings.Connected();
|
public void ReportConnected() => _bindings.Connected();
|
||||||
|
|
||||||
|
public void ReportRoster(LiveSessionRosterReport roster) =>
|
||||||
|
_bindings.Roster(roster);
|
||||||
|
|
||||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
|
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
|
||||||
_bindings.Selected(selection);
|
_bindings.Selected(selection);
|
||||||
|
|
||||||
|
|
|
||||||
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) { }
|
RuntimeGenerationToken retiringGeneration) { }
|
||||||
public void ReportConnecting(string host, int port, string user) { }
|
public void ReportConnecting(string host, int port, string user) { }
|
||||||
public void ReportConnected() { }
|
public void ReportConnected() { }
|
||||||
|
public void ReportRoster(LiveSessionRosterReport roster) { }
|
||||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
|
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) { }
|
||||||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { }
|
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) { }
|
||||||
public void DetachSession(WorldSession session) { }
|
public void DetachSession(WorldSession session) { }
|
||||||
|
|
|
||||||
|
|
@ -917,7 +917,9 @@ public sealed class CurrentGameRuntimeAdapterTests
|
||||||
_ => { },
|
_ => { },
|
||||||
() => { }),
|
() => { }),
|
||||||
(_, _, _) => { },
|
(_, _, _) => { },
|
||||||
() => { }),
|
() => { },
|
||||||
|
_ => { },
|
||||||
|
_ => { }),
|
||||||
new LiveSessionConnectOptions(
|
new LiveSessionConnectOptions(
|
||||||
true,
|
true,
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,9 @@ public sealed class HeadlessSessionEventRouteRetryPendingTests
|
||||||
new LiveSessionEnteredWorldBindings(
|
new LiveSessionEnteredWorldBindings(
|
||||||
_ => { }, () => { }, () => { }, _ => { }, () => { }),
|
_ => { }, () => { }, () => { }, _ => { }, () => { }),
|
||||||
(_, _, _) => { },
|
(_, _, _) => { },
|
||||||
() => { }),
|
() => { },
|
||||||
|
_ => { },
|
||||||
|
_ => { }),
|
||||||
options);
|
options);
|
||||||
LiveSessionStartResult startResult = live.Start(options);
|
LiveSessionStartResult startResult = live.Start(options);
|
||||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Collections.Immutable;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
|
|
@ -61,6 +62,95 @@ public sealed class HeadlessSessionHostTests
|
||||||
Assert.DoesNotContain("AcDream.App", diagnostics);
|
Assert.DoesNotContain("AcDream.App", diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign LA slice LA1: proves the status-event writer fires the
|
||||||
|
/// pinned lifecycle vocabulary — started/connected/characterList/
|
||||||
|
/// enteredWorld/disconnected/exited — in order, from a real
|
||||||
|
/// <see cref="HeadlessSessionHost"/> start+dispose cycle, and that the
|
||||||
|
/// roster surfaced matches <see cref="FixtureSessionOperations.GetCharacters"/>
|
||||||
|
/// exactly (before selection has happened — the roster is reported for
|
||||||
|
/// BOTH candidates, not just the selected one).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void StatusFileReceivesThePinnedLifecycleEventsInOrder()
|
||||||
|
{
|
||||||
|
string statusPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"acdream-headless-status-{Guid.NewGuid():N}.jsonl");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var operations = new FixtureSessionOperations();
|
||||||
|
using var diagnosticsOutput = new StringWriter();
|
||||||
|
using var credential = new HeadlessCredentialSecret(
|
||||||
|
"fixture",
|
||||||
|
"password");
|
||||||
|
using var host = new HeadlessSessionHost(
|
||||||
|
Descriptor(statusFile: statusPath),
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||||
|
operations);
|
||||||
|
|
||||||
|
RuntimeSessionStartResult started = host.Start();
|
||||||
|
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
|
||||||
|
host.Dispose();
|
||||||
|
|
||||||
|
string[] lines = File.ReadAllLines(statusPath);
|
||||||
|
string[] eventNames = lines
|
||||||
|
.Select(line => JsonDocument.Parse(line)
|
||||||
|
.RootElement.GetProperty("e").GetString()!)
|
||||||
|
.ToArray();
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"started", "connected", "characterList", "enteredWorld",
|
||||||
|
"disconnected", "exited",
|
||||||
|
],
|
||||||
|
eventNames);
|
||||||
|
|
||||||
|
using JsonDocument characterListDoc = JsonDocument.Parse(
|
||||||
|
lines[Array.IndexOf(eventNames, "characterList")]);
|
||||||
|
JsonElement characterList = characterListDoc.RootElement;
|
||||||
|
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
|
||||||
|
Assert.Equal(2, characterList.GetProperty("characters").GetArrayLength());
|
||||||
|
|
||||||
|
using JsonDocument enteredWorldDoc = JsonDocument.Parse(
|
||||||
|
lines[Array.IndexOf(eventNames, "enteredWorld")]);
|
||||||
|
Assert.Equal(
|
||||||
|
0x50000002u,
|
||||||
|
enteredWorldDoc.RootElement.GetProperty("characterId").GetUInt32());
|
||||||
|
|
||||||
|
using JsonDocument exitedDoc = JsonDocument.Parse(
|
||||||
|
lines[Array.IndexOf(eventNames, "exited")]);
|
||||||
|
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
|
||||||
|
|
||||||
|
string contents = File.ReadAllText(statusPath);
|
||||||
|
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(statusPath))
|
||||||
|
File.Delete(statusPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AbsentStatusFileConstructsANoOpWriter()
|
||||||
|
{
|
||||||
|
var operations = new FixtureSessionOperations();
|
||||||
|
using var diagnosticsOutput = new StringWriter();
|
||||||
|
using var credential = new HeadlessCredentialSecret("fixture", "password");
|
||||||
|
using var host = new HeadlessSessionHost(
|
||||||
|
Descriptor(),
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||||
|
operations);
|
||||||
|
|
||||||
|
RuntimeSessionStartResult started = host.Start();
|
||||||
|
|
||||||
|
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
|
||||||
|
// No exception, and (implicitly) no file was ever touched — the
|
||||||
|
// writer is a permanent no-op with no configured path.
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
|
||||||
{
|
{
|
||||||
|
|
@ -1937,7 +2027,9 @@ public sealed class HeadlessSessionHostTests
|
||||||
_ => { },
|
_ => { },
|
||||||
() => { }),
|
() => { }),
|
||||||
(_, _, _) => { },
|
(_, _, _) => { },
|
||||||
() => { }));
|
() => { },
|
||||||
|
_ => { },
|
||||||
|
_ => { }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations
|
private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations
|
||||||
|
|
@ -1967,7 +2059,8 @@ public sealed class HeadlessSessionHostTests
|
||||||
HeadlessCredentialProviderKind provider =
|
HeadlessCredentialProviderKind provider =
|
||||||
HeadlessCredentialProviderKind.Environment,
|
HeadlessCredentialProviderKind.Environment,
|
||||||
string credentialReference = "BOT_PASSWORD",
|
string credentialReference = "BOT_PASSWORD",
|
||||||
Dictionary<string, bool>? characterOptions = null) => new()
|
Dictionary<string, bool>? characterOptions = null,
|
||||||
|
string? statusFile = null) => new()
|
||||||
{
|
{
|
||||||
Id = "bot",
|
Id = "bot",
|
||||||
Endpoint = new HeadlessEndpointDescriptor
|
Endpoint = new HeadlessEndpointDescriptor
|
||||||
|
|
@ -1990,6 +2083,7 @@ public sealed class HeadlessSessionHostTests
|
||||||
Reference = credentialReference,
|
Reference = credentialReference,
|
||||||
},
|
},
|
||||||
CharacterOptions = characterOptions,
|
CharacterOptions = characterOptions,
|
||||||
|
StatusFile = statusFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
private static void HydrateGroundedPlayer(GameRuntime runtime)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
options);
|
||||||
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
||||||
var trace = new RuntimeTraceRecorder();
|
var trace = new RuntimeTraceRecorder();
|
||||||
|
|
@ -752,7 +754,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
|
||||||
_ => { },
|
_ => { },
|
||||||
() => { }),
|
() => { }),
|
||||||
(_, _, _) => { },
|
(_, _, _) => { },
|
||||||
() => { }),
|
() => { },
|
||||||
|
_ => { },
|
||||||
|
_ => { }),
|
||||||
options);
|
options);
|
||||||
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
|
||||||
_ = adapter.Session.Start(runtime.Generation);
|
_ = adapter.Session.Start(runtime.Generation);
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ public sealed class LiveSessionControllerTests
|
||||||
public Action? OnReset { get; set; }
|
public Action? OnReset { get; set; }
|
||||||
public Action? OnConnecting { get; set; }
|
public Action? OnConnecting { get; set; }
|
||||||
public Action? OnConnected { get; set; }
|
public Action? OnConnected { get; set; }
|
||||||
|
public Action? OnRoster { get; set; }
|
||||||
public Action? OnSelected { get; set; }
|
public Action? OnSelected { get; set; }
|
||||||
public Action? OnActivate { get; set; }
|
public Action? OnActivate { get; set; }
|
||||||
public Action? OnEntered { get; set; }
|
public Action? OnEntered { get; set; }
|
||||||
|
|
@ -146,6 +147,7 @@ public sealed class LiveSessionControllerTests
|
||||||
public bool ThrowOnBind { get; set; }
|
public bool ThrowOnBind { get; set; }
|
||||||
public bool ThrowOnConnecting { get; set; }
|
public bool ThrowOnConnecting { get; set; }
|
||||||
public bool ThrowOnConnected { get; set; }
|
public bool ThrowOnConnected { get; set; }
|
||||||
|
public bool ThrowOnRoster { get; set; }
|
||||||
public bool ThrowOnSelected { get; set; }
|
public bool ThrowOnSelected { get; set; }
|
||||||
public bool ThrowOnActivate { get; set; }
|
public bool ThrowOnActivate { get; set; }
|
||||||
public bool ThrowOnEntered { get; set; }
|
public bool ThrowOnEntered { get; set; }
|
||||||
|
|
@ -158,6 +160,7 @@ public sealed class LiveSessionControllerTests
|
||||||
public List<TestCommandBus> CommandBuses { get; } = [];
|
public List<TestCommandBus> CommandBuses { get; } = [];
|
||||||
public List<LiveSessionCharacterSelection> Selections { get; } = [];
|
public List<LiveSessionCharacterSelection> Selections { get; } = [];
|
||||||
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
|
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
|
||||||
|
public List<LiveSessionRosterReport> Rosters { get; } = [];
|
||||||
|
|
||||||
public LiveSessionBinding BindSession(WorldSession session)
|
public LiveSessionBinding BindSession(WorldSession session)
|
||||||
{
|
{
|
||||||
|
|
@ -231,6 +234,15 @@ public sealed class LiveSessionControllerTests
|
||||||
throw new InvalidOperationException("connected failure");
|
throw new InvalidOperationException("connected failure");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ReportRoster(LiveSessionRosterReport roster)
|
||||||
|
{
|
||||||
|
calls.Add("roster");
|
||||||
|
Rosters.Add(roster);
|
||||||
|
OnRoster?.Invoke();
|
||||||
|
if (ThrowOnRoster)
|
||||||
|
throw new InvalidOperationException("roster failure");
|
||||||
|
}
|
||||||
|
|
||||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
|
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
|
||||||
{
|
{
|
||||||
calls.Add("selected");
|
calls.Add("selected");
|
||||||
|
|
@ -290,7 +302,7 @@ public sealed class LiveSessionControllerTests
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[
|
[
|
||||||
"reset", "resolve", "create", "bind", "report-connecting",
|
"reset", "resolve", "create", "bind", "report-connecting",
|
||||||
"connect", "report-connected", "selected", "enter:1",
|
"connect", "report-connected", "roster", "selected", "enter:1",
|
||||||
"activate", "entered",
|
"activate", "entered",
|
||||||
],
|
],
|
||||||
calls);
|
calls);
|
||||||
|
|
@ -302,6 +314,32 @@ public sealed class LiveSessionControllerTests
|
||||||
Assert.True(host.CommandBuses[0].Active);
|
Assert.True(host.CommandBuses[0].Active);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Start_ReportsRosterFromCharacterListBeforeSelection()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var operations = new TestOperations(calls);
|
||||||
|
var host = new TestHost(calls);
|
||||||
|
var controller = new LiveSessionController(operations);
|
||||||
|
|
||||||
|
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
|
||||||
|
|
||||||
|
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
|
||||||
|
LiveSessionRosterReport roster = Assert.Single(host.Rosters);
|
||||||
|
Assert.Equal("Canonical", roster.AccountName);
|
||||||
|
Assert.Equal(11, roster.SlotCount);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
new LiveSessionRosterEntry(0x50000001u, "Grey", 10u),
|
||||||
|
new LiveSessionRosterEntry(0x50000002u, "Ready", 0u),
|
||||||
|
],
|
||||||
|
roster.Entries);
|
||||||
|
// "roster" must land strictly before "selected" — the launcher's
|
||||||
|
// char-select screen (LA7/LA8) will read the roster before any
|
||||||
|
// selection has been made.
|
||||||
|
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
|
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
|
||||||
{
|
{
|
||||||
|
|
@ -488,7 +526,7 @@ public sealed class LiveSessionControllerTests
|
||||||
[
|
[
|
||||||
"deactivate", "detach-events", "dispose-session", "detach-session",
|
"deactivate", "detach-events", "dispose-session", "detach-session",
|
||||||
"reset", "resolve", "create", "bind", "report-connecting",
|
"reset", "resolve", "create", "bind", "report-connecting",
|
||||||
"connect", "report-connected", "selected", "enter:1",
|
"connect", "report-connected", "roster", "selected", "enter:1",
|
||||||
"activate", "entered",
|
"activate", "entered",
|
||||||
],
|
],
|
||||||
calls);
|
calls);
|
||||||
|
|
@ -742,6 +780,7 @@ public sealed class LiveSessionControllerTests
|
||||||
[InlineData("connecting")]
|
[InlineData("connecting")]
|
||||||
[InlineData("connected")]
|
[InlineData("connected")]
|
||||||
[InlineData("characters")]
|
[InlineData("characters")]
|
||||||
|
[InlineData("roster")]
|
||||||
[InlineData("selected")]
|
[InlineData("selected")]
|
||||||
[InlineData("activate")]
|
[InlineData("activate")]
|
||||||
[InlineData("entered")]
|
[InlineData("entered")]
|
||||||
|
|
@ -755,6 +794,7 @@ public sealed class LiveSessionControllerTests
|
||||||
case "connecting": host.ThrowOnConnecting = true; break;
|
case "connecting": host.ThrowOnConnecting = true; break;
|
||||||
case "connected": host.ThrowOnConnected = true; break;
|
case "connected": host.ThrowOnConnected = true; break;
|
||||||
case "characters": operations.ThrowOnCharacters = true; break;
|
case "characters": operations.ThrowOnCharacters = true; break;
|
||||||
|
case "roster": host.ThrowOnRoster = true; break;
|
||||||
case "selected": host.ThrowOnSelected = true; break;
|
case "selected": host.ThrowOnSelected = true; break;
|
||||||
case "activate": host.ThrowOnActivate = true; break;
|
case "activate": host.ThrowOnActivate = true; break;
|
||||||
case "entered": host.ThrowOnEntered = true; break;
|
case "entered": host.ThrowOnEntered = true; break;
|
||||||
|
|
|
||||||
|
|
@ -35,12 +35,13 @@ public sealed class LiveSessionHostTests
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[
|
[
|
||||||
"reset", "resolve", "create", "events", "attach-events", "commands",
|
"reset", "resolve", "create", "events", "attach-events", "commands",
|
||||||
"connecting", "connect", "connected",
|
"connecting", "connect", "connected", "roster:Canonical",
|
||||||
"player:1342177282", "vitals:1342177282",
|
"player:1342177282", "vitals:1342177282",
|
||||||
"chat:1342177282", "persistent:1342177282",
|
"chat:1342177282", "persistent:1342177282",
|
||||||
"vanish:1342177282", "clear-combat", "enter:1",
|
"vanish:1342177282", "clear-combat", "enter:1",
|
||||||
"activate", "active:Ready", "restore-layout",
|
"activate", "active:Ready", "restore-layout",
|
||||||
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
|
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
|
||||||
|
"character-entered:1342177282",
|
||||||
],
|
],
|
||||||
calls);
|
calls);
|
||||||
Assert.Same(controller.CurrentSession, host.CurrentSession);
|
Assert.Same(controller.CurrentSession, host.CurrentSession);
|
||||||
|
|
@ -236,7 +237,10 @@ public sealed class LiveSessionHostTests
|
||||||
name => calls.Add($"load-settings:{name}"),
|
name => calls.Add($"load-settings:{name}"),
|
||||||
() => calls.Add("arm-auto-entry")),
|
() => calls.Add("arm-auto-entry")),
|
||||||
Connecting: (_, _, _) => calls.Add("connecting"),
|
Connecting: (_, _, _) => calls.Add("connecting"),
|
||||||
Connected: () => calls.Add("connected")));
|
Connected: () => calls.Add("connected"),
|
||||||
|
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
|
||||||
|
CharacterEntered: selection =>
|
||||||
|
calls.Add($"character-entered:{selection.CharacterId}")));
|
||||||
|
|
||||||
private static LiveSessionConnectOptions LiveOptions(
|
private static LiveSessionConnectOptions LiveOptions(
|
||||||
bool live = true,
|
bool live = true,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public sealed class LiveSessionLifecycleHostTests
|
||||||
host.ResetSessionState(RuntimeGenerationToken.Initial);
|
host.ResetSessionState(RuntimeGenerationToken.Initial);
|
||||||
host.ReportConnecting("host", 9000, "user");
|
host.ReportConnecting("host", 9000, "user");
|
||||||
host.ReportConnected();
|
host.ReportConnected();
|
||||||
|
host.ReportRoster(new LiveSessionRosterReport("account", 11, []));
|
||||||
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
|
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
|
||||||
host.ApplySelectedCharacter(selection);
|
host.ApplySelectedCharacter(selection);
|
||||||
binding.ActivateCommands();
|
binding.ActivateCommands();
|
||||||
|
|
@ -31,8 +32,8 @@ public sealed class LiveSessionLifecycleHostTests
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[
|
[
|
||||||
"bind", "reset", "connecting:host:9000:user",
|
"bind", "reset", "connecting:host:9000:user",
|
||||||
"connected", "selected:toon", "activate", "entered:toon",
|
"connected", "roster:account", "selected:toon", "activate",
|
||||||
"deactivate", "detach-events", "bind",
|
"entered:toon", "deactivate", "detach-events", "bind",
|
||||||
],
|
],
|
||||||
calls);
|
calls);
|
||||||
replacement.Dispose();
|
replacement.Dispose();
|
||||||
|
|
@ -71,6 +72,7 @@ public sealed class LiveSessionLifecycleHostTests
|
||||||
Connecting: (host, port, user) =>
|
Connecting: (host, port, user) =>
|
||||||
calls.Add($"connecting:{host}:{port}:{user}"),
|
calls.Add($"connecting:{host}:{port}:{user}"),
|
||||||
Connected: () => calls.Add("connected"),
|
Connected: () => calls.Add("connected"),
|
||||||
|
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
|
||||||
Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
|
Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
|
||||||
Entered: selection => calls.Add($"entered:{selection.CharacterName}")));
|
Entered: selection => calls.Add($"entered:{selection.CharacterName}")));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2342,7 +2342,9 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
||||||
_ => { },
|
_ => { },
|
||||||
() => { }),
|
() => { }),
|
||||||
(_, _, _) => { },
|
(_, _, _) => { },
|
||||||
() => { }),
|
() => { },
|
||||||
|
_ => { },
|
||||||
|
_ => { }),
|
||||||
options);
|
options);
|
||||||
LiveSessionStartResult startResult = live.Start(options);
|
LiveSessionStartResult startResult = live.Start(options);
|
||||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||||
|
|
|
||||||
|
|
@ -1049,7 +1049,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests
|
||||||
_ => { },
|
_ => { },
|
||||||
() => { }),
|
() => { }),
|
||||||
(_, _, _) => { },
|
(_, _, _) => { },
|
||||||
() => { }),
|
() => { },
|
||||||
|
_ => { },
|
||||||
|
_ => { }),
|
||||||
options);
|
options);
|
||||||
LiveSessionStartResult startResult = live.Start(options);
|
LiveSessionStartResult startResult = live.Start(options);
|
||||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ public sealed class RuntimeLiveSessionNoWindowTests
|
||||||
_ => { },
|
_ => { },
|
||||||
() => { }),
|
() => { }),
|
||||||
(_, _, _) => calls.Add("connecting"),
|
(_, _, _) => calls.Add("connecting"),
|
||||||
() => calls.Add("connected")),
|
() => calls.Add("connected"),
|
||||||
|
_ => calls.Add("roster"),
|
||||||
|
selection => calls.Add($"character-entered:{selection.CharacterId}")),
|
||||||
new LiveSessionConnectOptions(
|
new LiveSessionConnectOptions(
|
||||||
true,
|
true,
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
|
@ -61,10 +63,12 @@ public sealed class RuntimeLiveSessionNoWindowTests
|
||||||
"connect",
|
"connect",
|
||||||
"connected",
|
"connected",
|
||||||
"characters",
|
"characters",
|
||||||
|
"roster",
|
||||||
"player:1342177281",
|
"player:1342177281",
|
||||||
"enter:0",
|
"enter:0",
|
||||||
"activate-commands",
|
"activate-commands",
|
||||||
"entered:Runtime",
|
"entered:Runtime",
|
||||||
|
"character-entered:1342177281",
|
||||||
"deactivate-commands",
|
"deactivate-commands",
|
||||||
"detach-events",
|
"detach-events",
|
||||||
"dispose-session",
|
"dispose-session",
|
||||||
|
|
|
||||||
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,
|
host,
|
||||||
port,
|
port,
|
||||||
connectingUser),
|
connectingUser),
|
||||||
_operations.RecordConnected),
|
_operations.RecordConnected,
|
||||||
|
_operations.RecordRoster,
|
||||||
|
_operations.RecordCharacterEntered),
|
||||||
new LiveSessionConnectOptions(
|
new LiveSessionConnectOptions(
|
||||||
true,
|
true,
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
|
@ -693,6 +695,13 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
|
||||||
Trace.Add($"connecting:{host}:{port}:{user}");
|
Trace.Add($"connecting:{host}:{port}:{user}");
|
||||||
|
|
||||||
public void RecordConnected() => Trace.Add("connected");
|
public void RecordConnected() => Trace.Add("connected");
|
||||||
|
|
||||||
|
public void RecordRoster(LiveSessionRosterReport roster) =>
|
||||||
|
Trace.Add($"roster:{roster.AccountName}");
|
||||||
|
|
||||||
|
public void RecordCharacterEntered(
|
||||||
|
LiveSessionCharacterSelection selection) =>
|
||||||
|
Trace.Add($"character-entered:{selection.CharacterId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FixtureTransport : IWorldSessionTransport
|
private sealed class FixtureTransport : IWorldSessionTransport
|
||||||
|
|
|
||||||
|
|
@ -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