feat(runtime): share chat commands and run login sequence

This commit is contained in:
Erik 2026-08-14 20:27:45 +02:00
parent 5535d0adac
commit 41b15efd4d
57 changed files with 1739 additions and 345 deletions

View file

@ -31,7 +31,11 @@ public sealed class HeadlessPluginSessionTests
string statusPath = Path.Combine(temporary.Path, "status.jsonl");
var credential = new HeadlessCredentialSecret("fixture", "password");
using var session = new HeadlessSessionHost(
Descriptor([FixtureId.ToUpperInvariant(), BrokenId], statusPath),
Descriptor(
[FixtureId.ToUpperInvariant(), BrokenId],
statusPath,
loginCommands: ["/"],
loginCommandDelayMs: 0),
credential,
diagnostics,
new FixtureSessionOperations(),
@ -57,7 +61,7 @@ public sealed class HeadlessPluginSessionTests
Assert.Equal(
[
"started", "pluginLoaded", "pluginFailed", "connected",
"characterList", "enteredWorld",
"characterList", "enteredWorld", "loginCommandFailed",
],
EventNames(statuses));
Assert.Equal(FixtureId, statuses[1].GetProperty("plugin").GetString());
@ -66,6 +70,8 @@ public sealed class HeadlessPluginSessionTests
"entry dll not found",
statuses[2].GetProperty("error").GetString()!,
StringComparison.OrdinalIgnoreCase);
Assert.Equal(0, statuses[6].GetProperty("commandIndex").GetInt32());
Assert.Equal("/", statuses[6].GetProperty("command").GetString());
Assert.Contains("fixture-enabled:hasUi=False:entities=0", output.ToString());
WeakReference context = Assert.Single(
@ -231,7 +237,9 @@ public sealed class HeadlessPluginSessionTests
private static HeadlessSessionDescriptor Descriptor(
List<string> plugins,
string statusPath) => new()
string statusPath,
IReadOnlyList<string>? loginCommands = null,
int loginCommandDelayMs = 500) => new()
{
Id = "headless-session",
Endpoint = new HeadlessEndpointDescriptor
@ -255,6 +263,8 @@ public sealed class HeadlessPluginSessionTests
},
Plugins = plugins,
StatusFile = statusPath,
LoginCommands = loginCommands is null ? null : [.. loginCommands],
LoginCommandDelayMs = loginCommandDelayMs,
};
private static WorldSession.EntitySpawn Spawn(uint guid, float x) => new(

View file

@ -24,6 +24,165 @@ namespace AcDream.Headless.Tests;
public sealed class HeadlessSessionHostTests
{
[Fact]
public void LoginCommandsUseTheHeadlessLiveBusAndPreserveWireOrder()
{
var captured = new List<byte[]>();
var operations = new FixtureSessionOperations
{
GameActionCapture = body => captured.Add(body),
};
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(
loginCommands:
[
"hello",
"/tell Bob, secret",
"/f group",
"@admin raw",
"/vt start",
],
loginCommandDelayMs: 0),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
RuntimeSessionStartResult result = host.Start();
Assert.Equal(RuntimeSessionStartStatus.Connected, result.Status);
Assert.Equal(
[
ChatRequests.TalkOpcode,
ChatRequests.TellOpcode,
ChatRequests.ChatChannelOpcode,
ChatRequests.ChatChannelOpcode,
ChatRequests.TalkOpcode,
],
captured.Select(ActionOpcode));
Assert.Equal(
0x00000800u,
BinaryPrimitives.ReadUInt32LittleEndian(captured[2].AsSpan(12)));
Assert.Equal(
0x00000002u,
BinaryPrimitives.ReadUInt32LittleEndian(captured[3].AsSpan(12)));
}
[Fact]
public void LoginCommandFailuresAreVersionedOrderedAndSessionIsolated()
{
string statusPath = Path.Combine(
Path.GetTempPath(),
$"acdream-headless-login-commands-{Guid.NewGuid():N}.jsonl");
try
{
var captured = new List<byte[]>();
var operations = new FixtureSessionOperations
{
GameActionCapture = body => captured.Add(body),
};
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(
statusFile: statusPath,
loginCommands: ["/", "/version", "after"],
loginCommandDelayMs: 0),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations);
RuntimeSessionStartResult result = host.Start();
Assert.Equal(RuntimeSessionStartStatus.Connected, result.Status);
Assert.True(host.Runtime.Session.IsInWorld);
Assert.Single(captured);
Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(captured[0]));
JsonElement[] events = File.ReadAllLines(statusPath)
.Select(static line =>
JsonDocument.Parse(line).RootElement.Clone())
.ToArray();
Assert.Equal(
[
"started", "connected", "characterList", "enteredWorld",
"loginCommandFailed", "loginCommandFailed",
],
events.Select(static item =>
item.GetProperty("e").GetString()));
JsonElement[] failures = events
.Where(static item =>
item.GetProperty("e").GetString()
== "loginCommandFailed")
.ToArray();
Assert.Equal(1, failures[0].GetProperty("v").GetInt32());
Assert.Equal(0, failures[0].GetProperty("commandIndex").GetInt32());
Assert.Equal("/", failures[0].GetProperty("command").GetString());
Assert.Equal(
"Chat command routing returned UnknownCommand.",
failures[0].GetProperty("error").GetString());
Assert.Equal(1, failures[1].GetProperty("commandIndex").GetInt32());
Assert.Equal(
"/version",
failures[1].GetProperty("command").GetString());
Assert.Contains(
"not available in the headless host",
failures[1].GetProperty("error").GetString());
}
finally
{
if (File.Exists(statusPath))
File.Delete(statusPath);
}
}
[Fact]
public void LoginCommandDelayIsGenerationScopedAcrossReconnect()
{
var time = new ManualTimeProvider();
var captured = new List<byte[]>();
var operations = new FixtureSessionOperations
{
GameActionCapture = body => captured.Add(body),
};
using var diagnosticsOutput = new StringWriter();
using var credential = new HeadlessCredentialSecret(
"fixture",
"password");
using var host = new HeadlessSessionHost(
Descriptor(
loginCommands: ["first", "second"],
loginCommandDelayMs: 500),
credential,
new HeadlessDiagnosticWriter(diagnosticsOutput),
operations,
timeProvider: time);
Assert.Equal(RuntimeSessionStartStatus.Connected, host.Start().Status);
Assert.Equal(["first"], captured.Select(TalkText));
host.Tick(0.1d);
Assert.Equal(["first"], captured.Select(TalkText));
// Replacement cancels the retiring generation's pending "second"
// and starts the configured list once for the new entered-world edge.
Assert.Equal(RuntimeSessionStartStatus.Connected, host.Reconnect().Status);
Assert.Equal(["first", "first"], captured.Select(TalkText));
time.Advance(TimeSpan.FromMilliseconds(499));
host.Tick(0.1d);
Assert.Equal(["first", "first"], captured.Select(TalkText));
time.Advance(TimeSpan.FromMilliseconds(1));
host.Tick(0.1d);
Assert.Equal(["first", "first", "second"], captured.Select(TalkText));
}
[Fact]
public void SingleSessionStartsReconnectsAndConvergesWithoutPresentation()
{
@ -2452,7 +2611,9 @@ public sealed class HeadlessSessionHostTests
HeadlessCredentialProviderKind.Environment,
string credentialReference = "BOT_PASSWORD",
Dictionary<string, bool>? characterOptions = null,
string? statusFile = null) => new()
string? statusFile = null,
IReadOnlyList<string>? loginCommands = null,
int loginCommandDelayMs = 500) => new()
{
Id = "bot",
Endpoint = new HeadlessEndpointDescriptor
@ -2476,6 +2637,8 @@ public sealed class HeadlessSessionHostTests
},
CharacterOptions = characterOptions,
StatusFile = statusFile,
LoginCommands = loginCommands is null ? null : [.. loginCommands],
LoginCommandDelayMs = loginCommandDelayMs,
};
/// <summary>Campaign LA slice LA2: a probe-mode descriptor — mode
@ -3115,6 +3278,26 @@ public sealed class HeadlessSessionHostTests
BinaryPrimitives.ReadUInt32LittleEndian(
body.AsSpan(8, sizeof(uint)));
private static string TalkText(byte[] body)
{
Assert.Equal(ChatRequests.TalkOpcode, ActionOpcode(body));
ushort length = BinaryPrimitives.ReadUInt16LittleEndian(
body.AsSpan(12, sizeof(ushort)));
return System.Text.Encoding.ASCII.GetString(body, 14, length);
}
private sealed class ManualTimeProvider : TimeProvider
{
private long _timestamp;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override long GetTimestamp() => _timestamp;
internal void Advance(TimeSpan duration) =>
_timestamp = checked(_timestamp + duration.Ticks);
}
// SF-4 fixture: minimal PlayerDescription (0x0013) body carrying only
// the CharacterOptions1/2 trailer fields — copied from
// HeadlessCharacterOptionsSeederWiringTests.WrapPlayerDescriptionEnvelope
@ -3162,6 +3345,7 @@ public sealed class HeadlessSessionHostTests
public int DisposedSessionCount { get; private set; }
public string? LastUser { get; private set; }
public string? LastPassword { get; private set; }
public Action<byte[]>? GameActionCapture { get; init; }
public int EnterWorldCallCount =>
Volatile.Read(ref _enterWorldCallCount);
public int TickCallCount => Volatile.Read(ref _tickCallCount);
@ -3190,6 +3374,7 @@ public sealed class HeadlessSessionHostTests
{
CreatedSessionCount++;
var session = new WorldSession(endpoint);
session.GameActionCapture = GameActionCapture;
Sessions.Add(session);
return session;
}