merge: Campaign LA LA2 - probe and idle review-closed

# Conflicts:
#	docs/plans/2026-08-14-launcher-campaign.md
#	src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
This commit is contained in:
Erik 2026-08-14 17:30:07 +02:00
commit e01b2cd12f
16 changed files with 1361 additions and 69 deletions

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

@ -208,6 +208,339 @@ public sealed class HeadlessConfigurationLoaderTests
Assert.Empty(declared!);
}
// ── Campaign LA slice LA2: probe-mode `mode` field shape validation ──
[Fact]
public void ProbeSessionOmittingCharacterAndPolicyLoads()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "probe-session",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"mode": "probe",
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
}
]
}
""");
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(file.Path);
HeadlessSessionDescriptor session = Assert.Single(configuration.Sessions)!;
Assert.Null(session.Character);
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));
}
/// <summary>
/// Campaign LA LA2 review fix: the pinned contract is presence-aware.
/// Normal play omits mode and supplies character/policy; probe supplies
/// mode and omits character/policy. JSON null is not another spelling of
/// omission for any of those conditional fields.
/// </summary>
[Theory]
[InlineData(false, "mode")]
[InlineData(false, "character")]
[InlineData(false, "policy")]
[InlineData(true, "character")]
[InlineData(true, "policy")]
public void ConditionalSessionFieldsRejectExplicitJsonNull(
bool probe,
string nullField)
{
string mode = probe
? "\"mode\":\"probe\","
: nullField == "mode"
? "\"mode\":null,"
: string.Empty;
string character = nullField == "character"
? "\"character\":null,"
: probe
? string.Empty
: "\"character\":{\"index\":0},";
string policy = nullField == "policy"
? "\"policy\":null,"
: probe
? string.Empty
: "\"policy\":{\"id\":\"idle\"},";
using TemporaryConfiguration file = TemporaryConfiguration.Create(
$$"""
{
"version": 1,
"sessions": [
{
"id": "explicit-null",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
{{mode}}
{{character}}
{{policy}}
"credential": { "provider": "environment", "reference": "PASSWORD" }
}
]
}
""");
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
$"'{nullField}'",
exception.Message,
StringComparison.Ordinal);
Assert.Contains(
"cannot be null",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void PresenceAwareValidationKeepsUnmappedMemberRejection()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
ConfigurationWith(Session(
"bot",
"PASSWORD",
"\"notAContractField\":true")));
Assert.Throws<JsonException>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact]
public void PresenceAwareValidationKeepsTypedValueRejection()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "wrong-type",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"mode": { "value": "probe" },
"credential": { "provider": "environment", "reference": "PASSWORD" }
}
]
}
""");
Assert.Throws<JsonException>(
() => HeadlessConfigurationLoader.Load(file.Path));
}
[Fact]
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "probe-with-character",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"mode": "probe",
"character": { "index": 0 },
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
}
]
}
""");
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
Assert.Contains("character", exception.Message, StringComparison.Ordinal);
}
[Fact]
public void ProbeSessionDeclaringPolicyFailsLoadNamingTheField()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "probe-with-policy",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"mode": "probe",
"policy": { "id": "idle" },
"credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
}
]
}
""");
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains("probe", exception.Message, StringComparison.Ordinal);
Assert.Contains("policy", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// A play session (mode absent) missing `character` must still fail —
/// the LA2 change moved this requiredness from `[JsonRequired]` (a raw
/// <see cref="System.Text.Json.JsonException"/> at deserialize time) to
/// <see cref="HeadlessConfigurationLoader.ValidateSession"/>'s semantic
/// check (a <see cref="HeadlessConfigurationException"/> naming the
/// missing field). Exit-code parity (both map to
/// <c>HeadlessExitCode.ConfigurationError</c>) is proven at
/// <c>HeadlessEntryPointTests</c>; this test pins the loader-level
/// exception type/message.
/// </summary>
[Fact]
public void PlaySessionMissingCharacterStillFailsLoad()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "play-missing-character",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"policy": { "id": "idle" },
"credential": { "provider": "environment", "reference": "PLAY_PASSWORD" }
}
]
}
""");
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
"requires a character selector",
exception.Message,
StringComparison.Ordinal);
}
/// <summary>Same parity claim as
/// <see cref="PlaySessionMissingCharacterStillFailsLoad"/> for the
/// `policy` field.</summary>
[Fact]
public void PlaySessionMissingPolicyStillFailsLoad()
{
using TemporaryConfiguration file = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "play-missing-policy",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"character": { "index": 0 },
"credential": { "provider": "environment", "reference": "PLAY_PASSWORD" }
}
]
}
""");
HeadlessConfigurationException exception = Assert.Throws<
HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(file.Path));
Assert.Contains(
"requires a non-empty policy id",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void PlaySessionKeepsTodaysStrictCharacterSelectorAndPolicyValidation()
{
// Unrelated to `mode` — proves the LA2 refactor of ValidateSession
// did not loosen the existing selector-shape/policy-id checks for
// ordinary play sessions (mode absent).
using TemporaryConfiguration badSelector = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "bad-selector",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"character": { "index": 0, "name": "Two" },
"policy": { "id": "idle" },
"credential": { "provider": "environment", "reference": "A" }
}
]
}
""");
using TemporaryConfiguration blankPolicy = TemporaryConfiguration.Create(
"""
{
"version": 1,
"sessions": [
{
"id": "blank-policy",
"endpoint": { "host": "127.0.0.1", "port": 9000 },
"account": "account",
"character": { "index": 0 },
"policy": { "id": "" },
"credential": { "provider": "environment", "reference": "B" }
}
]
}
""");
Assert.Throws<HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(badSelector.Path));
Assert.Throws<HeadlessConfigurationException>(
() => HeadlessConfigurationLoader.Load(blankPolicy.Path));
}
private static string ConfigurationWith(params string[] sessions) =>
$$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}""";

View file

@ -191,6 +191,76 @@ public sealed class HeadlessEntryPointTests
Assert.Contains(expected, error.ToString());
}
/// <summary>
/// Campaign LA slice LA2: before this change, an omitted `character` or
/// `policy` field failed deserialization itself with a raw
/// <see cref="System.Text.Json.JsonException"/> ("missing required
/// properties") — this test pins that the LA2 move to semantic
/// validation (<see cref="AcDream.Headless.Configuration.HeadlessConfigurationException"/>
/// naming the exact missing field) preserves the SAME exit code
/// (3, <c>HeadlessExitCode.ConfigurationError</c>) end to end through
/// <see cref="HeadlessEntryPoint.Run(IReadOnlyList{string}, TextWriter, TextWriter)"/>.
/// The message text change (generic → field-naming) is a deliberate,
/// accepted improvement, not a contract break.
/// </summary>
[Theory]
[InlineData(
"""
{"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","policy":{"id":"idle"},"credential":{"provider":"environment","reference":"X"}}]}
""",
"character selector")]
[InlineData(
"""
{"version":1,"sessions":[{"id":"s","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","character":{"index":0},"credential":{"provider":"environment","reference":"X"}}]}
""",
"policy id")]
public void PlaySessionMissingCharacterOrPolicyKeepsConfigurationErrorExitCode(
string json,
string expectedMessageFragment)
{
using var file = TemporaryConfiguration.Create(json);
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["validate", "--config", file.Path],
output,
error);
Assert.Equal((int)HeadlessExitCode.ConfigurationError, exitCode);
Assert.Contains(
expectedMessageFragment,
error.ToString(),
StringComparison.OrdinalIgnoreCase);
Assert.Equal(string.Empty, output.ToString());
}
/// <summary>
/// Campaign LA slice LA2: a valid probe-mode session (mode "probe",
/// character/policy both omitted) passes `validate` — the launcher's
/// "refresh characters" flow only needs the process to accept the
/// document, not to run it.
/// </summary>
[Fact]
public void ValidateAcceptsProbeSessionOmittingCharacterAndPolicy()
{
using var file = TemporaryConfiguration.Create(
"""
{"version":1,"sessions":[{"id":"probe","endpoint":{"host":"127.0.0.1","port":9000},"account":"account","mode":"probe","credential":{"provider":"environment","reference":"X"}}]}
""");
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["validate", "--config", file.Path],
output,
error);
Assert.Equal((int)HeadlessExitCode.Success, exitCode);
Assert.Contains("1 session(s)", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public void UnknownCommandReturnsUsageError()
{

View file

@ -1,5 +1,6 @@
using System.Buffers.Binary;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Net;
using System.Numerics;
using System.Reflection;
@ -218,17 +219,217 @@ public sealed class HeadlessSessionHostTests
// writer is a permanent no-op with no configured path.
}
/// <summary>
/// Campaign LA slice LA2: a probe-mode session's status stream reports
/// started/connected/characterList and then converges straight to
/// exited(reason:"probe", code:0) — never enteredWorld — and the
/// underlying operations fake proves EnterWorld was literally never
/// called (not merely that no wire message happened to arrive).
/// </summary>
[Fact]
public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
public void ProbeSessionEmitsRosterThenExitsSuccessfullyWithoutEnteringWorld()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-probe-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(
ProbeDescriptor(statusFile: statusPath),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
RuntimeSessionStartResult started = host.Start();
Assert.Equal(RuntimeSessionStartStatus.ProbeComplete, started.Status);
Assert.Equal(0, operations.EnterWorldCallCount);
Assert.False(host.Runtime.Session.IsInWorld);
host.Dispose();
Assert.Equal(0, operations.EnterWorldCallCount);
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
string[] lines = File.ReadAllLines(statusPath);
string[] eventNames = lines
.Select(line => JsonDocument.Parse(line)
.RootElement.GetProperty("e").GetString()!)
.ToArray();
Assert.DoesNotContain("enteredWorld", eventNames);
Assert.Contains("characterList", eventNames);
Assert.Contains("exited", eventNames);
Assert.True(
Array.IndexOf(eventNames, "characterList")
< Array.IndexOf(eventNames, "exited"),
"characterList must land before the terminal exited event.");
using JsonDocument exitedDoc = JsonDocument.Parse(
lines[Array.IndexOf(eventNames, "exited")]);
Assert.Equal(0, exitedDoc.RootElement.GetProperty("code").GetInt32());
Assert.Equal(
"probe",
exitedDoc.RootElement.GetProperty("reason").GetString());
string contents = File.ReadAllText(statusPath);
Assert.DoesNotContain("password", contents, StringComparison.Ordinal);
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
}
/// <summary>
/// Campaign LA slice LA2: <see cref="HeadlessProcessHost.RunOnUpdateThread"/>
/// maps a ProbeComplete start to <see cref="HeadlessExitCode.Success"/>
/// (0) rather than <see cref="HeadlessExitCode.ConnectionError"/> — a
/// single-session probe-only process must exit cleanly and promptly
/// without ever needing SIGINT/cancellation, because
/// ProbeHeadlessBotPolicy reports IsComplete immediately.
/// </summary>
[Fact]
public async Task ProcessHostMapsProbeCompleteStartToSuccessExitCode()
{
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions =
[
ProbeDescriptor(
provider: HeadlessCredentialProviderKind.StandardInput,
credentialReference: "probe-password"),
],
};
HeadlessPathSet paths = HeadlessPathSet.Resolve(
new HeadlessPathOverrides());
using var diagnostics = new StringWriter();
var operations = new FixtureSessionOperations();
using var host = new HeadlessProcessHost(
configuration,
paths,
new System.IO.StringReader("probe-password" + Environment.NewLine),
diagnostics,
operations);
// Deliberately NOT cancelled — a probe-only process must return on
// its own; a hang here would mean the scheduler never recognized
// the probe session as already complete.
using var cancellation = new CancellationTokenSource(
TimeSpan.FromSeconds(10));
HeadlessExitCode result = await host.RunAsync(cancellation.Token);
Assert.Equal(HeadlessExitCode.Success, result);
Assert.Equal(0, operations.EnterWorldCallCount);
Assert.False(cancellation.IsCancellationRequested);
}
/// <summary>
/// Campaign LA LA2 review fix: configured probe intent is not proof of a
/// completed probe. If the connected session produces no CharacterList,
/// Runtime returns NoCharacters, the process returns ConnectionError, and
/// the sole terminal status event reports that same non-success instead of
/// the former false code-0/reason-probe pair.
/// </summary>
[Fact]
public async Task ProbeWithoutRosterReportsTheProcessConnectionErrorExactlyOnce()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-probe-no-roster-{Guid.NewGuid():N}.jsonl");
try
{
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions =
[
ProbeDescriptor(
provider: HeadlessCredentialProviderKind.StandardInput,
credentialReference: "probe-password",
statusFile: statusPath),
],
};
var operations = new FixtureSessionOperations
{
Characters = null,
};
using var diagnostics = new StringWriter();
using var host = new HeadlessProcessHost(
configuration,
HeadlessPathSet.Resolve(new HeadlessPathOverrides()),
new System.IO.StringReader(
"probe-password" + Environment.NewLine),
diagnostics,
operations);
HeadlessExitCode result = await host.RunAsync(
CancellationToken.None);
Assert.Equal(HeadlessExitCode.ConnectionError, result);
Assert.Equal(0, operations.EnterWorldCallCount);
Assert.Equal(1, operations.DisposedSessionCount);
host.Dispose();
host.Dispose();
string[] lines = File.ReadAllLines(statusPath);
JsonElement[] events = lines
.Select(static line =>
JsonDocument.Parse(line).RootElement.Clone())
.ToArray();
Assert.Equal(
["started", "connected", "disconnected", "exited"],
events.Select(static item =>
item.GetProperty("e").GetString()));
Assert.DoesNotContain(
events,
static item =>
item.GetProperty("e").GetString() == "characterList");
JsonElement exited = Assert.Single(
events,
static item => item.GetProperty("e").GetString() == "exited");
Assert.Equal(
(int)result,
exited.GetProperty("code").GetInt32());
Assert.Equal(
"connection-error",
exited.GetProperty("reason").GetString());
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
}
/// <summary>
/// Campaign LA slice LA2: a probe session completing must not tear down
/// a sibling play session sharing the same process — the process exit
/// code is 0 only once every configured session has succeeded (the
/// probe counts as success the instant it completes; the play session
/// keeps running until cancellation).
/// </summary>
[Fact]
public async Task ProbeSessionSharingAProcessDoesNotTearDownASiblingPlaySession()
{
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions =
[
ProbeDescriptor(
"probe-sibling",
provider: HeadlessCredentialProviderKind.StandardInput,
credentialReference: "probe-password"),
Descriptor(
HeadlessCredentialProviderKind.StandardInput,
"stdin-bot"),
"play-password"),
],
};
HeadlessPathSet paths = HeadlessPathSet.Resolve(
@ -239,20 +440,144 @@ public sealed class HeadlessSessionHostTests
configuration,
paths,
new System.IO.StringReader(
"process-password" + Environment.NewLine),
"probe-password" + Environment.NewLine
+ "play-password" + Environment.NewLine),
diagnostics,
operations);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
HeadlessExitCode result =
await host.RunAsync(cancellation.Token);
HeadlessExitCode result = await host.RunAsync(cancellation.Token);
Assert.Equal(HeadlessExitCode.Success, result);
Assert.True(host.Session.Runtime.Session.IsInWorld);
Assert.DoesNotContain(
"process-password",
diagnostics.ToString());
Assert.Equal(2, host.Sessions.Count);
HeadlessSessionHost probeSession = Assert.Single(
host.Sessions,
s => s.SessionId == "probe-sibling");
HeadlessSessionHost playSession = Assert.Single(
host.Sessions,
s => s.SessionId == "bot");
Assert.False(probeSession.Runtime.Session.IsInWorld);
Assert.True(playSession.Runtime.Session.IsInWorld);
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]
public async Task IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl");
try
{
var configuration = new HeadlessConfiguration
{
Version = 1,
Sessions =
[
Descriptor(
HeadlessCredentialProviderKind.StandardInput,
"stdin-bot",
statusFile: statusPath),
],
};
HeadlessPathSet paths = HeadlessPathSet.Resolve(
new HeadlessPathOverrides());
using var diagnostics = new StringWriter();
var operations = new FixtureSessionOperations();
using var host = new HeadlessProcessHost(
configuration,
paths,
new System.IO.StringReader(
"process-password" + Environment.NewLine),
diagnostics,
operations);
using var cancellation = new CancellationTokenSource();
Task<HeadlessExitCode> run = host.RunAsync(cancellation.Token);
var timeout = Stopwatch.StartNew();
while (operations.TickCallCount < 3
&& !run.IsCompleted
&& timeout.Elapsed < TimeSpan.FromSeconds(10))
{
await Task.Delay(5);
}
Assert.True(
operations.TickCallCount >= 3,
$"Expected at least 3 idle scheduler turns, observed {operations.TickCallCount}.");
Assert.False(run.IsCompleted);
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]
@ -2153,6 +2478,41 @@ public sealed class HeadlessSessionHostTests
StatusFile = statusFile,
};
/// <summary>Campaign LA slice LA2: a probe-mode descriptor — mode
/// "probe", <c>Character</c>/<c>Policy</c> both omitted per the pinned
/// contract shape <see cref="HeadlessConfigurationLoader"/> enforces.</summary>
private static HeadlessSessionDescriptor ProbeDescriptor(
string id = "probe-bot",
HeadlessCredentialProviderKind provider =
HeadlessCredentialProviderKind.Environment,
string credentialReference = "PROBE_PASSWORD",
string? statusFile = null) => new()
{
Id = id,
Endpoint = new HeadlessEndpointDescriptor
{
Host = "127.0.0.1",
Port = 9000,
},
Account = "account",
Mode = HeadlessSessionMode.Probe,
Credential = new HeadlessCredentialReference
{
Provider = provider,
Reference = credentialReference,
},
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)
{
const uint player = 0x50000002u;
@ -2794,11 +3154,34 @@ public sealed class HeadlessSessionHostTests
private sealed class FixtureSessionOperations : ILiveSessionOperations
{
private int _enterWorldCallCount;
private int _tickCallCount;
public List<WorldSession> Sessions { get; } = [];
public int CreatedSessionCount { get; private set; }
public int DisposedSessionCount { get; private set; }
public string? LastUser { get; private set; }
public string? LastPassword { get; private set; }
public int EnterWorldCallCount =>
Volatile.Read(ref _enterWorldCallCount);
public int TickCallCount => Volatile.Read(ref _tickCallCount);
public CharacterList.Parsed? Characters { get; init; } = new(
0u,
[
new CharacterList.Character(
0x50000001u,
"Other",
0u),
new CharacterList.Character(
0x50000002u,
"Headless",
0u),
],
[],
11,
"account",
true,
true);
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
@ -2820,34 +3203,19 @@ public sealed class HeadlessSessionHostTests
LastPassword = password;
}
public CharacterList.Parsed GetCharacters(
WorldSession session) =>
new(
0u,
[
new CharacterList.Character(
0x50000001u,
"Other",
0u),
new CharacterList.Character(
0x50000002u,
"Headless",
0u),
],
[],
11,
"account",
true,
true);
public CharacterList.Parsed? GetCharacters(
WorldSession session) => Characters;
public void EnterWorld(
WorldSession session,
int activeCharacterIndex)
{
Interlocked.Increment(ref _enterWorldCallCount);
}
public void Tick(WorldSession session)
{
Interlocked.Increment(ref _tickCallCount);
}
public void DisposeSession(WorldSession session)

View file

@ -32,8 +32,12 @@ public sealed class SessionConfigurationSharedFixtureTests
Assert.Equal("composer.example", session.Endpoint.Host);
Assert.Equal(9010, session.Endpoint.Port);
Assert.Equal("composer-account", session.Account);
Assert.Equal(0x50000001u, session.Character.Id);
Assert.Equal("idle", session.Policy.Id);
HeadlessCharacterSelector character =
Assert.IsType<HeadlessCharacterSelector>(session.Character);
HeadlessBotPolicyDescriptor policy =
Assert.IsType<HeadlessBotPolicyDescriptor>(session.Policy);
Assert.Equal(0x50000001u, character.Id);
Assert.Equal("idle", policy.Id);
Assert.Equal(
HeadlessCredentialProviderKind.StandardInput,
session.Credential.Provider);
@ -73,8 +77,8 @@ public sealed class SessionConfigurationSharedFixtureTests
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("SharedToon", session.Character!.Name);
Assert.Equal("idle", session.Policy!.Id);
Assert.Equal(
HeadlessCredentialProviderKind.StandardInput,
session.Credential.Provider);