From 579cbfb48b558db3c3b2cf04d179dd561332b22e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 21:49:56 +0200 Subject: [PATCH] fix(chat): block / unknown commands from broadcasting as speech MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported typing /ls (a command-style request, not chat) gets echoed by the server as "You say, \"/ls\"". Slash-prefix is a COMMAND surface, never a chat surface. Filed after the same flow that produced @help and the welcome-message work. Behavior change at the ChatPanel submit layer: - Any /-prefixed input whose verb isn't in our alias tables now renders a local "[System] Unknown command: /foo. Type /help for the list." line and is NEVER published to the bus. No SendChatCmd, no Talk packet. The server never sees /foo. - Known /-verbs (/say /tell /reply /retell /general /allegiance /patron /vassals /monarch /covassals /fellowship /lookingforgroup /trade /roleplay /society /olthoi /help /clear /framerate /loc and friends) still flow through ChatInputParser.Parse → SendChatCmd exactly as before. - @-prefix unchanged: ACE's CommandManager handles unknown @ verbs server-side and replies via SystemChat ("Unknown command: foo") per ACE GameActionTalk.cs:21. Our @ -> / normalization for known verbs (Phase J Tier 1) and the @-passthrough fallthrough for unknown verbs both still apply. ChatInputParser now exposes: - IsKnownVerb(string verb): query against the union of every alias table. Used by ChatPanel to discriminate "unknown verb" from "known verb with bad args". - GetVerbToken(string command): public alias of the existing ExtractVerb so callers can pull the first whitespace token without reproducing the helper. Parse itself is unchanged — its existing fall-through (Say with literal text) still applies for unknown /-verbs called directly via the parser, but ChatPanel intercepts before reaching that path so the fall-through never fires through the live submit pipeline. Tests that directly call Parse continue to pass; the new ChatPanel-level tests pin the unknown-command rejection. 19 new tests: - ChatInputParserTests: 10 IsKnownVerb Theory cases + 4 GetVerbToken Theory cases. - ChatPanelInputTests: 5 Theory cases for Submit_UnknownSlashCommand covering /foo, /ls, /mp , /genio, and bare /. Solution total: 1086 green (243 Core.Net + 183 UI + 660 Core), 0 warnings. Acceptance: type /ls, /mp /path, /anything-not-known — see local "[System] Unknown command: /xxx. Type /help for the list of supported commands." Nothing reaches the wire. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Panels/Chat/ChatInputParser.cs | 17 ++++++++++ .../Panels/Chat/ChatPanel.cs | 21 ++++++++++++ .../Panels/Chat/ChatInputParserTests.cs | 33 +++++++++++++++++-- .../Panels/Chat/ChatPanelInputTests.cs | 32 ++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index 2e41997..82aaf76 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -304,4 +304,21 @@ public static class ChatInputParser foreach (var (v, _) in ChannelVerbs) set.Add(v); return set; } + + /// + /// Returns true if (with leading + /// /) is one this parser routes — used by callers that + /// need to distinguish "unknown slash command" from "known + /// verb with bad arguments" without reproducing the alias + /// tables. @-prefixed verbs need to be normalized to + /// / before passing. + /// + public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(verb); + + /// + /// Pull the first whitespace-separated token (the command verb) + /// from . Returns the entire string if + /// there is no whitespace. + /// + public static string GetVerbToken(string command) => ExtractVerb(command); } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index c60524b..fe8939f 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -131,6 +131,27 @@ public sealed class ChatPanel : IPanel return; } + // Phase J Tier 4: any /-prefixed input that ISN'T one of our + // known verbs gets a local "Unknown command" message instead + // of being broadcast to the server as plain speech. The + // user reported "/ls" / "/mp /path" leaking out as chat — + // a / prefix is a command, never speech. (@-prefixed unknown + // verbs still pass through to ACE because ACE's + // CommandManager intercepts @ server-side and replies with + // its own "Unknown command" / valid command output.) + if (trimmed.Length > 0 && trimmed[0] == '/') + { + string verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + _vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + _input = string.Empty; + renderer.End(); + return; + } + } + var parsed = ChatInputParser.Parse( trimmed, ChatChannelKind.Say, diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs index 2ac4c2c..052aa6b 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs @@ -228,8 +228,11 @@ public sealed class ChatInputParserTests // Phase J added long-form aliases (/general, /allegiance, // /patron, etc.). The exact-token rule still applies — a // verb prefix that ISN'T one of the listed aliases falls - // through to the default channel. "/genio" is not /g, /general, - // or /gen — must stay as Say carrying the literal text. + // through. The Parse-level behaviour for unknown /-verbs is + // still "Say with the literal text" (matches holtburger); + // the ChatPanel layer is what catches unknowns and shows the + // local "Unknown command" line. ChatPanelInputTests cover + // that end-to-end behaviour. var parsed = ChatInputParser.Parse("/genio public", ChatChannelKind.Say, lastTellSender: null); Assert.NotNull(parsed); @@ -237,6 +240,32 @@ public sealed class ChatInputParserTests Assert.Equal("/genio public", parsed.Value.Text); } + [Theory] + [InlineData("/g", true)] + [InlineData("/say", true)] + [InlineData("/tell", true)] + [InlineData("/retell", true)] + [InlineData("/allegiance", true)] + [InlineData("/lookingforgroup", true)] + [InlineData("/genio", false)] + [InlineData("/ls", false)] + [InlineData("/foo", false)] + [InlineData("/", false)] + public void IsKnownVerb_ChecksAgainstAliasTables(string verb, bool expected) + { + Assert.Equal(expected, ChatInputParser.IsKnownVerb(verb)); + } + + [Theory] + [InlineData("/g hello", "/g")] + [InlineData("/tell Bob hi", "/tell")] + [InlineData("/foo", "/foo")] + [InlineData("/", "/")] + public void GetVerbToken_PullsFirstWhitespaceToken(string command, string expected) + { + Assert.Equal(expected, ChatInputParser.GetVerbToken(command)); + } + [Theory] [InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")] [InlineData("/allegiance recall", ChatChannelKind.Allegiance, "recall")] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 3496a42..07b3bf5 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -112,6 +112,38 @@ public sealed class ChatPanelInputTests Assert.Contains("(10.0, 20.0, 30.0)", entry.Text); } + [Theory] + [InlineData("/foo")] + [InlineData("/ls")] + [InlineData("/mp /tools/script.py")] + [InlineData("/genio public")] + [InlineData("/")] + public void Submit_UnknownSlashCommand_ShowsUnknownAndDoesNotPublish(string raw) + { + // Phase J Tier 4: /-prefixed text is NEVER broadcast as plain + // speech. Filed after a 2026-04-25 trace where typing /ls (a + // command-style request the user wanted) was getting echoed by + // the server as "You say, \"/ls\"". Now we intercept and show + // a local "Unknown command" line; nothing goes on the wire. + var log = new ChatLog(); + var vm = new ChatVM(log); + var panel = new ChatPanel(vm); + var bus = new RecordingBus(); + var renderer = new FakePanelRenderer + { + InputTextSubmitNextSubmitted = raw, + InputTextSubmitNextBufferAfter = "", + }; + + panel.Render(new PanelContext(0.016f, bus), renderer); + + Assert.Empty(bus.Published); + var entry = Assert.Single(log.Snapshot()); + Assert.Equal(ChatKind.System, entry.Kind); + Assert.Contains("Unknown command", entry.Text); + Assert.Contains("/help", entry.Text); + } + [Fact] public void Submit_AtAcehelp_PassesThroughToSayWithAtIntact() {