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
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue