fix(headless): route wire-only chat commands
This commit is contained in:
parent
41b15efd4d
commit
259f0e5ac3
3 changed files with 219 additions and 0 deletions
|
|
@ -929,6 +929,24 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
case ClientCommandId.ClearChat:
|
case ClientCommandId.ClearChat:
|
||||||
runtime.CommunicationOwner.Chat.Clear();
|
runtime.CommunicationOwner.Chat.Clear();
|
||||||
return;
|
return;
|
||||||
|
case ClientCommandId.ChatToggle:
|
||||||
|
// Retail DoChatToggle: "off" adds the global Speech
|
||||||
|
// squelch; "on" removes it.
|
||||||
|
session.SendModifyGlobalSquelch(
|
||||||
|
command.Arguments.Equals(
|
||||||
|
"off",
|
||||||
|
StringComparison.OrdinalIgnoreCase),
|
||||||
|
2u);
|
||||||
|
return;
|
||||||
|
case ClientCommandId.NoTellToggle:
|
||||||
|
// Retail DoNoTell: "on" adds the global Tell squelch;
|
||||||
|
// "off" removes it.
|
||||||
|
session.SendModifyGlobalSquelch(
|
||||||
|
command.Arguments.Equals(
|
||||||
|
"on",
|
||||||
|
StringComparison.OrdinalIgnoreCase),
|
||||||
|
3u);
|
||||||
|
return;
|
||||||
case ClientCommandId.IndexChannels:
|
case ClientCommandId.IndexChannels:
|
||||||
session.SendIndexChannels();
|
session.SendIndexChannels();
|
||||||
return;
|
return;
|
||||||
|
|
@ -953,6 +971,9 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
case ClientCommandId.AllegianceInfo:
|
case ClientCommandId.AllegianceInfo:
|
||||||
session.SendAllegianceInfoRequest(command.Arguments.Trim());
|
session.SendAllegianceInfoRequest(command.Arguments.Trim());
|
||||||
return;
|
return;
|
||||||
|
case ClientCommandId.Permit:
|
||||||
|
ExecutePermit(session, command.Arguments);
|
||||||
|
return;
|
||||||
case ClientCommandId.HouseAvailableList
|
case ClientCommandId.HouseAvailableList
|
||||||
when RetailClientCommandCatalog.TryResolveHouseType(
|
when RetailClientCommandCatalog.TryResolveHouseType(
|
||||||
command.Arguments,
|
command.Arguments,
|
||||||
|
|
@ -996,6 +1017,29 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
}
|
}
|
||||||
send(channelId);
|
send(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void ExecutePermit(
|
||||||
|
AcDream.Core.Net.WorldSession activeSession,
|
||||||
|
string arguments)
|
||||||
|
{
|
||||||
|
// The catalog has already required add/remove plus a name.
|
||||||
|
// Match ClientCommandController's JoinArgsAsName behavior so
|
||||||
|
// multi-word character names remain one exact wire argument.
|
||||||
|
string[] parts = arguments.Split(
|
||||||
|
(char[]?)null,
|
||||||
|
StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
string name = string.Join(' ', parts, 1, parts.Length - 1);
|
||||||
|
if (parts[0].Equals(
|
||||||
|
"add",
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
activeSession.SendAddPlayerPermission(name);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
activeSession.SendRemovePlayerPermission(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ILiveSessionEventRouting CreateEventRoute(
|
private ILiveSessionEventRouting CreateEventRoute(
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,53 @@ public sealed class LiveSessionCommandRouterTests
|
||||||
calls);
|
calls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GraphicalRouteKeepsWireOnlyClientCommandParityWithHeadless()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
ClientCommandController.Bindings client = NewClientBindings() with
|
||||||
|
{
|
||||||
|
AddPlayerPermission = name =>
|
||||||
|
calls.Add($"permit:add:{name}"),
|
||||||
|
RemovePlayerPermission = name =>
|
||||||
|
calls.Add($"permit:remove:{name}"),
|
||||||
|
ModifyGlobalSquelch = (add, messageType) =>
|
||||||
|
calls.Add($"squelch:{add}:{messageType}"),
|
||||||
|
};
|
||||||
|
var router = NewRouter(clientBindings: client);
|
||||||
|
router.Activate();
|
||||||
|
|
||||||
|
router.Publish(new ExecuteClientCommandCmd(
|
||||||
|
ClientCommandId.Permit,
|
||||||
|
"add Aunt Agatha"));
|
||||||
|
router.Publish(new ExecuteClientCommandCmd(
|
||||||
|
ClientCommandId.Permit,
|
||||||
|
"remove Lord Gnarly Beard"));
|
||||||
|
router.Publish(new ExecuteClientCommandCmd(
|
||||||
|
ClientCommandId.ChatToggle,
|
||||||
|
"on"));
|
||||||
|
router.Publish(new ExecuteClientCommandCmd(
|
||||||
|
ClientCommandId.ChatToggle,
|
||||||
|
"off"));
|
||||||
|
router.Publish(new ExecuteClientCommandCmd(
|
||||||
|
ClientCommandId.NoTellToggle,
|
||||||
|
"on"));
|
||||||
|
router.Publish(new ExecuteClientCommandCmd(
|
||||||
|
ClientCommandId.NoTellToggle,
|
||||||
|
"off"));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"permit:add:Aunt Agatha",
|
||||||
|
"permit:remove:Lord Gnarly Beard",
|
||||||
|
"squelch:False:2",
|
||||||
|
"squelch:True:2",
|
||||||
|
"squelch:True:3",
|
||||||
|
"squelch:False:3",
|
||||||
|
],
|
||||||
|
calls);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void InactiveAndDisposedRouter_CannotReachTransport()
|
public void InactiveAndDisposedRouter_CannotReachTransport()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,127 @@ public sealed class HeadlessSessionHostTests
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(captured[3].AsSpan(12)));
|
BinaryPrimitives.ReadUInt32LittleEndian(captured[3].AsSpan(12)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoginCommandsRouteWireOnlyClientCommandsWithExactPolarityAndOrder()
|
||||||
|
{
|
||||||
|
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:
|
||||||
|
[
|
||||||
|
"/permit add Aunt Agatha",
|
||||||
|
"@permit remove Lord Gnarly Beard",
|
||||||
|
"/chat on",
|
||||||
|
"/chat off",
|
||||||
|
"/notell on",
|
||||||
|
"/notell off",
|
||||||
|
],
|
||||||
|
loginCommandDelayMs: 0),
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||||
|
operations);
|
||||||
|
|
||||||
|
Assert.Equal(RuntimeSessionStartStatus.Connected, host.Start().Status);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
ClientCommandRequests.AddPlayerPermissionOpcode,
|
||||||
|
ClientCommandRequests.RemovePlayerPermissionOpcode,
|
||||||
|
ClientCommandRequests.ModifyGlobalSquelchOpcode,
|
||||||
|
ClientCommandRequests.ModifyGlobalSquelchOpcode,
|
||||||
|
ClientCommandRequests.ModifyGlobalSquelchOpcode,
|
||||||
|
ClientCommandRequests.ModifyGlobalSquelchOpcode,
|
||||||
|
],
|
||||||
|
captured.Select(ActionOpcode));
|
||||||
|
Assert.Equal("Aunt Agatha", StringActionArgument(captured[0]));
|
||||||
|
Assert.Equal("Lord Gnarly Beard", StringActionArgument(captured[1]));
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
(Add: 0u, MessageType: 2u),
|
||||||
|
(Add: 1u, MessageType: 2u),
|
||||||
|
(Add: 1u, MessageType: 3u),
|
||||||
|
(Add: 0u, MessageType: 3u),
|
||||||
|
],
|
||||||
|
captured.Skip(2).Select(static body => (
|
||||||
|
Add: BinaryPrimitives.ReadUInt32LittleEndian(
|
||||||
|
body.AsSpan(12, sizeof(uint))),
|
||||||
|
MessageType: BinaryPrimitives.ReadUInt32LittleEndian(
|
||||||
|
body.AsSpan(16, sizeof(uint))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/permit add")]
|
||||||
|
[InlineData("/chat maybe")]
|
||||||
|
[InlineData("/notell maybe")]
|
||||||
|
public void InvalidWireOnlyArgumentKeepsTypedFeedbackWithoutStatusFailure(
|
||||||
|
string invalidCommand)
|
||||||
|
{
|
||||||
|
string statusPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"acdream-headless-wire-client-errors-{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:
|
||||||
|
[
|
||||||
|
invalidCommand,
|
||||||
|
"after",
|
||||||
|
],
|
||||||
|
loginCommandDelayMs: 0),
|
||||||
|
credential,
|
||||||
|
new HeadlessDiagnosticWriter(diagnosticsOutput),
|
||||||
|
operations);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
host.Start().Status);
|
||||||
|
Assert.Single(captured);
|
||||||
|
Assert.Equal("after", TalkText(captured[0]));
|
||||||
|
|
||||||
|
// Invalid registered-command arguments are handled exactly as
|
||||||
|
// typed: retail's ClientLocal refusal reaches canonical Runtime
|
||||||
|
// feedback and is not misclassified as a transport failure.
|
||||||
|
host.Runtime.CommunicationOwner.SpewBox.Tick(0d);
|
||||||
|
Assert.Equal(
|
||||||
|
"That is not a valid command.",
|
||||||
|
Assert.Single(
|
||||||
|
host.Runtime.CommunicationOwner.SpewBox.Snapshot()).Text);
|
||||||
|
|
||||||
|
JsonElement[] events = File.ReadAllLines(statusPath)
|
||||||
|
.Select(static line =>
|
||||||
|
JsonDocument.Parse(line).RootElement.Clone())
|
||||||
|
.ToArray();
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
events,
|
||||||
|
static item => item.GetProperty("e").GetString()
|
||||||
|
== "loginCommandFailed");
|
||||||
|
Assert.True(host.Runtime.Session.IsInWorld);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(statusPath))
|
||||||
|
File.Delete(statusPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void LoginCommandFailuresAreVersionedOrderedAndSessionIsolated()
|
public void LoginCommandFailuresAreVersionedOrderedAndSessionIsolated()
|
||||||
{
|
{
|
||||||
|
|
@ -3286,6 +3407,13 @@ public sealed class HeadlessSessionHostTests
|
||||||
return System.Text.Encoding.ASCII.GetString(body, 14, length);
|
return System.Text.Encoding.ASCII.GetString(body, 14, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string StringActionArgument(byte[] 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 sealed class ManualTimeProvider : TimeProvider
|
||||||
{
|
{
|
||||||
private long _timestamp;
|
private long _timestamp;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue