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

@ -0,0 +1,60 @@
using System.Reflection;
using AcDream.Runtime.Chat;
namespace AcDream.Runtime.Tests.Chat;
public sealed class ChatExtractionTests
{
[Fact]
public void CompleteChatCoreLivesInRuntimeAssembly()
{
Assembly runtime = typeof(GameRuntime).Assembly;
Type[] extracted =
[
typeof(ChatInputParser),
typeof(ChatCommandRouter),
typeof(RetailClientCommandCatalog),
typeof(RetailCommandHelpTable),
typeof(RetailChannelTagTable),
typeof(ChannelResolver),
typeof(ICommandBus),
typeof(LiveCommandBus),
typeof(NullCommandBus),
typeof(ChatChannelKind),
typeof(ClientCommandId),
typeof(SendChatCmd),
typeof(SendServerCommandCmd),
typeof(SendRawChannelCmd),
typeof(ExecuteClientCommandCmd),
];
Assert.All(extracted, type => Assert.Same(runtime, type.Assembly));
Assert.DoesNotContain(
runtime.GetReferencedAssemblies(),
reference => reference.Name is "AcDream.App"
or "AcDream.UI.Abstractions");
}
[Fact]
public void RouterDependsOnlyOnTheFourMemberFeedbackContract()
{
string[] members = typeof(IChatCommandFeedback)
.GetMembers(BindingFlags.Instance | BindingFlags.Public)
.Where(static member => member.MemberType is
MemberTypes.Method or MemberTypes.Property)
.Where(static member => member is not MethodInfo method
|| !method.IsSpecialName)
.Select(static member => member.Name)
.Order(StringComparer.Ordinal)
.ToArray();
Assert.Equal(
[
"LastIncomingTellSender",
"LastOutgoingTellTarget",
"ShowInterfaceText",
"ShowSystemMessage",
],
members);
}
}

View file

@ -0,0 +1,59 @@
using AcDream.Core.Chat;
using AcDream.Runtime.Chat;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Chat;
public sealed class LiveChatCommandRouteTests
{
[Fact]
public void FourRegistrationsPreserveOrderedWireRoutesAndCanonicalEcho()
{
using var communication = new RuntimeCommunicationState();
using var character = new RuntimeCharacterState();
var sent = new List<string>();
var route = new LiveChatCommandRoute(new LiveChatCommandBindings(
command => sent.Add($"client:{command.Command}"),
communication,
communication.Chat,
communication.TurbineChat,
character,
() => 0x50000001u,
text => sent.Add($"talk:{text}"),
(target, text) => sent.Add($"tell:{target}:{text}"),
(channel, text) => sent.Add($"channel:{channel:X8}:{text}"),
(_, _, _, _, text, _) => sent.Add($"turbine:{text}")));
route.Activate();
route.Publish(new SendServerCommandCmd("@server"));
route.Publish(new SendChatCmd(ChatChannelKind.Say, null, "say"));
route.Publish(new SendChatCmd(ChatChannelKind.Tell, "Bob", "secret"));
route.Publish(new SendChatCmd(
ChatChannelKind.Fellowship,
null,
"group"));
route.Publish(new SendRawChannelCmd(0x00000002u, "admin"));
route.Publish(new ExecuteClientCommandCmd(
ClientCommandId.QueryAge,
string.Empty));
Assert.Equal(
[
"talk:@server",
"talk:say",
"tell:Bob:secret",
"channel:00000800:group",
"channel:00000002:admin",
"client:QueryAge",
],
sent);
ChatEntry echo = Assert.Single(communication.Chat.Snapshot());
Assert.Equal(ChatKind.Tell, echo.Kind);
Assert.Equal("Bob", echo.Sender);
Assert.Equal("secret", echo.Text);
route.Dispose();
route.Publish(new SendServerCommandCmd("@stale"));
Assert.Equal(6, sent.Count);
}
}

View file

@ -0,0 +1,184 @@
using AcDream.Runtime.Chat;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Chat;
public sealed class LoginCommandSequenceTests
{
[Fact]
public void EnteredWorldExecutesImmediatelyThenHonorsMonotonicDelay()
{
var time = new ManualTimeProvider();
var sent = new List<string>();
var bus = TalkBus(sent);
var sequence = new LoginCommandSequence(
["one", "two", "three"],
TimeSpan.FromMilliseconds(500),
new RecordingFeedback(),
bus,
timeProvider: time);
var generation = new RuntimeGenerationToken(7);
sequence.EnteredWorld(generation);
Assert.Equal(["one"], sent);
time.Advance(TimeSpan.FromMilliseconds(499));
sequence.Tick(generation, isInWorld: true);
Assert.Equal(["one"], sent);
time.Advance(TimeSpan.FromMilliseconds(1));
sequence.Tick(generation, isInWorld: true);
Assert.Equal(["one", "two"], sent);
time.Advance(TimeSpan.FromMilliseconds(500));
sequence.Tick(generation, isInWorld: true);
Assert.Equal(["one", "two", "three"], sent);
Assert.False(sequence.IsActive);
}
[Fact]
public void HandlerRuntimeDoesNotConsumeTheInterCommandDelay()
{
var time = new ManualTimeProvider();
var sent = new List<string>();
var bus = new LiveCommandBus();
bus.Register<SendChatCmd>(command =>
{
sent.Add(command.Text);
time.Advance(TimeSpan.FromSeconds(1));
});
var sequence = new LoginCommandSequence(
["one", "two"],
TimeSpan.FromMilliseconds(500),
new RecordingFeedback(),
bus,
timeProvider: time);
var generation = new RuntimeGenerationToken(7);
sequence.EnteredWorld(generation);
Assert.Equal(["one"], sent);
time.Advance(TimeSpan.FromMilliseconds(499));
sequence.Tick(generation, isInWorld: true);
Assert.Equal(["one"], sent);
time.Advance(TimeSpan.FromMilliseconds(1));
sequence.Tick(generation, isInWorld: true);
Assert.Equal(["one", "two"], sent);
}
[Fact]
public void ParseAndHandlerFailuresAndStatusFailureAllContinue()
{
var sent = new List<string>();
var bus = TalkBus(sent);
bus.Register<ExecuteClientCommandCmd>(_ =>
throw new InvalidOperationException("client boom"));
int reports = 0;
var sequence = new LoginCommandSequence(
["/", "/version", "after"],
TimeSpan.Zero,
new RecordingFeedback(),
bus,
_ =>
{
reports++;
throw new IOException("status unavailable");
});
sequence.EnteredWorld(new RuntimeGenerationToken(1));
Assert.Equal(2, reports);
Assert.Equal(["after"], sent);
Assert.False(sequence.IsActive);
}
[Fact]
public void CancelAndGenerationReplacementPreventLeaksAndReconnectRestarts()
{
var time = new ManualTimeProvider();
var sent = new List<string>();
var sequence = new LoginCommandSequence(
["one", "two"],
TimeSpan.FromMilliseconds(500),
new RecordingFeedback(),
TalkBus(sent),
timeProvider: time);
var first = new RuntimeGenerationToken(1);
var second = new RuntimeGenerationToken(2);
sequence.EnteredWorld(first);
sequence.Cancel(first);
time.Advance(TimeSpan.FromSeconds(1));
sequence.Tick(first, isInWorld: true);
sequence.EnteredWorld(first); // exact-once per generation
Assert.Equal(["one"], sent);
sequence.EnteredWorld(second);
sequence.Tick(first, isInWorld: true); // stale frame is inert
time.Advance(TimeSpan.FromMilliseconds(500));
sequence.Tick(second, isInWorld: true);
Assert.Equal(["one", "one", "two"], sent);
}
[Fact]
public void NullAndEmptyCollectionsAreNoOpsAndNullEntriesTypeAsEmpty()
{
var feedback = new RecordingFeedback();
var bus = TalkBus([]);
var absent = new LoginCommandSequence(
null,
TimeSpan.Zero,
feedback,
bus);
var empty = new LoginCommandSequence(
[],
TimeSpan.Zero,
feedback,
bus);
var nullEntry = new LoginCommandSequence(
[null],
TimeSpan.Zero,
feedback,
bus);
absent.EnteredWorld(new RuntimeGenerationToken(1));
empty.EnteredWorld(new RuntimeGenerationToken(1));
nullEntry.EnteredWorld(new RuntimeGenerationToken(1));
Assert.False(absent.IsActive);
Assert.False(empty.IsActive);
Assert.False(nullEntry.IsActive);
}
private static LiveCommandBus TalkBus(List<string> sent)
{
var bus = new LiveCommandBus();
bus.Register<SendChatCmd>(command => sent.Add(command.Text));
bus.Register<SendServerCommandCmd>(command => sent.Add(command.Text));
bus.Register<SendRawChannelCmd>(_ => { });
return bus;
}
private sealed class RecordingFeedback : IChatCommandFeedback
{
public string? LastIncomingTellSender { get; set; }
public string? LastOutgoingTellTarget { get; set; }
public List<string> Interface { get; } = [];
public List<string> System { get; } = [];
public void ShowInterfaceText(string text) => Interface.Add(text);
public void ShowSystemMessage(string text) => System.Add(text);
}
private sealed class ManualTimeProvider : TimeProvider
{
private long _timestamp;
public override long TimestampFrequency => 1_000;
public override long GetTimestamp() => _timestamp;
public void Advance(TimeSpan duration) =>
_timestamp += checked((long)duration.TotalMilliseconds);
}
}

View file

@ -1,6 +1,7 @@
using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime.Chat;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
@ -74,6 +75,37 @@ public sealed class LiveSessionHostTests
Assert.False(host.IsInWorld);
}
[Fact]
public void LoginSequenceStartsAfterTheEnteredWorldObservation()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var bus = new LiveCommandBus();
bus.Register<SendChatCmd>(command => calls.Add($"login:{command.Text}"));
var loginCommands = new LoginCommandSequence(
["ready"],
TimeSpan.Zero,
new TestFeedback(),
bus);
LiveSessionHost host = CreateHost(
controller,
calls,
_ => new TestEventRouting(calls),
_ => new TestCommandRouting(calls),
loginCommands);
Assert.Equal(
LiveSessionStartStatus.Connected,
host.Start(LiveOptions()).Status);
int entered = calls.IndexOf("character-entered:1342177282");
int login = calls.IndexOf("login:ready");
Assert.True(entered >= 0);
Assert.Equal(entered + 1, login);
controller.Dispose();
}
[Fact]
public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute()
{
@ -219,7 +251,8 @@ public sealed class LiveSessionHostTests
LiveSessionController controller,
List<string> calls,
Func<WorldSession, ILiveSessionEventRouting> createEvents,
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
Func<WorldSession, ILiveSessionCommandRouting> createCommands,
LoginCommandSequence? loginCommands = null) =>
new(controller, new LiveSessionHostBindings(
Routing: new(createEvents, createCommands),
Reset: _ => calls.Add("reset"),
@ -240,7 +273,8 @@ public sealed class LiveSessionHostTests
Connected: () => calls.Add("connected"),
Roster: roster => calls.Add($"roster:{roster.AccountName}"),
CharacterEntered: selection =>
calls.Add($"character-entered:{selection.CharacterId}")));
calls.Add($"character-entered:{selection.CharacterId}"),
LoginCommands: loginCommands));
private static LiveSessionConnectOptions LiveOptions(
bool live = true,
@ -291,6 +325,14 @@ public sealed class LiveSessionHostTests
public void Dispose() => calls.Add("dispose-commands");
}
private sealed class TestFeedback : IChatCommandFeedback
{
public string? LastIncomingTellSender => null;
public string? LastOutgoingTellTarget => null;
public void ShowInterfaceText(string text) { }
public void ShowSystemMessage(string text) { }
}
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
{
public List<WorldSession> Sessions { get; } = [];

View file

@ -32,11 +32,12 @@ public sealed class SessionStatusWriterTests
writer.EnteredWorld("s1", 0x50000001u, "Ready");
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "enable failed");
writer.LoginCommandFailed("s1", 2, "/version", "unsupported headless command");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(8, lines.Length);
Assert.Equal(9, lines.Length);
JsonElement started = Parse(lines[0]);
Assert.Equal(1, started.GetProperty("v").GetInt32());
@ -73,11 +74,25 @@ public sealed class SessionStatusWriterTests
Assert.Equal("acdream.bad", pluginFailed.GetProperty("plugin").GetString());
Assert.Equal("enable failed", pluginFailed.GetProperty("error").GetString());
JsonElement disconnected = Parse(lines[6]);
JsonElement loginCommandFailed = Parse(lines[6]);
Assert.Equal(1, loginCommandFailed.GetProperty("v").GetInt32());
Assert.Equal("loginCommandFailed", loginCommandFailed.GetProperty("e").GetString());
Assert.Equal("s1", loginCommandFailed.GetProperty("sessionId").GetString());
Assert.Equal(2, loginCommandFailed.GetProperty("commandIndex").GetInt32());
Assert.Equal("/version", loginCommandFailed.GetProperty("command").GetString());
Assert.Equal(
"unsupported headless command",
loginCommandFailed.GetProperty("error").GetString());
Assert.Equal(
["v", "e", "t", "sessionId", "commandIndex", "command", "error"],
loginCommandFailed.EnumerateObject()
.Select(static property => property.Name));
JsonElement disconnected = Parse(lines[7]);
Assert.Equal("disconnected", disconnected.GetProperty("e").GetString());
Assert.Equal("stopped", disconnected.GetProperty("reason").GetString());
JsonElement exited = Parse(lines[7]);
JsonElement exited = Parse(lines[8]);
Assert.Equal("exited", exited.GetProperty("e").GetString());
Assert.Equal(0, exited.GetProperty("code").GetInt32());
Assert.Equal("disposed", exited.GetProperty("reason").GetString());
@ -93,6 +108,7 @@ public sealed class SessionStatusWriterTests
writer.Connected("s1");
writer.PluginLoaded("s1", "acdream.good");
writer.PluginFailed("s1", "acdream.bad", "failed");
writer.LoginCommandFailed("s1", 0, "", "unknown command");
writer.Disconnected("s1", "stopped");
writer.Exited("s1", 0, "disposed");
@ -199,11 +215,12 @@ public sealed class SessionStatusWriterTests
writer.EnteredWorld("bot", 0x50000001u, "Ready");
writer.PluginLoaded("bot", "acdream.good");
writer.PluginFailed("bot", "acdream.bad", "enable failed");
writer.LoginCommandFailed("bot", 1, "/version", "unsupported");
writer.Disconnected("bot", "stopped");
writer.Exited("bot", 0, "disposed");
string[] lines = File.ReadAllLines(file.Path);
Assert.Equal(8, lines.Length);
Assert.Equal(9, lines.Length);
AssertExactProperties(lines[0], "v", "e", "t", "sessionId");
AssertExactProperties(lines[1], "v", "e", "t", "sessionId");
@ -215,8 +232,11 @@ public sealed class SessionStatusWriterTests
AssertExactProperties(lines[4], "v", "e", "t", "sessionId", "plugin");
AssertExactProperties(
lines[5], "v", "e", "t", "sessionId", "plugin", "error");
AssertExactProperties(lines[6], "v", "e", "t", "sessionId", "reason");
AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "code", "reason");
AssertExactProperties(
lines[6],
"v", "e", "t", "sessionId", "commandIndex", "command", "error");
AssertExactProperties(lines[7], "v", "e", "t", "sessionId", "reason");
AssertExactProperties(lines[8], "v", "e", "t", "sessionId", "code", "reason");
// The nested characters[] entries are exact too — the exact shape a
// password could otherwise be smuggled through.