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
|
|
@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies(
|
|||
CombatAttackOperationsSlot CombatAttackOperations,
|
||||
CombatFeedbackSlot CombatFeedback,
|
||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||
Action<string> Log)
|
||||
Action<string> Log,
|
||||
/// <summary>Campaign LA slice LA1: the shared per-session status-event
|
||||
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
|
||||
/// not configured.</summary>
|
||||
SessionStatusWriter StatusWriter)
|
||||
{
|
||||
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||
|
||||
|
|
@ -1124,7 +1128,9 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
acceptedPositionDrive,
|
||||
remotePlacementDrive),
|
||||
liveSessionCommands,
|
||||
d.Log);
|
||||
d.Log,
|
||||
d.StatusWriter,
|
||||
d.Options.SessionId ?? "app");
|
||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
||||
liveSession,
|
||||
new LiveSessionConnectOptions(
|
||||
|
|
@ -1132,7 +1138,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.Options.LiveHost,
|
||||
d.Options.LivePort,
|
||||
d.Options.LiveUser ?? string.Empty,
|
||||
d.Options.LivePass ?? string.Empty));
|
||||
d.Options.LivePass ?? string.Empty,
|
||||
d.Options.LiveCharacterSelector));
|
||||
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
||||
|
||||
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App.Composition;
|
||||
|
||||
internal sealed record SessionStartDependencies(
|
||||
Action<string> Log);
|
||||
Action<string> Log,
|
||||
/// <summary>Campaign LA slice LA1: no-op when no statusFile was
|
||||
/// configured.</summary>
|
||||
SessionStatusWriter StatusWriter,
|
||||
string SessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Terminal startup phase. Every callback, command target, and frame root is
|
||||
|
|
@ -21,6 +26,9 @@ internal sealed class SessionStartCompositionPhase
|
|||
public void Start(FrameRootResult frame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
// Campaign LA slice LA1: "started" = session host start — the
|
||||
// earliest point the graphical host actually attempts to connect.
|
||||
_dependencies.StatusWriter.Started(_dependencies.SessionId);
|
||||
RuntimeSessionStartResult result =
|
||||
frame.GameRuntime.Session.Start(frame.GameRuntime.Generation);
|
||||
Report(result, _dependencies.Log);
|
||||
|
|
|
|||
142
src/AcDream.App/Configuration/SessionConfiguration.cs
Normal file
142
src/AcDream.App/Configuration/SessionConfiguration.cs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: the graphical host's reader for the pinned
|
||||
/// session-config document shape shared with
|
||||
/// <c>AcDream.Headless.Configuration.HeadlessConfiguration</c> — see
|
||||
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 and
|
||||
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6.
|
||||
///
|
||||
/// <para>
|
||||
/// This is a DELIBERATELY independent DTO set, not a shared type reused from
|
||||
/// <c>AcDream.Headless</c> — Headless's config types are internal, tied to
|
||||
/// its own OP7 <c>characterOptions</c> allow-list semantics, and Headless is
|
||||
/// not a project App references. The two readers are cross-checked instead
|
||||
/// by a shared fixture document both test suites parse
|
||||
/// (<c>SessionConfigurationSharedFixtureTests</c> /
|
||||
/// <c>HeadlessConfigurationSharedFixtureTests</c>).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Differences from the Headless reader, all intentional per the pinned
|
||||
/// contract: <see cref="SessionDescriptor.Character"/> is OPTIONAL here
|
||||
/// (absent = today's first-available fallback; the character-select screen
|
||||
/// is LA7, not this slice); <see cref="SessionDescriptor.Policy"/> is parsed
|
||||
/// but never consulted (App has no bot-policy concept); exactly ONE session
|
||||
/// is required, not "one or more".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class SessionConfiguration
|
||||
{
|
||||
[JsonRequired]
|
||||
public int Version { get; init; }
|
||||
|
||||
public SessionProcessSettings? Process { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public List<SessionDescriptor?> Sessions { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class SessionProcessSettings
|
||||
{
|
||||
public SessionContentDescriptor? Content { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class SessionContentDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public string DatDirectory { get; init; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public string PreparedAssetPath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed record SessionDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public SessionEndpointDescriptor Endpoint { get; init; } = new();
|
||||
|
||||
[JsonRequired]
|
||||
public string Account { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Optional for the graphical host: absent means today's
|
||||
/// existing first-available fallback stays in effect. The retail
|
||||
/// character-select screen (LA7) is what actually consumes "no
|
||||
/// selector" as "stop and let the user pick".</summary>
|
||||
public SessionCharacterSelectorDescriptor? Character { get; init; }
|
||||
|
||||
/// <summary>Accepted so the SAME document also satisfies the Headless
|
||||
/// loader's <c>JsonRequired</c> policy field — parsed and ignored here;
|
||||
/// App has no bot-policy concept.</summary>
|
||||
public SessionPolicyDescriptor? Policy { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public SessionCredentialDescriptor Credential { get; init; } = new();
|
||||
|
||||
/// <summary>Accepted-but-ignored by App; Headless's own loader owns the
|
||||
/// allow-list semantics for this field (OP7 D8).</summary>
|
||||
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
||||
|
||||
/// <summary>LA1: plugin ids to load. Absent = load all (LA5 consumes
|
||||
/// this; parsed and carried here now per the pinned launch contract).</summary>
|
||||
public List<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>LA1: ordered chat-typed strings run after entering world
|
||||
/// (LA6 consumes this; parsed and carried here now).</summary>
|
||||
public List<string>? LoginCommands { get; init; }
|
||||
|
||||
/// <summary>LA1: inter-command delay for <see cref="LoginCommands"/>,
|
||||
/// milliseconds. Matches the pinned contract default of 500 ms.</summary>
|
||||
public int LoginCommandDelayMs { get; init; } = 500;
|
||||
|
||||
/// <summary>LA1: absolute path for the status-event JSONL stream.
|
||||
/// Absent = no writer constructed.</summary>
|
||||
public string? StatusFile { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class SessionEndpointDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public string Host { get; init; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public int Port { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class SessionCharacterSelectorDescriptor
|
||||
{
|
||||
public int? Index { get; init; }
|
||||
public uint? Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Loose by design: App never inspects the policy's shape beyond
|
||||
/// "does this document parse" — <c>Id</c>/<c>Role</c> stay untyped strings so
|
||||
/// this DTO never has to track Headless's own policy-id/role vocabulary.</summary>
|
||||
internal sealed class SessionPolicyDescriptor
|
||||
{
|
||||
public string? Id { get; init; }
|
||||
public string? Role { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<SessionCredentialProviderKind>))]
|
||||
internal enum SessionCredentialProviderKind
|
||||
{
|
||||
Environment,
|
||||
StandardInput,
|
||||
File,
|
||||
}
|
||||
|
||||
internal sealed class SessionCredentialDescriptor
|
||||
{
|
||||
[JsonRequired]
|
||||
public SessionCredentialProviderKind Provider { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public string Reference { get; init; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
namespace AcDream.App.Configuration;
|
||||
|
||||
/// <summary>Mirrors <c>AcDream.Headless.Configuration.HeadlessConfigurationException</c>
|
||||
/// — a semantic validation failure of an already well-typed session-config
|
||||
/// document (a type-SHAPE violation fails earlier, as a raw
|
||||
/// <see cref="System.Text.Json.JsonException"/> during deserialization).</summary>
|
||||
internal sealed class SessionConfigurationException : Exception
|
||||
{
|
||||
internal SessionConfigurationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
internal SessionConfigurationException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
158
src/AcDream.App/Configuration/SessionConfigurationLoader.cs
Normal file
158
src/AcDream.App/Configuration/SessionConfigurationLoader.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.App.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: loads and validates the <c>--session-config</c>
|
||||
/// document for the graphical host. Same strictness as
|
||||
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c>
|
||||
/// (camelCase, <see cref="JsonUnmappedMemberHandling.Disallow"/>, camelCase
|
||||
/// string enums) — see that type's own doc for why the two readers are
|
||||
/// independent DTOs rather than a shared type.
|
||||
/// </summary>
|
||||
internal static class SessionConfigurationLoader
|
||||
{
|
||||
private const int CurrentVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
ReadCommentHandling = JsonCommentHandling.Disallow,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(
|
||||
JsonNamingPolicy.CamelCase,
|
||||
allowIntegerValues: false),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Loads the document and returns the exact one configured
|
||||
/// <see cref="SessionDescriptor"/> the graphical host runs — the
|
||||
/// document itself may only ever declare exactly one session.</summary>
|
||||
internal static (SessionConfiguration Configuration, SessionDescriptor Session) Load(
|
||||
string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
using FileStream stream = File.OpenRead(fullPath);
|
||||
SessionConfiguration? configuration =
|
||||
JsonSerializer.Deserialize<SessionConfiguration>(stream, Options);
|
||||
|
||||
if (configuration is null)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"The configuration document is empty.");
|
||||
}
|
||||
|
||||
if (configuration.Version != CurrentVersion)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Unsupported configuration version {configuration.Version}; "
|
||||
+ $"expected {CurrentVersion}.");
|
||||
}
|
||||
|
||||
if (configuration.Sessions is null
|
||||
|| configuration.Sessions.Count != 1)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"The graphical host requires exactly one configured session.");
|
||||
}
|
||||
|
||||
SessionDescriptor session = configuration.Sessions[0]
|
||||
?? throw new SessionConfigurationException(
|
||||
"The configured session cannot be null.");
|
||||
|
||||
ValidateContent(configuration.Process?.Content);
|
||||
ValidateSession(session);
|
||||
|
||||
return (configuration, session);
|
||||
}
|
||||
|
||||
private static void ValidateContent(SessionContentDescriptor? content)
|
||||
{
|
||||
if (content is null)
|
||||
return;
|
||||
if (string.IsNullOrWhiteSpace(content.DatDirectory)
|
||||
|| string.IsNullOrWhiteSpace(content.PreparedAssetPath))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"process.content requires non-empty datDirectory and preparedAssetPath.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSession(SessionDescriptor session)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(session.Id))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"The session requires a non-empty id.");
|
||||
}
|
||||
|
||||
if (session.Endpoint is null
|
||||
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|
||||
|| session.Endpoint.Port is < 1 or > 65535)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' requires a host and a port from 1 through 65535.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(session.Account))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' requires a non-empty account.");
|
||||
}
|
||||
|
||||
if (session.Character is { } selector)
|
||||
{
|
||||
int selectorCount =
|
||||
(selector.Index.HasValue ? 1 : 0)
|
||||
+ (selector.Id.HasValue ? 1 : 0)
|
||||
+ (!string.IsNullOrWhiteSpace(selector.Name) ? 1 : 0);
|
||||
if (selectorCount != 1
|
||||
|| selector.Index is < 0
|
||||
|| selector.Id == 0u)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' character selector must specify "
|
||||
+ "exactly one valid index, id, or name.");
|
||||
}
|
||||
}
|
||||
|
||||
if (session.Credential is null
|
||||
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' requires a credential reference.");
|
||||
}
|
||||
|
||||
if (session.Plugins is { } plugins)
|
||||
{
|
||||
foreach (string? plugin in plugins)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plugin))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' plugins entries must be non-empty strings.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (session.LoginCommandDelayMs < 0)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
|
||||
}
|
||||
|
||||
if (session.StatusFile is not null
|
||||
&& string.IsNullOrWhiteSpace(session.StatusFile))
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/AcDream.App/Credentials/AppCredentialResolver.cs
Normal file
155
src/AcDream.App/Credentials/AppCredentialResolver.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Platform;
|
||||
|
||||
namespace AcDream.App.Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: resolves a <c>--session-config</c> session's
|
||||
/// credential reference — the App-side mirror of
|
||||
/// <c>AcDream.Headless.Credentials.HeadlessCredentialResolver</c> (see that
|
||||
/// type's own file for why this is an independent port rather than a shared
|
||||
/// reference). Supports the same three providers with the same semantics:
|
||||
/// <c>environment</c> (read an env var), <c>standardInput</c> (read one line
|
||||
/// from stdin, mirroring <c>HeadlessCredentialResolver.ResolveStandardInput</c>),
|
||||
/// and <c>file</c> (read a credential file relative to a base directory,
|
||||
/// rejecting symlinks and, on Linux, group/other-readable permissions).
|
||||
/// </summary>
|
||||
internal sealed class AppCredentialResolver
|
||||
{
|
||||
private const UnixFileMode NonUserPermissionMask =
|
||||
UnixFileMode.GroupRead
|
||||
| UnixFileMode.GroupWrite
|
||||
| UnixFileMode.GroupExecute
|
||||
| UnixFileMode.OtherRead
|
||||
| UnixFileMode.OtherWrite
|
||||
| UnixFileMode.OtherExecute;
|
||||
|
||||
private readonly TextReader _standardInput;
|
||||
private readonly string _credentialBaseDirectory;
|
||||
private readonly bool _isLinux;
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="isLinux"/> is caller-supplied, never detected in this
|
||||
/// file — <c>LinuxPlatformBoundaryTests</c>'s platform-owner guard
|
||||
/// requires every OS-family check to live under <c>Platform/</c>;
|
||||
/// callers pass <c>GraphicalHostPlatformServices</c>'s already-detected
|
||||
/// value instead of this file re-detecting it itself.
|
||||
/// </summary>
|
||||
internal AppCredentialResolver(
|
||||
TextReader standardInput,
|
||||
string credentialBaseDirectory,
|
||||
bool isLinux)
|
||||
{
|
||||
_standardInput = standardInput
|
||||
?? throw new ArgumentNullException(nameof(standardInput));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(credentialBaseDirectory);
|
||||
_credentialBaseDirectory = Path.GetFullPath(credentialBaseDirectory);
|
||||
_isLinux = isLinux;
|
||||
}
|
||||
|
||||
internal AppCredentialSecret Resolve(
|
||||
string sessionId,
|
||||
SessionCredentialDescriptor credential)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
ArgumentNullException.ThrowIfNull(credential);
|
||||
|
||||
string value;
|
||||
try
|
||||
{
|
||||
value = credential.Provider switch
|
||||
{
|
||||
SessionCredentialProviderKind.Environment =>
|
||||
ResolveEnvironment(credential.Reference),
|
||||
SessionCredentialProviderKind.StandardInput =>
|
||||
ResolveStandardInput(credential.Reference),
|
||||
SessionCredentialProviderKind.File =>
|
||||
ResolveFile(credential.Reference),
|
||||
_ => throw new AppCredentialException(
|
||||
$"Session '{sessionId}' uses an unsupported credential provider."),
|
||||
};
|
||||
}
|
||||
catch (AppCredentialException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception error)
|
||||
when (error is IOException
|
||||
or UnauthorizedAccessException
|
||||
or ArgumentException
|
||||
or NotSupportedException)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential '{credential.Reference}' for session '{sessionId}' could not be resolved.",
|
||||
error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return new AppCredentialSecret(credential.Reference, value.AsSpan());
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The BCL returns immutable strings from environment, TextReader,
|
||||
// and File APIs. Do not retain another copy in the resolver; the
|
||||
// erasable char[] owner becomes the sole explicit retained copy.
|
||||
value = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveEnvironment(string reference)
|
||||
{
|
||||
string? value = Environment.GetEnvironmentVariable(reference);
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential environment reference '{reference}' is unavailable.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private string ResolveStandardInput(string reference)
|
||||
{
|
||||
string? value = _standardInput.ReadLine();
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential standard-input reference '{reference}' is unavailable.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private string ResolveFile(string reference)
|
||||
{
|
||||
string path = Path.GetFullPath(reference, _credentialBaseDirectory);
|
||||
var file = new FileInfo(path);
|
||||
if (file.LinkTarget is not null)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential file reference '{reference}' cannot be a symbolic link.");
|
||||
}
|
||||
|
||||
// RuntimePlatformGuard.IsLinuxRuntime is the CA1416-recognized guard
|
||||
// for File.GetUnixFileMode below; _isLinux is the separate,
|
||||
// caller-injected value tests use for deterministic cross-platform
|
||||
// coverage (see the constructor's own doc).
|
||||
if (RuntimePlatformGuard.IsLinuxRuntime && _isLinux)
|
||||
{
|
||||
UnixFileMode mode = File.GetUnixFileMode(path);
|
||||
if ((mode & NonUserPermissionMask) != 0
|
||||
|| (mode & UnixFileMode.UserRead) == 0)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential file reference '{reference}' must be readable only by its owner.");
|
||||
}
|
||||
}
|
||||
|
||||
string value = File.ReadAllText(path).TrimEnd('\r', '\n');
|
||||
if (value.Length == 0)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential file reference '{reference}' is empty.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
65
src/AcDream.App/Credentials/AppCredentialSecret.cs
Normal file
65
src/AcDream.App/Credentials/AppCredentialSecret.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System.Security.Cryptography;
|
||||
|
||||
namespace AcDream.App.Credentials;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: retains a resolved <c>--session-config</c>
|
||||
/// credential in erasable memory — the App-side mirror of
|
||||
/// <c>AcDream.Headless.Credentials.HeadlessCredentialSecret</c> (that type is
|
||||
/// internal to the Headless project, so this is a minimal, independent port
|
||||
/// rather than a shared reference). The network boundary still requires one
|
||||
/// short-lived immutable string; callers must not retain that value beyond
|
||||
/// constructing the connect request.
|
||||
/// </summary>
|
||||
internal sealed class AppCredentialSecret : IDisposable
|
||||
{
|
||||
private char[]? _buffer;
|
||||
|
||||
internal AppCredentialSecret(string referenceId, ReadOnlySpan<char> value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(referenceId);
|
||||
if (value.IsEmpty)
|
||||
{
|
||||
throw new AppCredentialException(
|
||||
$"Credential '{referenceId}' resolved to an empty secret.");
|
||||
}
|
||||
|
||||
ReferenceId = referenceId;
|
||||
_buffer = value.ToArray();
|
||||
}
|
||||
|
||||
internal string ReferenceId { get; }
|
||||
internal bool IsDisposed => _buffer is null;
|
||||
|
||||
internal string Reveal()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_buffer is null, this);
|
||||
return new string(_buffer);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
char[]? buffer = Interlocked.Exchange(ref _buffer, null);
|
||||
if (buffer is null)
|
||||
return;
|
||||
CryptographicOperations.ZeroMemory(
|
||||
System.Runtime.InteropServices.MemoryMarshal.AsBytes(
|
||||
buffer.AsSpan()));
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"[redacted:{ReferenceId}]";
|
||||
}
|
||||
|
||||
internal sealed class AppCredentialException : Exception
|
||||
{
|
||||
internal AppCredentialException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
internal AppCredentialException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +104,8 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
private readonly LiveSessionCommandSurface _commands;
|
||||
private readonly Action<string> _log;
|
||||
private readonly LiveMovementStatsApplier _movementStats;
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
private readonly string _sessionId;
|
||||
|
||||
public LiveSessionRuntimeFactory(
|
||||
LiveSessionPlayerRuntime player,
|
||||
|
|
@ -112,7 +114,9 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
LiveSessionInteractionRuntime interaction,
|
||||
LiveSessionWorldRuntime world,
|
||||
LiveSessionCommandSurface commands,
|
||||
Action<string> log)
|
||||
Action<string> log,
|
||||
SessionStatusWriter? statusWriter = null,
|
||||
string sessionId = "app")
|
||||
{
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
||||
|
|
@ -122,6 +126,10 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
||||
// Campaign LA slice LA1: a no-op instance when the caller has no
|
||||
// status file configured — every call site below stays unconditional.
|
||||
_statusWriter = statusWriter ?? new SessionStatusWriter(null);
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
||||
// typed application seam; App keeps zero direct controller mutations.
|
||||
_movementStats = new LiveMovementStatsApplier(
|
||||
|
|
@ -176,9 +184,17 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
$"connecting to {host}:{port} as {user}",
|
||||
chatType: 1),
|
||||
Connected: () =>
|
||||
{
|
||||
_domain.Communication.Chat.OnSystemMessage(
|
||||
"connected — character list received",
|
||||
chatType: 1)),
|
||||
chatType: 1);
|
||||
_statusWriter.Connected(_sessionId);
|
||||
},
|
||||
Roster: roster => _statusWriter.CharacterList(_sessionId, roster),
|
||||
CharacterEntered: selection => _statusWriter.EnteredWorld(
|
||||
_sessionId,
|
||||
selection.CharacterId,
|
||||
selection.CharacterName)),
|
||||
connectOptions);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Platform;
|
||||
|
||||
|
|
@ -10,6 +11,21 @@ internal enum GraphicalHostOperatingSystem
|
|||
Linux,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: a <c>[SupportedOSPlatformGuard]</c>-annotated
|
||||
/// runtime-OS check, for code OUTSIDE <c>Platform/</c> that needs a
|
||||
/// CA1416-recognized guard around a Linux-only API (e.g.
|
||||
/// <c>AppCredentialResolver</c>'s <c>File.GetUnixFileMode</c> call) without
|
||||
/// re-detecting the OS itself — <c>LinuxPlatformBoundaryTests
|
||||
/// .OperatingSystemChecksRemainInsidePlatformOwners</c> requires every such
|
||||
/// check to live under this folder.
|
||||
/// </summary>
|
||||
internal static class RuntimePlatformGuard
|
||||
{
|
||||
[SupportedOSPlatformGuard("linux")]
|
||||
internal static bool IsLinuxRuntime => System.OperatingSystem.IsLinux();
|
||||
}
|
||||
|
||||
internal sealed record GraphicalNativeDependency(
|
||||
string Feature,
|
||||
string PublishedFileName);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using AcDream.App;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Credentials;
|
||||
using AcDream.App.Plugins;
|
||||
using AcDream.App.Platform;
|
||||
using AcDream.App.Rendering;
|
||||
|
|
@ -32,17 +34,96 @@ Log.Information(
|
|||
dependency =>
|
||||
$"{dependency.Feature}={dependency.PublishedFileName}")));
|
||||
|
||||
var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
if (string.IsNullOrWhiteSpace(datDir))
|
||||
{
|
||||
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
|
||||
return 2;
|
||||
}
|
||||
// Campaign LA slice LA1: --session-config <path> is purely additive — the
|
||||
// existing one positional dat-dir argument and every ACDREAM_* env var keep
|
||||
// working exactly as before when the flag is absent. See
|
||||
// docs/plans/2026-08-14-launcher-campaign.md LA1.
|
||||
string? sessionConfigFlagPath = ExtractFlagValue(args, "--session-config");
|
||||
string[] positionalArgs = WithoutFlagAndValue(args, "--session-config");
|
||||
|
||||
var datDirArg = positionalArgs.FirstOrDefault();
|
||||
var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
|
||||
// Single read of the startup-time process environment. Every downstream
|
||||
// consumer (GameWindow + collaborators) reads the typed bundle, not the
|
||||
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
|
||||
var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||
RuntimeOptions runtimeOptions;
|
||||
if (sessionConfigFlagPath is not null)
|
||||
{
|
||||
SessionConfiguration sessionConfig;
|
||||
SessionDescriptor session;
|
||||
try
|
||||
{
|
||||
(sessionConfig, session) = SessionConfigurationLoader.Load(sessionConfigFlagPath);
|
||||
}
|
||||
catch (Exception error)
|
||||
when (error is IOException
|
||||
or UnauthorizedAccessException
|
||||
or ArgumentException
|
||||
or NotSupportedException
|
||||
or System.Text.Json.JsonException
|
||||
or SessionConfigurationException)
|
||||
{
|
||||
Log.Error("--session-config invalid: {Error}", error.Message);
|
||||
return 2;
|
||||
}
|
||||
|
||||
string? resolvedDatDir =
|
||||
NullIfEmpty(sessionConfig.Process?.Content?.DatDirectory)
|
||||
?? NullIfEmpty(datDirArg)
|
||||
?? NullIfEmpty(envDatDir);
|
||||
if (resolvedDatDir is null)
|
||||
{
|
||||
Log.Error(
|
||||
"usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR, "
|
||||
+ "or supply process.content.datDirectory in --session-config)");
|
||||
return 2;
|
||||
}
|
||||
|
||||
AppCredentialSecret? secret = null;
|
||||
try
|
||||
{
|
||||
var resolver = new AppCredentialResolver(
|
||||
Console.In,
|
||||
applicationPaths.ConfigDirectory,
|
||||
graphicalPlatform.OperatingSystem
|
||||
== GraphicalHostOperatingSystem.Linux);
|
||||
secret = resolver.Resolve(session.Id, session.Credential);
|
||||
runtimeOptions = RuntimeOptions.FromSessionConfig(
|
||||
resolvedDatDir,
|
||||
Environment.GetEnvironmentVariable,
|
||||
sessionConfigFlagPath,
|
||||
sessionConfig,
|
||||
session,
|
||||
secret.Reveal());
|
||||
}
|
||||
catch (AppCredentialException error)
|
||||
{
|
||||
Log.Error("--session-config credential unavailable: {Error}", error.Message);
|
||||
return 2;
|
||||
}
|
||||
finally
|
||||
{
|
||||
secret?.Dispose();
|
||||
}
|
||||
|
||||
// Env-var flow untouched when the flag is absent; when both are present
|
||||
// the flag wins — this line makes that explicit rather than silent.
|
||||
Log.Information(
|
||||
"--session-config {Path} present; overriding ACDREAM_LIVE*/ACDREAM_TEST_* "
|
||||
+ "env-var live-session settings",
|
||||
sessionConfigFlagPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
var datDir = datDirArg ?? envDatDir;
|
||||
if (string.IsNullOrWhiteSpace(datDir))
|
||||
{
|
||||
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
|
||||
return 2;
|
||||
}
|
||||
runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||
}
|
||||
|
||||
if (runtimeOptions.DevTools)
|
||||
{
|
||||
|
|
@ -158,3 +239,35 @@ finally
|
|||
}
|
||||
|
||||
return 0;
|
||||
|
||||
// Campaign LA slice LA1: --session-config <path> parsing helpers. Kept
|
||||
// local/minimal rather than a general-purpose CLI parser — App has exactly
|
||||
// one optional flag-with-value today; the positional dat-dir argument must
|
||||
// stay untouched by its presence (see the comment above the flag parse).
|
||||
static string? ExtractFlagValue(string[] arguments, string flag)
|
||||
{
|
||||
for (int i = 0; i < arguments.Length - 1; i++)
|
||||
{
|
||||
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
||||
return arguments[i + 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static string[] WithoutFlagAndValue(string[] arguments, string flag)
|
||||
{
|
||||
var result = new List<string>(arguments.Length);
|
||||
for (int i = 0; i < arguments.Length; i++)
|
||||
{
|
||||
if (string.Equals(arguments[i], flag, StringComparison.Ordinal))
|
||||
{
|
||||
i++; // also skip the flag's value
|
||||
continue;
|
||||
}
|
||||
result.Add(arguments[i]);
|
||||
}
|
||||
return [.. result];
|
||||
}
|
||||
|
||||
static string? NullIfEmpty(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ public sealed class GameWindow :
|
|||
/ (double)System.Diagnostics.Stopwatch.Frequency;
|
||||
|
||||
private readonly AcDream.App.RuntimeOptions _options;
|
||||
// Campaign LA slice LA1: no-op instance when --session-config didn't
|
||||
// configure a statusFile (or the env-var launch path was used at all).
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
private readonly AnimationPresentationDiagnostics _animationDiagnostics;
|
||||
private readonly string _datDir;
|
||||
private readonly WorldGameState _worldGameState;
|
||||
|
|
@ -615,6 +618,7 @@ public sealed class GameWindow :
|
|||
GraphicalHostPlatformServices platformServices)
|
||||
{
|
||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||
_statusWriter = new SessionStatusWriter(options.StatusFilePath);
|
||||
_platformServices = platformServices
|
||||
?? throw new ArgumentNullException(nameof(platformServices));
|
||||
_applicationPaths = _platformServices.Paths;
|
||||
|
|
@ -1489,7 +1493,8 @@ public sealed class GameWindow :
|
|||
_combatAttackOperations,
|
||||
_combatFeedback,
|
||||
_portalTunnelFallback,
|
||||
Console.WriteLine),
|
||||
Console.WriteLine,
|
||||
_statusWriter),
|
||||
this).Compose(
|
||||
hostInputCamera,
|
||||
contentEffectsAudio,
|
||||
|
|
@ -1548,7 +1553,10 @@ public sealed class GameWindow :
|
|||
livePresentation,
|
||||
sessionPlayer),
|
||||
frameRoots => new SessionStartCompositionPhase(
|
||||
new SessionStartDependencies(Console.WriteLine))
|
||||
new SessionStartDependencies(
|
||||
Console.WriteLine,
|
||||
_statusWriter,
|
||||
_options.SessionId ?? "app"))
|
||||
.Start(frameRoots));
|
||||
}
|
||||
|
||||
|
|
@ -1636,13 +1644,30 @@ public sealed class GameWindow :
|
|||
private void CompleteShutdown(bool releaseNativeWindow)
|
||||
{
|
||||
if (!_lifetime.HasShutdownRoots)
|
||||
{
|
||||
// Campaign LA slice LA1: capture BEFORE the shutdown roots run —
|
||||
// by the time teardown completes, IsInWorld is always false
|
||||
// regardless of whether a real session was ever connected.
|
||||
// OnClosing() and Dispose() both funnel through this method;
|
||||
// HasShutdownRoots's own guard means this fires exactly once,
|
||||
// from whichever of the two reaches it first.
|
||||
if (_runtime.Session.IsInWorld)
|
||||
_statusWriter.Disconnected(_options.SessionId ?? "app", "stopped");
|
||||
_lifetime.PublishShutdownRoots(CaptureShutdownRoots());
|
||||
}
|
||||
|
||||
GameWindowLifetimeReport report = releaseNativeWindow
|
||||
? _lifetime.CompleteAndReleaseNativeWindow()
|
||||
: _lifetime.TryComplete();
|
||||
if (report.Status == GameWindowLifetimeStatus.Complete)
|
||||
{
|
||||
// "exited" = terminal — only the true Dispose() call (not the
|
||||
// OnClosing() native-window-close-request pass) represents the
|
||||
// process actually being done.
|
||||
if (releaseNativeWindow)
|
||||
_statusWriter.Exited(_options.SessionId ?? "app", 0, "disposed");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
|
||||
|
|
@ -1655,6 +1680,14 @@ public sealed class GameWindow :
|
|||
|
||||
if (report.Error is not null)
|
||||
Console.Error.WriteLine($"[shutdown] {report.Error}");
|
||||
|
||||
if (releaseNativeWindow)
|
||||
{
|
||||
_statusWriter.Exited(
|
||||
_options.SessionId ?? "app",
|
||||
1,
|
||||
"shutdown-incomplete");
|
||||
}
|
||||
}
|
||||
|
||||
private GameWindowShutdownRoots CaptureShutdownRoots() => new(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using AcDream.App.Configuration;
|
||||
using AcDream.App.Rendering.Residency;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.App;
|
||||
|
||||
|
|
@ -62,7 +65,34 @@ public sealed record RuntimeOptions(
|
|||
string? VulkanDeviceOverride,
|
||||
string? VulkanForcedUnsupportedFeature,
|
||||
bool VulkanCapabilityProbe,
|
||||
int VulkanCapabilityProbeFrames)
|
||||
int VulkanCapabilityProbeFrames,
|
||||
/// <summary>Campaign LA slice LA1: the raw <c>--session-config</c> path,
|
||||
/// or <see langword="null"/> when the flag was not supplied (the env-var
|
||||
/// dev flow). Kept for diagnostics/logging only.</summary>
|
||||
string? SessionConfigPath,
|
||||
/// <summary>Campaign LA slice LA1: the configured session's id, used as
|
||||
/// the <c>sessionId</c> field on every status-stream event. Defaults to
|
||||
/// <c>"app"</c> at every call site when unset (env-var flow).</summary>
|
||||
string? SessionId,
|
||||
/// <summary>Campaign LA slice LA1: the session-config character
|
||||
/// selector, or <see langword="null"/> for today's existing
|
||||
/// first-available fallback (absent selector = LA7's char-select screen
|
||||
/// stop point once that slice lands; this slice does not build the
|
||||
/// screen).</summary>
|
||||
LiveSessionCharacterSelector? LiveCharacterSelector,
|
||||
/// <summary>Campaign LA slice LA1: absolute path for the status-event
|
||||
/// JSONL stream. <see langword="null"/> = no writer constructed.</summary>
|
||||
string? StatusFilePath,
|
||||
/// <summary>Campaign LA slice LA1: plugin ids to load.
|
||||
/// <see langword="null"/> = load every discovered plugin (today's
|
||||
/// behavior). Consumed by LA5; parsed and carried now.</summary>
|
||||
IReadOnlyList<string>? Plugins,
|
||||
/// <summary>Campaign LA slice LA1: ordered chat-typed strings run once
|
||||
/// entered-world. Consumed by LA6; parsed and carried now.</summary>
|
||||
IReadOnlyList<string> LoginCommands,
|
||||
/// <summary>Campaign LA slice LA1: inter-command delay for
|
||||
/// <see cref="LoginCommands"/>, milliseconds.</summary>
|
||||
int LoginCommandDelayMs)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
|
|
@ -170,9 +200,72 @@ public sealed record RuntimeOptions(
|
|||
// closes the window. Zero -- unset, unparseable, or an explicit 0 --
|
||||
// keeps the interactive behaviour, so no existing invocation changes.
|
||||
VulkanCapabilityProbeFrames:
|
||||
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0);
|
||||
TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0,
|
||||
// Campaign LA slice LA1: the env-var dev flow never carries a
|
||||
// session-config document — every new field below stays at its
|
||||
// "nothing configured" default. RuntimeOptions.FromSessionConfig
|
||||
// overlays the real values on top of this base.
|
||||
SessionConfigPath: null,
|
||||
SessionId: null,
|
||||
LiveCharacterSelector: null,
|
||||
StatusFilePath: null,
|
||||
Plugins: null,
|
||||
LoginCommands: [],
|
||||
LoginCommandDelayMs: 500);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: builds options for the <c>--session-config</c>
|
||||
/// launch path. Starts from the same env-var parse as
|
||||
/// <see cref="FromEnvironment"/> (diagnostic/dev flags are still
|
||||
/// env-controlled — only the LIVE session settings and the five new LA1
|
||||
/// fields come from the document) and overlays the resolved session.
|
||||
/// <paramref name="resolvedPassword"/> is revealed into
|
||||
/// <see cref="LivePass"/> exactly as wide as the existing env-var flow —
|
||||
/// see that field's own doc.
|
||||
/// </summary>
|
||||
internal static RuntimeOptions FromSessionConfig(
|
||||
string datDir,
|
||||
Func<string, string?> env,
|
||||
string sessionConfigPath,
|
||||
SessionConfiguration config,
|
||||
SessionDescriptor session,
|
||||
string? resolvedPassword)
|
||||
{
|
||||
if (config is null) throw new ArgumentNullException(nameof(config));
|
||||
if (session is null) throw new ArgumentNullException(nameof(session));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionConfigPath);
|
||||
|
||||
RuntimeOptions baseOptions = Parse(datDir, env);
|
||||
SessionContentDescriptor? content = config.Process?.Content;
|
||||
return baseOptions with
|
||||
{
|
||||
PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath)
|
||||
?? baseOptions.PreparedAssetPath,
|
||||
LiveMode = true,
|
||||
LiveHost = session.Endpoint.Host,
|
||||
LivePort = session.Endpoint.Port,
|
||||
LiveUser = session.Account,
|
||||
LivePass = resolvedPassword,
|
||||
SessionConfigPath = sessionConfigPath,
|
||||
SessionId = session.Id,
|
||||
LiveCharacterSelector = MapCharacterSelector(session.Character),
|
||||
StatusFilePath = NullIfEmpty(session.StatusFile),
|
||||
Plugins = session.Plugins,
|
||||
LoginCommands = (IReadOnlyList<string>?)session.LoginCommands ?? [],
|
||||
LoginCommandDelayMs = session.LoginCommandDelayMs,
|
||||
};
|
||||
}
|
||||
|
||||
private static LiveSessionCharacterSelector? MapCharacterSelector(
|
||||
SessionCharacterSelectorDescriptor? selector) =>
|
||||
selector is null
|
||||
? null
|
||||
: new LiveSessionCharacterSelector(
|
||||
selector.Index,
|
||||
selector.Id,
|
||||
selector.Name);
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
public bool HasLiveCredentials =>
|
||||
LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
|
||||
|
|
|
|||
|
|
@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor
|
|||
/// legal no-ops.
|
||||
/// </summary>
|
||||
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
|
||||
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
|
||||
/// Absent means load every discovered plugin (today's dev behavior);
|
||||
/// LA5 wires this into an actual allow-list filter. Parsed and carried
|
||||
/// here now so the session-config shape is stable before LA5 lands.
|
||||
/// </summary>
|
||||
public List<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: ordered chat-typed strings run once the
|
||||
/// session enters world. LA6 wires actual execution; parsed and carried
|
||||
/// here now.
|
||||
/// </summary>
|
||||
public List<string>? LoginCommands { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: inter-command delay for
|
||||
/// <see cref="LoginCommands"/>, in milliseconds. Matches the pinned
|
||||
/// launch-contract default (500 ms) when the field is absent from the
|
||||
/// document.
|
||||
/// </summary>
|
||||
public int LoginCommandDelayMs { get; init; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: absolute path for this session's status-event
|
||||
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
|
||||
/// §6). Absent means no <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>
|
||||
/// is constructed for this session.
|
||||
/// </summary>
|
||||
public string? StatusFile { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class HeadlessEndpointDescriptor
|
||||
|
|
|
|||
|
|
@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader
|
|||
}
|
||||
|
||||
ValidateCharacterOptions(session);
|
||||
ValidateLaunchContractFields(session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: validates the four new optional per-session
|
||||
/// fields shared with the App session-config reader (see
|
||||
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's pinned
|
||||
/// contract). All four stay optional; only their SHAPE is checked here
|
||||
/// — parsing/executing <c>plugins</c>/<c>loginCommands</c> is LA5/LA6.
|
||||
/// </summary>
|
||||
private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session)
|
||||
{
|
||||
if (session.Plugins is { } plugins)
|
||||
{
|
||||
foreach (string? plugin in plugins)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plugin))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' plugins entries must be non-empty strings.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (session.LoginCommandDelayMs < 0)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
|
||||
}
|
||||
|
||||
if (session.StatusFile is not null
|
||||
&& string.IsNullOrWhiteSpace(session.StatusFile))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -113,6 +113,19 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private readonly HeadlessCredentialSecret _credential;
|
||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: a SEPARATE per-session sink from
|
||||
/// <see cref="_diagnostics"/> — a no-op instance when
|
||||
/// <see cref="HeadlessSessionDescriptor.StatusFile"/> was not configured.
|
||||
/// See <see cref="SessionStatusWriter"/>'s own doc for why this is not a
|
||||
/// rework of the shared-stdout diagnostics writer.
|
||||
/// </summary>
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
/// <summary>Guards <see cref="Stop"/>'s <c>disconnected</c> status event
|
||||
/// so a Stop() on a session that never actually reached Connected (e.g.
|
||||
/// disposing a fresh, never-started host) does not report a spurious
|
||||
/// disconnect.</summary>
|
||||
private bool _hasConnected;
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP7 (2026-08-11), D8: the parsed
|
||||
/// <c>characterOptions</c> block — empty when the config omitted it.
|
||||
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
|
||||
|
|
@ -271,6 +284,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
var commands = new DirectGameRuntimeCommandAdapter(
|
||||
runtime,
|
||||
bridge);
|
||||
// Campaign LA slice LA1: no-op instance when
|
||||
// descriptor.StatusFile is unset — every call site below stays
|
||||
// unconditional.
|
||||
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
||||
var liveSession = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
|
|
@ -318,14 +335,25 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
descriptor.Id,
|
||||
$"connecting:{host}:{port}:{user}",
|
||||
runtime.Generation.Value),
|
||||
() => diagnostics.Message(
|
||||
() =>
|
||||
{
|
||||
diagnostics.Message(
|
||||
descriptor.Id,
|
||||
"connected",
|
||||
runtime.Generation.Value);
|
||||
statusWriter.Connected(descriptor.Id);
|
||||
_hasConnected = true;
|
||||
},
|
||||
roster => statusWriter.CharacterList(descriptor.Id, roster),
|
||||
selection => statusWriter.EnteredWorld(
|
||||
descriptor.Id,
|
||||
"connected",
|
||||
runtime.Generation.Value)));
|
||||
selection.CharacterId,
|
||||
selection.CharacterName)));
|
||||
|
||||
Runtime = runtime;
|
||||
Commands = commands;
|
||||
_liveSession = liveSession;
|
||||
_statusWriter = statusWriter;
|
||||
_localPlayerFrame =
|
||||
runtime.CreateLocalPlayerFrameController(
|
||||
new HeadlessLocalPlayerFrameHost(
|
||||
|
|
@ -421,8 +449,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_pendingConfirmation = null;
|
||||
}
|
||||
|
||||
internal RuntimeSessionStartResult Start() =>
|
||||
Commands.Session.Start(Runtime.Generation);
|
||||
internal RuntimeSessionStartResult Start()
|
||||
{
|
||||
// Campaign LA slice LA1: "started" = session host start — the
|
||||
// earliest point this session actually attempts to connect.
|
||||
_statusWriter.Started(_descriptor.Id);
|
||||
return Commands.Session.Start(Runtime.Generation);
|
||||
}
|
||||
|
||||
internal RuntimeSessionStartResult Reconnect() =>
|
||||
Commands.Session.Reconnect(Runtime.Generation);
|
||||
|
|
@ -470,6 +503,15 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// (possibly disposed) WorldSession in the window between this Stop
|
||||
// and the next CreateEventRoute call.
|
||||
_currentSession = null;
|
||||
// Campaign LA slice LA1: only report a disconnect for a session that
|
||||
// actually reached Connected — a Stop() on a never-started or
|
||||
// never-connected host (e.g. immediate Dispose()) is not a real
|
||||
// disconnect.
|
||||
if (_hasConnected)
|
||||
{
|
||||
_hasConnected = false;
|
||||
_statusWriter.Disconnected(_descriptor.Id, "stopped");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -592,6 +634,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_descriptor.Id,
|
||||
"disposed",
|
||||
_stoppedGeneration);
|
||||
// Campaign LA slice LA1: "exited" = terminal — the sole
|
||||
// point every disposal path (graceful and post-
|
||||
// quarantine) converges on.
|
||||
_statusWriter.Exited(
|
||||
_descriptor.Id,
|
||||
_faulted ? 1 : 0,
|
||||
_faulted ? "fault" : "disposed");
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,32 @@ public sealed record LiveSessionStartResult(
|
|||
LiveSessionCharacterSelection? Selection = null,
|
||||
Exception? Error = null);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: one roster entry as reported by
|
||||
/// <see cref="CharacterList.Parsed"/> — decoupled from the wire type so the
|
||||
/// lifecycle-host seam does not leak <c>AcDream.Core.Net.Messages</c> shapes
|
||||
/// into every consumer.
|
||||
/// </summary>
|
||||
public readonly record struct LiveSessionRosterEntry(
|
||||
uint Id,
|
||||
string Name,
|
||||
uint SecondsGreyedOut);
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: the account's active-character roster, reported to
|
||||
/// <see cref="ILiveSessionLifecycleHost.ReportRoster"/> right after
|
||||
/// <c>CharacterList</c> arrives and BEFORE selection — see
|
||||
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 item 2. Hosts forward
|
||||
/// this to their status stream (<c>characterList</c> event) and, later
|
||||
/// (LA7/LA8), to the character-select screen. Deleted characters are
|
||||
/// deliberately excluded — the same candidate set
|
||||
/// <see cref="CharacterList.TrySelectFirstAvailable"/> already uses.
|
||||
/// </summary>
|
||||
public sealed record LiveSessionRosterReport(
|
||||
string AccountName,
|
||||
int SlotCount,
|
||||
IReadOnlyList<LiveSessionRosterEntry> Entries);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime boundary for the domain and presentation sinks attached to one
|
||||
/// exact <see cref="WorldSession"/> generation. The controller owns the
|
||||
|
|
@ -61,6 +87,10 @@ public interface ILiveSessionLifecycleHost
|
|||
void ResetSessionState(RuntimeGenerationToken retiringGeneration);
|
||||
void ReportConnecting(string host, int port, string user);
|
||||
void ReportConnected();
|
||||
/// <summary>Campaign LA slice LA1: reported once per successful
|
||||
/// <c>CharacterList</c> receipt, right before character selection. See
|
||||
/// <see cref="LiveSessionRosterReport"/>.</summary>
|
||||
void ReportRoster(LiveSessionRosterReport roster);
|
||||
void ApplySelectedCharacter(LiveSessionCharacterSelection selection);
|
||||
void ApplyEnteredWorld(LiveSessionCharacterSelection selection);
|
||||
void DetachSession(WorldSession session);
|
||||
|
|
@ -610,6 +640,13 @@ public sealed class LiveSessionController
|
|||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
||||
CharacterList.Parsed? characters = _operations.GetCharacters(session);
|
||||
if (characters is not null)
|
||||
{
|
||||
host.ReportRoster(BuildRosterReport(characters));
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
|
||||
if (characters is null
|
||||
|| !TrySelectCharacter(
|
||||
characters,
|
||||
|
|
@ -838,6 +875,29 @@ public sealed class LiveSessionController
|
|||
private LiveSessionStartResult ConnectedResult()
|
||||
=> new(LiveSessionStartStatus.Connected, _activeSelection);
|
||||
|
||||
/// <summary>Campaign LA slice LA1: projects the wire-shaped
|
||||
/// <see cref="CharacterList.Parsed"/> into the decoupled
|
||||
/// <see cref="LiveSessionRosterReport"/>. Deleted characters are
|
||||
/// excluded, matching <see cref="CharacterList.TrySelectFirstAvailable"/>'s
|
||||
/// candidate set.</summary>
|
||||
private static LiveSessionRosterReport BuildRosterReport(
|
||||
CharacterList.Parsed characters)
|
||||
{
|
||||
var entries = new LiveSessionRosterEntry[characters.Characters.Count];
|
||||
for (int i = 0; i < entries.Length; i++)
|
||||
{
|
||||
CharacterList.Character character = characters.Characters[i];
|
||||
entries[i] = new LiveSessionRosterEntry(
|
||||
character.Id,
|
||||
character.Name,
|
||||
character.SecondsGreyedOut);
|
||||
}
|
||||
return new LiveSessionRosterReport(
|
||||
characters.AccountName,
|
||||
characters.SlotCount,
|
||||
entries);
|
||||
}
|
||||
|
||||
private static bool TrySelectCharacter(
|
||||
CharacterList.Parsed characters,
|
||||
LiveSessionCharacterSelector? selector,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,18 @@ public sealed record LiveSessionHostBindings(
|
|||
LiveSessionSelectionBindings Selection,
|
||||
LiveSessionEnteredWorldBindings EnteredWorld,
|
||||
Action<string, int, string> Connecting,
|
||||
Action Connected);
|
||||
Action Connected,
|
||||
/// <summary>Campaign LA slice LA1: reported once per successful
|
||||
/// <c>CharacterList</c> receipt, right before character selection — see
|
||||
/// <see cref="LiveSessionRosterReport"/>. Hosts forward this to their
|
||||
/// status stream's <c>characterList</c> event.</summary>
|
||||
Action<LiveSessionRosterReport> Roster,
|
||||
/// <summary>Campaign LA slice LA1: reported once entered-world state is
|
||||
/// applied, carrying the full selection (id + name) — unlike
|
||||
/// <see cref="EnteredWorld"/>'s narrow <c>SetActiveCharacter(string)</c>
|
||||
/// fan-out, this exists so a status writer can emit the
|
||||
/// <c>enteredWorld</c> event's <c>characterId</c> field.</summary>
|
||||
Action<LiveSessionCharacterSelection> CharacterEntered);
|
||||
|
||||
/// <summary>
|
||||
/// Runtime host for the one canonical <see cref="LiveSessionController"/>.
|
||||
|
|
@ -84,6 +95,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
private readonly LiveSessionRoutingFactories _routing;
|
||||
private readonly LiveSessionSelectionBindings _selection;
|
||||
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
|
||||
private readonly Action<LiveSessionCharacterSelection> _characterEntered;
|
||||
private readonly Action<RuntimeGenerationToken> _reset;
|
||||
private readonly LiveSessionLifecycleHost _lifecycle;
|
||||
private PendingRouteRollback? _pendingRouteRollback;
|
||||
|
|
@ -100,11 +112,14 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
|
||||
_enteredWorld = bindings.EnteredWorld
|
||||
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
|
||||
_characterEntered = bindings.CharacterEntered
|
||||
?? throw new ArgumentNullException(nameof(bindings.CharacterEntered));
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
|
||||
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Roster);
|
||||
Validate(_selection, _enteredWorld);
|
||||
|
||||
_reset = bindings.Reset;
|
||||
|
|
@ -113,6 +128,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
Reset: ResetSessionState,
|
||||
Connecting: bindings.Connecting,
|
||||
Connected: bindings.Connected,
|
||||
Roster: bindings.Roster,
|
||||
Selected: ApplySelection,
|
||||
Entered: ApplyEnteredWorld));
|
||||
}
|
||||
|
|
@ -217,6 +233,7 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
_enteredWorld.SyncToolbar();
|
||||
_enteredWorld.LoadCharacterSettings(name);
|
||||
_enteredWorld.ArmPlayerModeAutoEntry();
|
||||
_characterEntered(selection);
|
||||
}
|
||||
|
||||
private void RethrowWithRetryableRollback(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ public sealed record LiveSessionLifecycleBindings(
|
|||
Action<RuntimeGenerationToken> Reset,
|
||||
Action<string, int, string> Connecting,
|
||||
Action Connected,
|
||||
Action<LiveSessionRosterReport> Roster,
|
||||
Action<LiveSessionCharacterSelection> Selected,
|
||||
Action<LiveSessionCharacterSelection> Entered);
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
|||
ArgumentNullException.ThrowIfNull(bindings.Reset);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connecting);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Connected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Roster);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Selected);
|
||||
ArgumentNullException.ThrowIfNull(bindings.Entered);
|
||||
}
|
||||
|
|
@ -51,6 +53,9 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost
|
|||
|
||||
public void ReportConnected() => _bindings.Connected();
|
||||
|
||||
public void ReportRoster(LiveSessionRosterReport roster) =>
|
||||
_bindings.Roster(roster);
|
||||
|
||||
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection) =>
|
||||
_bindings.Selected(selection);
|
||||
|
||||
|
|
|
|||
162
src/AcDream.Runtime/Session/SessionStatusWriter.cs
Normal file
162
src/AcDream.Runtime/Session/SessionStatusWriter.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace AcDream.Runtime.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: appends one JSON object per line to a per-session
|
||||
/// status-event file the launcher tails
|
||||
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1,
|
||||
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6).
|
||||
///
|
||||
/// <para>
|
||||
/// This is a SEPARATE sink from <c>HeadlessDiagnosticWriter</c> — that class
|
||||
/// is a single shared-stdout JSONL diagnostics stream with no per-session
|
||||
/// file; this class writes one file per session, meant to be read by an
|
||||
/// external process (the launcher) rather than scraped from console output.
|
||||
/// Event shapes are versioned (<c>"v":1</c>) so a future event kind
|
||||
/// (<c>pluginLoaded</c>/<c>pluginFailed</c>, LA5) can be added without
|
||||
/// breaking an existing reader.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Every write opens the file in append mode with <see cref="FileShare.Read"/>
|
||||
/// so an external tailer can read the file concurrently, writes exactly one
|
||||
/// line, flushes, and closes — there is no long-lived file handle to leak or
|
||||
/// to dispose. A writer constructed with a <see langword="null"/> or blank
|
||||
/// path is a permanent no-op: every method becomes a cheap null-check, so
|
||||
/// callers never need to guard construction sites on whether a status file
|
||||
/// was configured.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <strong>Never write credential material into this stream.</strong> Every
|
||||
/// event method below takes only identifiers, names, and counts — there is no
|
||||
/// parameter shape that could carry a password, by construction.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SessionStatusWriter
|
||||
{
|
||||
private const int VocabularyVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
private readonly string? _path;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public SessionStatusWriter(string? path, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_path = string.IsNullOrWhiteSpace(path) ? null : Path.GetFullPath(path);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when this writer has a configured path and will actually append
|
||||
/// events. Lets a caller with an expensive report to build (e.g. the
|
||||
/// roster projection) skip that work entirely when nobody configured a
|
||||
/// status file for this session.
|
||||
/// </summary>
|
||||
public bool IsEnabled => _path is not null;
|
||||
|
||||
public void Started(string sessionId) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "started",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
public void Connected(string sessionId) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "connected",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
public void CharacterList(string sessionId, LiveSessionRosterReport roster)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(roster);
|
||||
if (!IsEnabled)
|
||||
return;
|
||||
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "characterList",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
accountName = roster.AccountName,
|
||||
slotCount = roster.SlotCount,
|
||||
characters = roster.Entries
|
||||
.Select(static entry => new
|
||||
{
|
||||
id = entry.Id,
|
||||
name = entry.Name,
|
||||
secondsGreyedOut = entry.SecondsGreyedOut,
|
||||
})
|
||||
.ToArray(),
|
||||
});
|
||||
}
|
||||
|
||||
public void EnteredWorld(string sessionId, uint characterId, string characterName) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "enteredWorld",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
characterId,
|
||||
characterName,
|
||||
});
|
||||
|
||||
public void Disconnected(string sessionId, string reason) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "disconnected",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
reason,
|
||||
});
|
||||
|
||||
public void Exited(string sessionId, int code, string reason) =>
|
||||
Write(new
|
||||
{
|
||||
v = VocabularyVersion,
|
||||
e = "exited",
|
||||
t = Now(),
|
||||
sessionId,
|
||||
code,
|
||||
reason,
|
||||
});
|
||||
|
||||
private string Now() =>
|
||||
_timeProvider.GetUtcNow().ToString(
|
||||
"O",
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private void Write<T>(T value)
|
||||
{
|
||||
if (_path is not { } path)
|
||||
return;
|
||||
|
||||
string line = JsonSerializer.Serialize(value, JsonOptions);
|
||||
lock (_gate)
|
||||
{
|
||||
using FileStream stream = new(
|
||||
path,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.Read);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.WriteLine(line);
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue