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:
Erik 2026-08-14 16:04:32 +02:00
parent 0bcc7ba3a3
commit db9ad53c1c
38 changed files with 2397 additions and 40 deletions

View file

@ -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,

View file

@ -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(

View file

@ -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);

View 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();
}
}
}