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