fix(headless): route wire-only chat commands

This commit is contained in:
Erik 2026-08-14 20:47:06 +02:00
parent 41b15efd4d
commit 259f0e5ac3
3 changed files with 219 additions and 0 deletions

View file

@ -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(

View file

@ -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()
{ {

View file

@ -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;