From 50883e445b209c5c058fd10e3e2c970c1649b968 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:09:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.2b):=20extract=20ChatCommandRouter=20?= =?UTF-8?q?=E2=80=94=20shared=20chat=20submit=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the ImGui devtools ChatPanel and the upcoming retail chat window now route through ChatCommandRouter.Submit so command handling lives in one place. The ChatPanel inline block (TryHandleClientCommand / EqAny / BuildHelpText) is deleted; ChatCommandRouter carries the same logic verbatim. ChatPanel.Render becomes a 2-line delegate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Panels/Chat/ChatCommandRouter.cs | 78 +++++++++++ .../Panels/Chat/ChatPanel.cs | 123 +----------------- .../Panels/Chat/ChatCommandRouterTests.cs | 74 +++++++++++ 3 files changed, 153 insertions(+), 122 deletions(-) create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs new file mode 100644 index 00000000..9158d2d0 --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -0,0 +1,78 @@ +using System; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// What a submit did, so the caller can clear its input + give feedback. +public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } + +/// +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 +/// 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). +/// +public static class ChatCommandRouter +{ + public static SubmitOutcome Submit( + string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) + { + ArgumentNullException.ThrowIfNull(vm); + ArgumentNullException.ThrowIfNull(bus); + var trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) return SubmitOutcome.Empty; + + if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + + if (trimmed[0] == '/') + { + 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; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; + } + + private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + { + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { vm.ShowSystemMessage(BuildHelpText()); return true; } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { vm.Clear(); return true; } + if (EqAny(trimmed, "/framerate", "@framerate")) + { vm.ShowFps(); return true; } + if (EqAny(trimmed, "/loc", "@loc")) + { vm.ShowLocation(); return true; } + return false; + } + + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; +} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index c8ece999..9cb8cb1f 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -191,53 +191,7 @@ public sealed class ChatPanel : IPanel if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) && submitted is not null) { - var trimmed = submitted.Trim(); - // Phase J follow-up: client-side commands intercepted before - // the server-bound parse path. Avoids the /help round-trip - // that produced "Unknown command: help" duplicates from - // ACE's command-error replies, AND gives users a discoverable - // local cheat-sheet of acdream's own slash prefixes. - if (TryHandleClientCommand(trimmed)) - { - _input = string.Empty; - renderer.EndChild(); // outer ##chatbody - renderer.End(); - 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.EndChild(); // outer ##chatbody - renderer.End(); - return; - } - } - - var parsed = ChatInputParser.Parse( - trimmed, - ChatChannelKind.Say, - _vm.LastIncomingTellSender, - _vm.LastOutgoingTellTarget); - if (parsed is { } p) - { - ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); - } - // Defensive: if the backend ever forgot to clear on submit, - // do it here. Cheap; no harm if already empty. + ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); _input = string.Empty; } @@ -258,79 +212,4 @@ public sealed class ChatPanel : IPanel _ => new Vector4(1f, 1f, 1f, 1f), }; - /// - /// Phase J follow-up: handle client-side slash commands before - /// the parser passes anything to the server bus. Returns true - /// when the input was consumed (and the caller should clear the - /// buffer + skip the SendChatCmd path); false otherwise. - /// - /// - /// Recognised client-side commands: - /// - /// /help, /?, /h — render the slash-prefix - /// cheat-sheet locally. Avoids the server's "Unknown command" - /// round-trip when the user just wants to know what they can - /// type. - /// /clear, /cls — drain the chat log so the - /// panel starts empty. - /// - /// - private bool TryHandleClientCommand(string trimmed) - { - if (trimmed.Length == 0) return false; - - // /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence. - if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) - { - _vm.ShowSystemMessage(BuildHelpText()); - return true; - } - - // /clear, /cls — also @clear, @cls. - if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) - { - _vm.Clear(); - return true; - } - - // /framerate — also @framerate. Prints current FPS to chat. - if (EqAny(trimmed, "/framerate", "@framerate")) - { - _vm.ShowFps(); - return true; - } - - // /loc — also @loc. Prints current player position to chat. - // ACE has a server-side @loc too; client-side wins here - // (instantaneous + uses our local interpolated position). - if (EqAny(trimmed, "/loc", "@loc")) - { - _vm.ShowLocation(); - return true; - } - - return false; - } - - /// Case-insensitive multi-string equality test. - private static bool EqAny(string s, params string[] options) - { - for (int i = 0; i < options.Length; i++) - if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - - /// - /// Multi-line cheat-sheet text rendered by /help. ImGui's - /// Text path flows embedded newlines naturally so this lands - /// as one ChatLog entry that visually wraps to several lines. - /// - private static string BuildHelpText() => - "Note: / and @ are equivalent prefixes.\n" + - "Chat: /say (default), /tell , /reply, /retell\n" + - "Channels: /general /trade /fellowship /allegiance\n" + - " /patron /vassals /monarch /covassals\n" + - " /lfg /roleplay /society /olthoi\n" + - "Client: /help (this) /clear /framerate /loc\n" + - "Server: type @acehelp or @acecommands for ACE's full list."; } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs new file mode 100644 index 00000000..e0f1daad --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -0,0 +1,74 @@ +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using Xunit; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public class ChatCommandRouterTests +{ + private sealed class CaptureBus : ICommandBus + { + public SendChatCmd? Last; + public void Publish(T command) where T : notnull + { + if (command is SendChatCmd c) Last = c; + } + } + + private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() + { + var log = new ChatLog(); + var vm = new ChatVM(log, displayLimit: 50); + return (vm, log, new CaptureBus()); + } + + [Fact] + public void PlainText_PublishesOnDefaultChannel() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); + Assert.NotNull(bus.Last); + Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); + Assert.Equal("hello there", bus.Last.Text); + } + + [Fact] + public void DefaultChannel_IsHonored() + { + var (vm, _, bus) = Fixture(); + ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + } + + [Fact] + public void ClearCommand_DrainsLog_DoesNotPublish() + { + var (vm, log, bus) = Fixture(); + log.OnSystemMessage("x", chatType: 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + Assert.Null(bus.Last); + Assert.Empty(log.Snapshot()); + } + + [Fact] + public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() + { + 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")); + } + + [Fact] + public void EmptyInput_DoesNothing() + { + var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); + Assert.Null(bus.Last); + } +}