feat(runtime): share chat commands and run login sequence
This commit is contained in:
parent
5535d0adac
commit
41b15efd4d
57 changed files with 1739 additions and 345 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue