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

@ -3,6 +3,7 @@ using System.Collections.Immutable;
using System.Net;
using System.Numerics;
using System.Reflection;
using System.Text.Json;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
@ -61,6 +62,95 @@ public sealed class HeadlessSessionHostTests
Assert.DoesNotContain("AcDream.App", diagnostics);
}
/// <summary>
/// Campaign LA slice LA1: proves the status-event writer fires the
/// pinned lifecycle vocabulary — started/connected/characterList/
/// enteredWorld/disconnected/exited — in order, from a real
/// <see cref="HeadlessSessionHost"/> start+dispose cycle, and that the
/// roster surfaced matches <see cref="FixtureSessionOperations.GetCharacters"/>
/// exactly (before selection has happened — the roster is reported for
/// BOTH candidates, not just the selected one).
/// </summary>
[Fact]
public void StatusFileReceivesThePinnedLifecycleEventsInOrder()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-status-{Guid.NewGuid():N}.jsonl");
try
{
var operations = new FixtureSessionOperations();
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(statusFile: statusPath),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
RuntimeSessionStartResult started = host.Start();
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
host.Dispose();
string[] lines = File.ReadAllLines(statusPath);
string[] eventNames = lines
.Select(line => JsonDocument.Parse(line)
.RootElement.GetProperty("e").GetString()!)
.ToArray();
Assert.Equal(
[
"started", "connected", "characterList", "enteredWorld",
"disconnected", "exited",
],
eventNames);
using JsonDocument characterListDoc = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "characterList")]);
JsonElement characterList = characterListDoc.RootElement;
Assert.Equal("account", characterList.GetProperty("accountName").GetString());
Assert.Equal(2, characterList.GetProperty("characters").GetArrayLength());
using JsonDocument enteredWorldDoc = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "enteredWorld")]);
Assert.Equal(
0x50000002u,
enteredWorldDoc.RootElement.GetProperty("characterId").GetUInt32());
using JsonDocument exitedDoc = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "exited")]);
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
string contents = File.ReadAllText(statusPath);
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
}
[Fact]
public void AbsentStatusFileConstructsANoOpWriter()
{
var operations = new FixtureSessionOperations();
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret("fixture", "password");
using var host = new HeadlessSessionHost(
Descriptor(),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
RuntimeSessionStartResult started = host.Start();
Assert.Equal(RuntimeSessionStartStatus.Connected, started.Status);
// No exception, and (implicitly) no file was ever touched — the
// writer is a permanent no-op with no configured path.
}
[Fact]
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
{
@ -1937,7 +2027,9 @@ public sealed class HeadlessSessionHostTests
_ => { },
() => { }),
(_, _, _) => { },
() => { }));
() => { },
_ => { },
_ => { }));
}
private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations
@ -1967,7 +2059,8 @@ public sealed class HeadlessSessionHostTests
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
string credentialReference = "BOT_PASSWORD",
Dictionary<string, bool>? characterOptions = null) => new()
Dictionary<string, bool>? characterOptions = null,
string? statusFile = null) => new()
{
Id = "bot",
Endpoint = new HeadlessEndpointDescriptor
@ -1990,6 +2083,7 @@ public sealed class HeadlessSessionHostTests
Reference = credentialReference,
},
CharacterOptions = characterOptions,
StatusFile = statusFile,
};
private static void HydrateGroundedPlayer(GameRuntime runtime)