diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 5219f94c..21b168f3 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -929,6 +929,24 @@ internal sealed class HeadlessSessionHost : IDisposable case ClientCommandId.ClearChat: runtime.CommunicationOwner.Chat.Clear(); 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: session.SendIndexChannels(); return; @@ -953,6 +971,9 @@ internal sealed class HeadlessSessionHost : IDisposable case ClientCommandId.AllegianceInfo: session.SendAllegianceInfoRequest(command.Arguments.Trim()); return; + case ClientCommandId.Permit: + ExecutePermit(session, command.Arguments); + return; case ClientCommandId.HouseAvailableList when RetailClientCommandCatalog.TryResolveHouseType( command.Arguments, @@ -996,6 +1017,29 @@ internal sealed class HeadlessSessionHost : IDisposable } 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( diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 2812f7bf..054c6e96 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -54,6 +54,53 @@ public sealed class LiveSessionCommandRouterTests calls); } + [Fact] + public void GraphicalRouteKeepsWireOnlyClientCommandParityWithHeadless() + { + var calls = new List(); + 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] public void InactiveAndDisposedRouter_CannotReachTransport() { diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 8dabeb98..7a7081ac 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -71,6 +71,127 @@ public sealed class HeadlessSessionHostTests BinaryPrimitives.ReadUInt32LittleEndian(captured[3].AsSpan(12))); } + [Fact] + public void LoginCommandsRouteWireOnlyClientCommandsWithExactPolarityAndOrder() + { + var captured = new List(); + 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(); + 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] public void LoginCommandFailuresAreVersionedOrderedAndSessionIsolated() { @@ -3286,6 +3407,13 @@ public sealed class HeadlessSessionHostTests 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 long _timestamp;