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

@ -55,7 +55,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
_ => { },
() => { }),
(_, _, _) => { },
() => { }),
() => { },
_ => { },
_ => { }),
options);
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
var trace = new RuntimeTraceRecorder();
@ -752,7 +754,9 @@ public sealed class DirectGameRuntimeCommandAdapterTests
_ => { },
() => { }),
(_, _, _) => { },
() => { }),
() => { },
_ => { },
_ => { }),
options);
adapter = new DirectGameRuntimeCommandAdapter(runtime, live);
_ = adapter.Session.Start(runtime.Generation);

View file

@ -135,6 +135,7 @@ public sealed class LiveSessionControllerTests
public Action? OnReset { get; set; }
public Action? OnConnecting { get; set; }
public Action? OnConnected { get; set; }
public Action? OnRoster { get; set; }
public Action? OnSelected { get; set; }
public Action? OnActivate { get; set; }
public Action? OnEntered { get; set; }
@ -146,6 +147,7 @@ public sealed class LiveSessionControllerTests
public bool ThrowOnBind { get; set; }
public bool ThrowOnConnecting { get; set; }
public bool ThrowOnConnected { get; set; }
public bool ThrowOnRoster { get; set; }
public bool ThrowOnSelected { get; set; }
public bool ThrowOnActivate { get; set; }
public bool ThrowOnEntered { get; set; }
@ -158,6 +160,7 @@ public sealed class LiveSessionControllerTests
public List<TestCommandBus> CommandBuses { get; } = [];
public List<LiveSessionCharacterSelection> Selections { get; } = [];
public List<RuntimeGenerationToken> ResetGenerations { get; } = [];
public List<LiveSessionRosterReport> Rosters { get; } = [];
public LiveSessionBinding BindSession(WorldSession session)
{
@ -231,6 +234,15 @@ public sealed class LiveSessionControllerTests
throw new InvalidOperationException("connected failure");
}
public void ReportRoster(LiveSessionRosterReport roster)
{
calls.Add("roster");
Rosters.Add(roster);
OnRoster?.Invoke();
if (ThrowOnRoster)
throw new InvalidOperationException("roster failure");
}
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
{
calls.Add("selected");
@ -290,7 +302,7 @@ public sealed class LiveSessionControllerTests
Assert.Equal(
[
"reset", "resolve", "create", "bind", "report-connecting",
"connect", "report-connected", "selected", "enter:1",
"connect", "report-connected", "roster", "selected", "enter:1",
"activate", "entered",
],
calls);
@ -302,6 +314,32 @@ public sealed class LiveSessionControllerTests
Assert.True(host.CommandBuses[0].Active);
}
[Fact]
public void Start_ReportsRosterFromCharacterListBeforeSelection()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
LiveSessionRosterReport roster = Assert.Single(host.Rosters);
Assert.Equal("Canonical", roster.AccountName);
Assert.Equal(11, roster.SlotCount);
Assert.Equal(
[
new LiveSessionRosterEntry(0x50000001u, "Grey", 10u),
new LiveSessionRosterEntry(0x50000002u, "Ready", 0u),
],
roster.Entries);
// "roster" must land strictly before "selected" — the launcher's
// char-select screen (LA7/LA8) will read the roster before any
// selection has been made.
Assert.True(calls.IndexOf("roster") < calls.IndexOf("selected"));
}
[Fact]
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
{
@ -488,7 +526,7 @@ public sealed class LiveSessionControllerTests
[
"deactivate", "detach-events", "dispose-session", "detach-session",
"reset", "resolve", "create", "bind", "report-connecting",
"connect", "report-connected", "selected", "enter:1",
"connect", "report-connected", "roster", "selected", "enter:1",
"activate", "entered",
],
calls);
@ -742,6 +780,7 @@ public sealed class LiveSessionControllerTests
[InlineData("connecting")]
[InlineData("connected")]
[InlineData("characters")]
[InlineData("roster")]
[InlineData("selected")]
[InlineData("activate")]
[InlineData("entered")]
@ -755,6 +794,7 @@ public sealed class LiveSessionControllerTests
case "connecting": host.ThrowOnConnecting = true; break;
case "connected": host.ThrowOnConnected = true; break;
case "characters": operations.ThrowOnCharacters = true; break;
case "roster": host.ThrowOnRoster = true; break;
case "selected": host.ThrowOnSelected = true; break;
case "activate": host.ThrowOnActivate = true; break;
case "entered": host.ThrowOnEntered = true; break;

