From d3cab1ab102185eb218d3e8029578f04747b6ff4 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 3 Jul 2026 09:46:50 +0200 Subject: [PATCH] fix(chat): / and @ are equivalent command prefixes (retail parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail treats / and @ interchangeably for commands, but two client-side layers broke the / spelling for server commands: - ChatCommandRouter refused ANY unknown /verb with a local "Unknown command" guess — so /tele, /ci, /acehelp never reached ACE even though ACE supports them. The guard is gone; the server is now the single authority on what's a valid command (ACE replies "Unknown command: x" itself). - ChatInputParser passed unknown /verbs through as literal speech. ACE's GameActionTalk only intercepts the @ form on the wire (the / acceptance in CommandManager is the server CONSOLE path), so the parser now rewrites unknown /xyz -> @xyz. Both command pass-throughs (@ and rewritten /) now also force the Say channel: GameActionTalk (0x0015) is the only wire action ACE parses commands on — previously a command typed with a chat channel active would broadcast as channel speech. Phase J Tier 4 ("/-text must never broadcast as speech") still holds: letter-verbed input goes out as an @command (never speech), and command-shaped-but-verbless input ("/", "//shrug") is refused locally by a narrow router guard. Co-Authored-By: Claude Fable 5 --- .../Panels/Chat/ChatCommandRouter.cs | 34 ++++++++----- .../Panels/Chat/ChatInputParser.cs | 36 +++++++++----- .../Panels/Chat/ChatCommandRouterTests.cs | 26 ++++++++-- .../Panels/Chat/ChatInputParserTests.cs | 49 ++++++++++++++----- .../Panels/Chat/ChatPanelInputTests.cs | 47 +++++++++++++----- 5 files changed, 141 insertions(+), 51 deletions(-) diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs index 9158d2d0..81034765 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -2,7 +2,11 @@ using System; namespace AcDream.UI.Abstractions.Panels.Chat; -/// What a submit did, so the caller can clear its input + give feedback. +/// What a submit did, so the caller can clear its input + give feedback. +/// UnknownCommand is now produced only for command-SHAPED but verbless +/// input ("/", "//x", "@ x"); real unknown verbs route to the server (retail +/// treats / and @ as equivalent command prefixes, and ACE answers unknown +/// commands itself). public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } /// @@ -10,9 +14,15 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped /// analogue). Both the ImGui devtools and the retail /// chat window route through here so command handling stays in one place. /// -/// Order mirrors the prior inline flow: -/// client-command intercept → unknown-slash-verb guard → -/// → Publish(SendChatCmd). +/// Flow: client-command intercept → degenerate-prefix guard → +/// Publish(SendChatCmd). +/// Unknown slash/at verbs pass through the parser to the server (the parser +/// rewrites /xyz@xyz and forces the Say action, the only +/// wire path ACE parses commands on); ACE replies "Unknown command: xyz" +/// for verbs it doesn't know, so the server stays the single authority on +/// what's a valid command. Prefix text with no letter verb is refused +/// locally — /-prefixed input must NEVER broadcast as speech (Phase J +/// Tier 4, from the 2026-04-25 "/ls echoed as speech" trace). /// public static class ChatCommandRouter { @@ -26,15 +36,15 @@ public static class ChatCommandRouter if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; - if (trimmed[0] == '/') + // Command-shaped but no letter verb ("/", "//shrug", "@ x"): refuse + // locally. Rewriting to @ would put junk on the wire, and letting it + // fall through would broadcast /-text as speech (Tier-4 violation). + if (trimmed[0] is '/' or '@' + && (trimmed.Length == 1 || !char.IsLetter(trimmed[1]))) { - var verb = ChatInputParser.GetVerbToken(trimmed); - if (!ChatInputParser.IsKnownVerb(verb)) - { - vm.ShowSystemMessage( - $"Unknown command: {verb}. Type /help for the list of supported commands."); - return SubmitOutcome.UnknownCommand; - } + vm.ShowSystemMessage( + $"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands."); + return SubmitOutcome.UnknownCommand; } var parsed = ChatInputParser.Parse( diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index 82aaf760..73141255 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -16,11 +16,12 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// /say with no message → null /// /t with no target / no message → null /// /r with no lastTellSendernull -/// unknown /xyz verb → fall through to default channel -/// carrying the literal text (matches holtburger -/// handle_slash_command default arm at line ~744 — they -/// send ClientCommand::Talk(command) with the original -/// slash-prefixed string) +/// unknown /xyz verb → rewritten to @xyz and passed +/// through on the default channel. Retail treats / and @ as +/// equivalent command prefixes; ACE's GameActionTalk only +/// intercepts the @ form on the wire. Deliberate divergence +/// from holtburger's literal fall-through (which would SAY the +/// command text out loud). /// multi-word /t target: first whitespace token is target, /// rest is message (matches Rust split_once semantics) /// @@ -122,8 +123,12 @@ public static class ChatInputParser return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget); } // Unknown @-verb — keep the original @ so ACE recognizes - // it server-side when the message arrives via Talk. - return new ParsedInput(defaultChannel, null, trimmed); + // it server-side. Always emit as Say: ACE's GameActionTalk + // (the 0x0015 Talk action) is the ONLY wire path that parses + // @commands — on a chat channel the text would broadcast as + // ordinary channel speech. Retail likewise resolves commands + // before channel routing. + return new ParsedInput(ChatChannelKind.Say, null, trimmed); } // /say @@ -165,11 +170,18 @@ public static class ChatInputParser return null; } - // Unknown slash command: holtburger falls back to Talk(literal). - // We mirror that — emit on the default channel with the - // slash-prefixed text intact so the server treats it as speech. - // No special-case for known-but-unimplemented verbs; the user's - // text round-trips so their intent isn't silently dropped. + // Unknown slash verb: retail treats / and @ as equivalent command + // prefixes, but ACE's GameActionTalk only intercepts the @ form on + // the wire (a /-prefixed Talk is plain speech server-side). Rewrite + // to @ and emit as Say — same reasoning as the unknown-@ branch at + // the top: only the Talk action parses commands. (Deliberate + // divergence from holtburger's literal fall-through, which would + // SAY "/ci 629" out loud.) + if (trimmed.Length > 1 && trimmed[0] == '/' && char.IsLetter(trimmed[1])) + return new ParsedInput(ChatChannelKind.Say, null, "@" + trimmed.Substring(1)); + + // Plain speech (no recognized verb): emit on the default channel + // so the user's text round-trips instead of being silently dropped. return new ParsedInput(defaultChannel, null, trimmed); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs index e0f1daad..bd180c7e 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -54,13 +54,31 @@ public class ChatCommandRouterTests } [Fact] - public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + public void UnknownSlashVerb_RoutesToServerInAtForm() { + // Retail / ≡ @: unknown verbs are the server's to judge. The + // parser rewrites /-form to @-form (ACE's GameActionTalk only + // intercepts @ on the wire); ACE answers "Unknown command: x" + // for verbs it doesn't know — no local guess. var (vm, log, bus) = Fixture(); var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.UnknownCommand, outcome); - Assert.Null(bus.Last); - Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal("@notacommand", bus.Last!.Text); + Assert.DoesNotContain(log.Snapshot(), e => e.Text.Contains("Unknown command")); + } + + [Fact] + public void ServerCommandWithArgs_PublishesAtFormAsSay_EvenOnChannelDefault() + { + // Commands resolve before channel routing (retail behavior): + // even with Fellowship as the active input channel, a command + // goes out as Say/Talk — the only wire action ACE parses @ on. + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("/ci 629 5", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("@ci 629 5", bus.Last.Text); } [Fact] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs index 052aa6bb..c039af43 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs @@ -191,18 +191,46 @@ public sealed class ChatInputParserTests Assert.Null(ChatInputParser.Parse("/a", ChatChannelKind.Say, lastTellSender: null)); } - // -- Unknown slash command: holtburger fall-through to Talk(literal) - // ("/wave hello" → Talk("/wave hello") + treat-as-Say). See - // chat.rs::handle_slash_command default arm at line ~744. + // -- Unknown slash command: retail / ≡ @ equivalence. ACE's + // GameActionTalk only intercepts @-prefixed Talk on the wire, so + // the parser rewrites the unknown /-verb to its @ form for the + // server's CommandManager. (Deliberate divergence from holtburger's + // literal fall-through, which would speak the command out loud.) [Fact] - public void UnknownSlashCommand_FallsBackToDefaultChannelWithLiteralText() + public void UnknownSlashCommand_IsRewrittenToAtFormForServer() { var parsed = ChatInputParser.Parse("/xyz hello", ChatChannelKind.Say, lastTellSender: null); Assert.NotNull(parsed); Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel); Assert.Null(parsed.Value.TargetName); - Assert.Equal("/xyz hello", parsed.Value.Text); + Assert.Equal("@xyz hello", parsed.Value.Text); + } + + [Theory] + [InlineData("/ci 629", "@ci 629")] // ACE admin: create inventory item + [InlineData("/tele holtburg", "@tele holtburg")] // ACE admin: teleport + [InlineData("@ci 629", "@ci 629")] // @ form already server-ready — untouched + [InlineData("@acehelp", "@acehelp")] + public void ServerCommandVerbs_ReachTheWireInAtForm(string input, string expectedText) + { + var parsed = ChatInputParser.Parse(input, ChatChannelKind.Say, lastTellSender: null); + + Assert.NotNull(parsed); + Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel); + Assert.Equal(expectedText, parsed.Value.Text); + } + + [Fact] + public void SlashWithoutLetterVerb_StaysLiteralAtParseLevel() + { + // "/ hello" and "//shrug" have no letter verb after the slash, so + // the pure Parse function leaves them untouched. In the real + // submit flows they never reach the wire: ChatCommandRouter's + // degenerate-prefix guard refuses them locally (Phase J Tier 4 — + // /-prefixed text must never broadcast as speech). + Assert.Equal("/ hello", ChatInputParser.Parse("/ hello", ChatChannelKind.Say, lastTellSender: null)!.Value.Text); + Assert.Equal("//shrug", ChatInputParser.Parse("//shrug", ChatChannelKind.Say, lastTellSender: null)!.Value.Text); } // -- Default-channel parameter is honoured when no prefix is given -- @@ -227,17 +255,14 @@ 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. 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. + // verb prefix that ISN'T one of the listed aliases is NOT a + // client verb; it routes to the server in @ form like any + // other unknown verb (retail / ≡ @). var parsed = ChatInputParser.Parse("/genio public", ChatChannelKind.Say, lastTellSender: null); Assert.NotNull(parsed); Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel); - Assert.Equal("/genio public", parsed.Value.Text); + Assert.Equal("@genio public", parsed.Value.Text); } [Theory] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 07b3bf5a..1977688c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -113,18 +113,43 @@ public sealed class ChatPanelInputTests } [Theory] - [InlineData("/foo")] - [InlineData("/ls")] - [InlineData("/mp /tools/script.py")] - [InlineData("/genio public")] - [InlineData("/")] - public void Submit_UnknownSlashCommand_ShowsUnknownAndDoesNotPublish(string raw) + [InlineData("/foo", "@foo")] + [InlineData("/ls", "@ls")] + [InlineData("/mp /tools/script.py", "@mp /tools/script.py")] + [InlineData("/genio public", "@genio public")] + public void Submit_UnknownSlashCommand_RoutesToServerAsAtCommand(string raw, string expectedText) { - // 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. + // Phase J Tier 4 held: /-prefixed text is still NEVER broadcast + // as plain speech. Retail treats / and @ as equivalent command + // prefixes, so unknown verbs now go to the SERVER as @commands + // (ACE's GameActionTalk intercepts @ on the Say action and + // answers "Unknown command: x" itself) instead of a local guess. + 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); + + var sendCmd = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ChatChannelKind.Say, sendCmd.Channel); + Assert.Equal(expectedText, sendCmd.Text); + Assert.Empty(log.Snapshot()); // no local "Unknown command" guess + } + + [Theory] + [InlineData("/")] + [InlineData("//shrug")] + public void Submit_CommandShapedWithoutVerb_ShowsUnknownAndDoesNotPublish(string raw) + { + // Command-shaped but no letter verb: refused locally — this is + // the remaining Tier-4 guard (never broadcast /-text as speech, + // and don't put junk @-rewrites on the wire either). var log = new ChatLog(); var vm = new ChatVM(log); var panel = new ChatPanel(vm);