test: Campaign LA finish LA2 probe and idle gates

Prove idle play remains passive and live until cancellation, then converges through one truthful status teardown. Keep probe mode string-only so numeric enum aliases cannot expand the pinned v1 contract, and record the Windows/WSL gates.
This commit is contained in:
Erik 2026-08-14 16:49:51 +02:00
parent c601942467
commit 000ea979d5
5 changed files with 243 additions and 38 deletions

View file

@ -478,7 +478,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|---|---|---|---|---| |---|---|---|---|---|
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | | LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
| LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers | | LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers |
| LA2 | — | | | | | LA2 | implementation complete; automated gates **GREEN**; Opus dual-lens review pending | `c6019424` + completion (this commit) | pending | Probe is roster-before-selection with graceful pre-world teardown and exit 0; normal play remains strict selector + `idle` policy. Release build green; Runtime 1,632/1,632 and Headless 141/141 on both Windows and Ubuntu/WSL |
| LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge | | LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge |
| LA4 | — | | | | | LA4 | — | | | |
| LA5 | — | | | | | LA5 | — | | | |

View file

@ -167,9 +167,11 @@ internal sealed class HeadlessBotPolicyDescriptor
/// The pinned launch-contract schema defines exactly two states for a /// The pinned launch-contract schema defines exactly two states for a
/// session — ABSENT (mapped to <see langword="null"/>, meaning "play") or /// session — ABSENT (mapped to <see langword="null"/>, meaning "play") or
/// the literal string <c>"probe"</c> — so <see cref="Probe"/> is the only /// the literal string <c>"probe"</c> — so <see cref="Probe"/> is the only
/// member; there is no explicit "play" spelling. /// member; there is no explicit "play" spelling. This deliberately uses
/// <see cref="HeadlessConfigurationLoader"/>'s global camel-case,
/// string-only enum converter; a per-enum converter with its default options
/// would accidentally accept numeric <c>0</c> as a second probe spelling.
/// </summary> /// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessSessionMode>))]
internal enum HeadlessSessionMode internal enum HeadlessSessionMode
{ {
Probe, Probe,

View file

@ -0,0 +1,77 @@
using System.Reflection;
using AcDream.Headless.Policies;
using AcDream.Runtime;
namespace AcDream.Headless.Tests;
public sealed class HeadlessBotPolicyTests
{
/// <summary>
/// Campaign LA slice LA2: <c>idle</c> is a deliberately passive,
/// non-terminal policy. It must neither inspect Runtime state nor reach
/// any command surface, and no event can make it complete on its own.
/// The process scheduler therefore keeps the play session alive until
/// external cancellation/stop drives the host's ordinary teardown path.
/// </summary>
[Fact]
public void IdlePolicyIsPassiveAndNeverCompletesAutonomously()
{
var policy = new IdleHeadlessBotPolicy();
IGameRuntimeView view = CreateNoTouchProxy<IGameRuntimeView>(
out InvocationCountingProxy viewCalls);
IGameRuntimeCommands commands =
CreateNoTouchProxy<IGameRuntimeCommands>(
out InvocationCountingProxy commandCalls);
for (int index = 0; index < 3; index++)
policy.Tick(view, commands);
RuntimeLifecycleDelta lifecycle = default;
RuntimeCommandDelta command = default;
RuntimeEntityDelta entity = default;
RuntimeInventoryDelta inventory = default;
RuntimeChatDelta chat = default;
RuntimeMovementDelta movement = default;
RuntimePortalDelta portal = default;
RuntimeCombatDelta combat = default;
policy.OnLifecycle(in lifecycle);
policy.OnCommand(in command);
policy.OnEntity(in entity);
policy.OnInventory(in inventory);
policy.OnChat(in chat);
policy.OnMovement(in movement);
policy.OnPortal(in portal);
policy.OnCombat(in combat);
Assert.False(policy.IsComplete);
Assert.Equal(0, viewCalls.InvocationCount);
Assert.Equal(0, commandCalls.InvocationCount);
policy.Dispose();
policy.Dispose();
Assert.False(policy.IsComplete);
}
private static T CreateNoTouchProxy<T>(
out InvocationCountingProxy proxy)
where T : class
{
T value = DispatchProxy.Create<T, InvocationCountingProxy>();
proxy = (InvocationCountingProxy)(object)value;
return value;
}
public class InvocationCountingProxy : DispatchProxy
{
public int InvocationCount { get; private set; }
protected override object? Invoke(
MethodInfo? targetMethod,
object?[]? args)
{
InvocationCount++;
throw new InvalidOperationException(
$"Idle policy unexpectedly invoked {targetMethod?.Name}.");
}
}
}

View file

@ -237,6 +237,38 @@ public sealed class HeadlessConfigurationLoaderTests
Assert.Null(session.Policy); Assert.Null(session.Policy);
} }
/// <summary>
/// The pinned v1 contract has one named mode value: <c>"probe"</c>.
/// In particular, the enum's underlying numeric zero must not become an
/// accidental second spelling through an enum converter configured to
/// allow integers.
/// </summary>
[Theory]
[InlineData("\"play\"")]
[InlineData("0")]
public void SessionModeRejectsEveryValueOtherThanTheNamedProbeMode(
string modeJson)
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
$$"""
{
"version": 1,
"sessions": [
{
"id": "unsupported-mode",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"mode": {{modeJson}},
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
}
]
}
""");
Assert.Throws<JsonException>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact] [Fact]
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField() public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
{ {

View file

@ -1,5 +1,6 @@
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Diagnostics;
using System.Net; using System.Net;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
@ -315,41 +316,122 @@ public sealed class HeadlessSessionHostTests
Assert.False(playSession.IsFaulted); Assert.False(playSession.IsFaulted);
} }
/// <summary>
/// Campaign LA slice LA2: the configured <c>idle</c> policy follows the
/// normal play shape (selector + policy, mode absent), enters world, and
/// remains non-terminal through real scheduler turns until cancellation.
/// Cancellation stops the process loop; the owning host's ordinary
/// disposal transaction then performs graceful session teardown. Status
/// events must describe those boundaries truthfully and remain exactly
/// once even when disposal is repeated.
/// </summary>
[Fact] [Fact]
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode() public async Task IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce()
{ {
var configuration = new HeadlessConfiguration string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl");
try
{ {
Version = 1, var configuration = new HeadlessConfiguration
Sessions = {
[ Version = 1,
Descriptor( Sessions =
HeadlessCredentialProviderKind.StandardInput, [
"stdin-bot"), Descriptor(
], HeadlessCredentialProviderKind.StandardInput,
}; "stdin-bot",
HeadlessPathSet paths = HeadlessPathSet.Resolve( statusFile: statusPath),
new HeadlessPathOverrides()); ],
using var diagnostics = new StringWriter(); };
var operations = new FixtureSessionOperations(); HeadlessPathSet paths = HeadlessPathSet.Resolve(
using var host = new HeadlessProcessHost( new HeadlessPathOverrides());
configuration, using var diagnostics = new StringWriter();
paths, var operations = new FixtureSessionOperations();
new System.IO.StringReader( using var host = new HeadlessProcessHost(
"process-password" + Environment.NewLine), configuration,
diagnostics, paths,
operations); new System.IO.StringReader(
using var cancellation = new CancellationTokenSource(); "process-password" + Environment.NewLine),
cancellation.Cancel(); diagnostics,
operations);
using var cancellation = new CancellationTokenSource();
HeadlessExitCode result = Task<HeadlessExitCode> run = host.RunAsync(cancellation.Token);
await host.RunAsync(cancellation.Token); var timeout = Stopwatch.StartNew();
while (operations.TickCallCount < 3
&& !run.IsCompleted
&& timeout.Elapsed < TimeSpan.FromSeconds(10))
{
await Task.Delay(5);
}
Assert.Equal(HeadlessExitCode.Success, result); Assert.True(
Assert.True(host.Session.Runtime.Session.IsInWorld); operations.TickCallCount >= 3,
Assert.DoesNotContain( $"Expected at least 3 idle scheduler turns, observed {operations.TickCallCount}.");
"process-password", Assert.False(run.IsCompleted);
diagnostics.ToString()); Assert.Equal(1, operations.EnterWorldCallCount);
Assert.Equal("Headless", host.Session.ActiveCharacterName);
Assert.True(host.Session.Runtime.Session.IsInWorld);
Assert.False(host.Session.IsPolicyComplete);
Assert.Equal(
["started", "connected", "characterList", "enteredWorld"],
ReadStatusEventNames(statusPath));
cancellation.Cancel();
HeadlessExitCode result = await run.WaitAsync(
TimeSpan.FromSeconds(10));
Assert.Equal(HeadlessExitCode.Success, result);
// RunAsync owns scheduling, not the host lifetime. The session
// remains honestly connected until its owner disposes it.
Assert.True(host.Session.Runtime.Session.IsInWorld);
Assert.Equal(
["started", "connected", "characterList", "enteredWorld"],
ReadStatusEventNames(statusPath));
host.Dispose();
host.Dispose();
Assert.True(host.Session.Runtime.CaptureOwnership().IsConverged);
Assert.Equal(1, operations.DisposedSessionCount);
string[] lines = File.ReadAllLines(statusPath);
string[] eventNames = ReadStatusEventNames(statusPath);
Assert.Equal(
[
"started", "connected", "characterList", "enteredWorld",
"disconnected", "exited",
],
eventNames);
using JsonDocument disconnected = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "disconnected")]);
Assert.Equal(
"stopped",
disconnected.RootElement.GetProperty("reason").GetString());
using JsonDocument exited = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "exited")]);
JsonElement exit = exited.RootElement;
Assert.Equal(0, exit.GetProperty("code").GetInt32());
string? exitReason = exit.GetProperty("reason").GetString();
Assert.False(string.IsNullOrWhiteSpace(exitReason));
Assert.NotEqual("fault", exitReason);
Assert.NotEqual("probe", exitReason);
Assert.DoesNotContain(
"process-password",
File.ReadAllText(statusPath),
StringComparison.Ordinal);
Assert.DoesNotContain(
"process-password",
diagnostics.ToString(),
StringComparison.Ordinal);
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
} }
[Fact] [Fact]
@ -2276,6 +2358,15 @@ public sealed class HeadlessSessionHostTests
StatusFile = statusFile, StatusFile = statusFile,
}; };
private static string[] ReadStatusEventNames(string path) =>
File.ReadAllLines(path)
.Select(static line =>
{
using JsonDocument document = JsonDocument.Parse(line);
return document.RootElement.GetProperty("e").GetString()!;
})
.ToArray();
private static void HydrateGroundedPlayer(GameRuntime runtime) private static void HydrateGroundedPlayer(GameRuntime runtime)
{ {
const uint player = 0x50000002u; const uint player = 0x50000002u;
@ -2917,11 +3008,17 @@ public sealed class HeadlessSessionHostTests
private sealed class FixtureSessionOperations : ILiveSessionOperations private sealed class FixtureSessionOperations : ILiveSessionOperations
{ {
private int _enterWorldCallCount;
private int _tickCallCount;
public List<WorldSession> Sessions { get; } = []; public List<WorldSession> Sessions { get; } = [];
public int CreatedSessionCount { get; private set; } public int CreatedSessionCount { get; private set; }
public int DisposedSessionCount { get; private set; } public int DisposedSessionCount { get; private set; }
public string? LastUser { get; private set; } public string? LastUser { get; private set; }
public string? LastPassword { get; private set; } public string? LastPassword { get; private set; }
public int EnterWorldCallCount =>
Volatile.Read(ref _enterWorldCallCount);
public int TickCallCount => Volatile.Read(ref _tickCallCount);
public IPEndPoint ResolveEndpoint(string host, int port) => public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port); new(IPAddress.Loopback, port);
@ -2963,19 +3060,16 @@ public sealed class HeadlessSessionHostTests
true, true,
true); true);
/// <summary>Campaign LA slice LA2: lets a probe test assert the
/// live-session controller never reached EnterWorld.</summary>
public int EnterWorldCallCount { get; private set; }
public void EnterWorld( public void EnterWorld(
WorldSession session, WorldSession session,
int activeCharacterIndex) int activeCharacterIndex)
{ {
EnterWorldCallCount++; Interlocked.Increment(ref _enterWorldCallCount);
} }
public void Tick(WorldSession session) public void Tick(WorldSession session)
{ {
Interlocked.Increment(ref _tickCallCount);
} }
public void DisposeSession(WorldSession session) public void DisposeSession(WorldSession session)