View file

@ -35,12 +35,13 @@ public sealed class LiveSessionHostTests
Assert.Equal(
[
"reset", "resolve", "create", "events", "attach-events", "commands",
"connecting", "connect", "connected",
"connecting", "connect", "connected", "roster:Canonical",
"player:1342177282", "vitals:1342177282",
"chat:1342177282", "persistent:1342177282",
"vanish:1342177282", "clear-combat", "enter:1",
"activate", "active:Ready", "restore-layout",
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
"character-entered:1342177282",
],
calls);
Assert.Same(controller.CurrentSession, host.CurrentSession);
@ -236,7 +237,10 @@ public sealed class LiveSessionHostTests
name => calls.Add($"load-settings:{name}"),
() => calls.Add("arm-auto-entry")),
Connecting: (_, _, _) => calls.Add("connecting"),
Connected: () => calls.Add("connected")));
Connected: () => calls.Add("connected"),
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
CharacterEntered: selection =>
calls.Add($"character-entered:{selection.CharacterId}")));
private static LiveSessionConnectOptions LiveOptions(
bool live = true,

View file

@ -18,6 +18,7 @@ public sealed class LiveSessionLifecycleHostTests
host.ResetSessionState(RuntimeGenerationToken.Initial);
host.ReportConnecting("host", 9000, "user");
host.ReportConnected();
host.ReportRoster(new LiveSessionRosterReport("account", 11, []));
var selection = new LiveSessionCharacterSelection(2, 3u, "toon", "account");
host.ApplySelectedCharacter(selection);
binding.ActivateCommands();
@ -31,8 +32,8 @@ public sealed class LiveSessionLifecycleHostTests
Assert.Equal(
[
"bind", "reset", "connecting:host:9000:user",
"connected", "selected:toon", "activate", "entered:toon",
"deactivate", "detach-events", "bind",
"connected", "roster:account", "selected:toon", "activate",
"entered:toon", "deactivate", "detach-events", "bind",
],
calls);
replacement.Dispose();
@ -71,6 +72,7 @@ public sealed class LiveSessionLifecycleHostTests
Connecting: (host, port, user) =>
calls.Add($"connecting:{host}:{port}:{user}"),
Connected: () => calls.Add("connected"),
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
Selected: selection => calls.Add($"selected:{selection.CharacterName}"),
Entered: selection => calls.Add($"entered:{selection.CharacterName}")));

View file

@ -2342,7 +2342,9 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
_ => { },
() => { }),
(_, _, _) => { },
() => { }),
() => { },
_ => { },
_ => { }),
options);
LiveSessionStartResult startResult = live.Start(options);
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);

View file

@ -1049,7 +1049,9 @@ public sealed class RuntimeLiveEntitySessionControllerTests
_ => { },
() => { }),
(_, _, _) => { },
() => { }),
() => { },
_ => { },
_ => { }),
options);
LiveSessionStartResult startResult = live.Start(options);
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);

View file

@ -34,7 +34,9 @@ public sealed class RuntimeLiveSessionNoWindowTests
_ => { },
() => { }),
(_, _, _) => calls.Add("connecting"),
() => calls.Add("connected")),
() => calls.Add("connected"),
_ => calls.Add("roster"),
selection => calls.Add($"character-entered:{selection.CharacterId}")),
new LiveSessionConnectOptions(
true,
"127.0.0.1",
@ -61,10 +63,12 @@ public sealed class RuntimeLiveSessionNoWindowTests
"connect",
"connected",
"characters",
"roster",
"player:1342177281",
"enter:0",
"activate-commands",
"entered:Runtime",
"character-entered:1342177281",
"deactivate-commands",
"detach-events",
"dispose-session",

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

View file

@ -72,7 +72,9 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
host,
port,
connectingUser),
_operations.RecordConnected),
_operations.RecordConnected,
_operations.RecordRoster,
_operations.RecordCharacterEntered),
new LiveSessionConnectOptions(
true,
"127.0.0.1",
@ -693,6 +695,13 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
Trace.Add($"connecting:{host}:{port}:{user}");
public void RecordConnected() => Trace.Add("connected");
public void RecordRoster(LiveSessionRosterReport roster) =>
Trace.Add($"roster:{roster.AccountName}");
public void RecordCharacterEntered(
LiveSessionCharacterSelection selection) =>
Trace.Add($"character-entered:{selection.CharacterId}");
}
private sealed class FixtureTransport : IWorldSessionTransport