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
|
|
@ -222,7 +222,9 @@ public sealed class HeadlessSessionEventRouteRetryPendingTests
|
|||
new LiveSessionEnteredWorldBindings(
|
||||
_ => { }, () => { }, () => { }, _ => { }, () => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
() => { },
|
||||
_ => { },
|
||||
_ => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using AcDream.Headless.Configuration;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: proves the Headless config reader accepts the
|
||||
/// EXACT document the App reader also accepts —
|
||||
/// <c>tests/Fixtures/campaign-la/session-config-shared-fixture.json</c> is
|
||||
/// parsed by both <see cref="HeadlessConfigurationLoader"/> here and
|
||||
/// <c>AcDream.App.Configuration.SessionConfigurationLoader</c> in
|
||||
/// <c>AcDream.App.Tests</c>'s twin of this test. This is the pinned-contract
|
||||
/// acceptance test from <c>docs/plans/2026-08-14-launcher-campaign.md</c>
|
||||
/// LA1: "a SHARED fixture JSON parsed by both test suites proving the two
|
||||
/// readers accept the identical document." If either reader's DTO shape
|
||||
/// drifts from the pinned contract, ONE of these two tests fails.
|
||||
/// </summary>
|
||||
public sealed class SessionConfigurationSharedFixtureTests
|
||||
{
|
||||
[Fact]
|
||||
public void HeadlessReaderAcceptsTheSharedFixtureAndParsesTheFiveNewFields()
|
||||
{
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(SharedFixturePath());
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
||||
Assert.Equal("shared-fixture", session.Id);
|
||||
Assert.Equal("127.0.0.1", session.Endpoint.Host);
|
||||
Assert.Equal(9000, session.Endpoint.Port);
|
||||
Assert.Equal("sharedaccount", session.Account);
|
||||
Assert.Equal("SharedToon", session.Character.Name);
|
||||
Assert.Equal("idle", session.Policy.Id);
|
||||
Assert.Equal(
|
||||
HeadlessCredentialProviderKind.Environment,
|
||||
session.Credential.Provider);
|
||||
Assert.Equal("SHARED_FIXTURE_PASSWORD", session.Credential.Reference);
|
||||
|
||||
Assert.Equal(["ExamplePlugin", "AnotherPlugin"], session.Plugins);
|
||||
Assert.Equal(
|
||||
["/tell someone, hi", "/vt start"],
|
||||
session.LoginCommands);
|
||||
Assert.Equal(750, session.LoginCommandDelayMs);
|
||||
Assert.Equal("shared-fixture-status.jsonl", session.StatusFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AbsentLaunchContractFieldsFallBackToPinnedDefaults()
|
||||
{
|
||||
// Every LA1 field is optional; a document that omits all five must
|
||||
// still load, with loginCommandDelayMs defaulting to the pinned
|
||||
// 500 ms and the rest defaulting to "nothing configured".
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "no-launch-contract-fields",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" }
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
HeadlessConfiguration configuration =
|
||||
HeadlessConfigurationLoader.Load(file.Path);
|
||||
|
||||
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
|
||||
Assert.Null(session.Plugins);
|
||||
Assert.Null(session.LoginCommands);
|
||||
Assert.Equal(500, session.LoginCommandDelayMs);
|
||||
Assert.Null(session.StatusFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyPluginsEntryFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-plugins",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"plugins": ["Ok", " "]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeLoginCommandDelayFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-delay",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"loginCommandDelayMs": -1
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlankStatusFileFailsLoad()
|
||||
{
|
||||
using TemporaryFile file = TemporaryFile.Create(
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"id": "bad-status-file",
|
||||
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||
"account": "account",
|
||||
"character": { "index": 0 },
|
||||
"policy": { "id": "idle" },
|
||||
"credential": { "provider": "environment", "reference": "X" },
|
||||
"statusFile": " "
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
Assert.Throws<HeadlessConfigurationException>(
|
||||
() => HeadlessConfigurationLoader.Load(file.Path));
|
||||
}
|
||||
|
||||
internal static string SharedFixturePath(
|
||||
[CallerFilePath] string sourcePath = "") =>
|
||||
Path.Combine(
|
||||
FindRepositoryRoot(sourcePath),
|
||||
"tests",
|
||||
"Fixtures",
|
||||
"campaign-la",
|
||||
"session-config-shared-fixture.json");
|
||||
|
||||
private static string FindRepositoryRoot(string sourcePath)
|
||||
{
|
||||
string[] starts =
|
||||
{
|
||||
Path.GetDirectoryName(sourcePath) ?? string.Empty,
|
||||
Directory.GetCurrentDirectory(),
|
||||
AppContext.BaseDirectory,
|
||||
};
|
||||
foreach (string start in starts)
|
||||
{
|
||||
if (string.IsNullOrEmpty(start))
|
||||
continue;
|
||||
|
||||
DirectoryInfo? directory = new(start);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not find AcDream.slnx above the working or output directory.");
|
||||
}
|
||||
|
||||
private sealed class TemporaryFile : IDisposable
|
||||
{
|
||||
private TemporaryFile(string path) => Path = path;
|
||||
|
||||
internal string Path { get; }
|
||||
|
||||
internal static TemporaryFile Create(string json)
|
||||
{
|
||||
string path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
$"acdream-headless-la1-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, json);
|
||||
return new TemporaryFile(path);
|
||||
}
|
||||
|
||||
public void Dispose() => File.Delete(Path);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue