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
|
|
@ -1,4 +1,5 @@
|
|||
global using AcDream.Runtime.Gameplay;
|
||||
global using AcDream.Runtime.Physics;
|
||||
global using AcDream.Runtime.Chat;
|
||||
global using ILocalPlayerMotionSource =
|
||||
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
|
||||
|
|
|
|||
|
|
@ -6,12 +6,54 @@ using AcDream.Core.Items;
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Social;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.UI.Abstractions;
|
||||
|
||||
namespace AcDream.App.Tests.Net;
|
||||
|
||||
public sealed class LiveSessionCommandRouterTests
|
||||
{
|
||||
[Fact]
|
||||
public void LoginCommandSequenceUsesTheIdenticalGraphicalCommandSurface()
|
||||
{
|
||||
using var communication = new RuntimeCommunicationState();
|
||||
var calls = new List<string>();
|
||||
ClientCommandController.Bindings client = NewClientBindings() with
|
||||
{
|
||||
QueryAge = () => calls.Add("client:age"),
|
||||
};
|
||||
var router = NewRouter(
|
||||
chat: communication.Chat,
|
||||
turbine: communication.TurbineChat,
|
||||
communication: communication,
|
||||
clientBindings: client,
|
||||
sendTalk: text => calls.Add($"talk:{text}"),
|
||||
sendTell: (target, text) =>
|
||||
calls.Add($"tell:{target}:{text}"),
|
||||
sendChannel: (channel, text) =>
|
||||
calls.Add($"channel:{channel:X8}:{text}"));
|
||||
var surface = new LiveSessionCommandSurface();
|
||||
using ILiveSessionCommandRouting lease = surface.Attach(router);
|
||||
lease.Activate();
|
||||
var sequence = new LoginCommandSequence(
|
||||
["hello", "/tell Bob, secret", "@admin raw", "/age"],
|
||||
TimeSpan.Zero,
|
||||
new RuntimeChatCommandFeedback(communication),
|
||||
surface);
|
||||
|
||||
sequence.EnteredWorld(new RuntimeGenerationToken(9));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"talk:hello",
|
||||
"tell:Bob:secret",
|
||||
"channel:00000002:raw",
|
||||
"client:age",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InactiveAndDisposedRouter_CannotReachTransport()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,44 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
Assert.Contains("+Acdream", inWorld.Status, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginCommandFailureIsVisibleButDoesNotMakeTheSessionTerminal()
|
||||
{
|
||||
var statusSources = new QueueStatusSourceFactory();
|
||||
using LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
statusSourceFactory: statusSources);
|
||||
_ = await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless);
|
||||
|
||||
QueueStatusSource source = Assert.Single(statusSources.Created);
|
||||
source.Enqueue(Connected("s1"));
|
||||
source.Enqueue(EnteredWorld("s1", "+Acdream"));
|
||||
source.Enqueue(new LoginCommandFailedStatusEvent
|
||||
{
|
||||
V = 1,
|
||||
E = "loginCommandFailed",
|
||||
T = DateTimeOffset.UtcNow,
|
||||
SessionId = "s1",
|
||||
CommandIndex = 2,
|
||||
Command = "/version",
|
||||
Error = "not available in the headless host",
|
||||
});
|
||||
|
||||
orchestrator.PollStatus();
|
||||
|
||||
LauncherSessionSnapshot session = Assert.Single(
|
||||
orchestrator.GetSnapshot().Sessions);
|
||||
Assert.Equal(LauncherActivityState.InWorld, session.State);
|
||||
Assert.Equal(
|
||||
"Login command 2 failed: not available in the headless host",
|
||||
session.Error);
|
||||
Assert.Equal(session.Error, session.Status);
|
||||
Assert.Null(session.ExitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AccountGuiSelectDoesNotRequireACachedCharacterOrEmitASelector()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -77,6 +77,32 @@ public sealed class StatusEventParserTests
|
|||
Assert.Equal("boom", failed.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesLoginCommandFailed()
|
||||
{
|
||||
var failed = Assert.IsType<LoginCommandFailedStatusEvent>(
|
||||
StatusEventParser.Parse(
|
||||
"""{"v":1,"e":"loginCommandFailed","t":"2026-08-14T12:00:05Z","sessionId":"s1","commandIndex":2,"command":"/version","error":"unsupported headless command"}"""));
|
||||
|
||||
Assert.Equal(2, failed.CommandIndex);
|
||||
Assert.Equal("/version", failed.Command);
|
||||
Assert.Equal("unsupported headless command", failed.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":-1,\"command\":\"/version\",\"error\":\"unsupported\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":0,\"error\":\"unsupported\"}")]
|
||||
[InlineData("{\"v\":1,\"e\":\"loginCommandFailed\",\"t\":\"2026-08-14T12:00:05Z\",\"sessionId\":\"s1\",\"commandIndex\":0,\"command\":\"/version\"}")]
|
||||
public void MalformedLoginCommandFailureUsesTheKnownEventFailurePath(string line)
|
||||
{
|
||||
var malformed = Assert.IsType<MalformedStatusEvent>(
|
||||
StatusEventParser.Parse(line));
|
||||
|
||||
Assert.Equal("loginCommandFailed", malformed.E);
|
||||
Assert.Equal("s1", malformed.SessionId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(malformed.Error));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsesDisconnectedAndExited()
|
||||
{
|
||||
|
|
|
|||
60
tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs
Normal file
60
tests/AcDream.Runtime.Tests/Chat/ChatExtractionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
184
tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs
Normal file
184
tests/AcDream.Runtime.Tests/Chat/LoginCommandSequenceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; } = [];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="AcDream.Runtime.Chat" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|||
/// </summary>
|
||||
public sealed class ChatPanelFocusTests
|
||||
{
|
||||
private sealed class NullBus : AcDream.UI.Abstractions.ICommandBus
|
||||
private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus
|
||||
{
|
||||
public void Publish<T>(T command) where T : notnull { }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ public sealed class ChatPanelInputTests
|
|||
var entries = log.Snapshot();
|
||||
Assert.Equal(2, entries.Length);
|
||||
Assert.All(entries, entry => Assert.Equal(ChatKind.System, entry.Kind));
|
||||
Assert.Equal(AcDream.UI.Abstractions.Panels.Chat.RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
|
||||
Assert.Equal(AcDream.UI.Abstractions.Panels.Chat.RetailCommandHelpTable.AvailableHelpListing, entries[1].Text);
|
||||
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
|
||||
Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue