From 35011940833bca646c937cb65bfe8032e3ee31a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 21:22:07 +0200 Subject: [PATCH] fix(chat): /help client-side handler + System dedup + ThatIsNotAValidCommand template Phase J follow-up after a 2026-04-25 trace where typing /help produced two identical "Unknown command: help" lines (ACE fires the text via both GameMessageSystemChat 0xF7E0 and a paired CommunicationTransientString 0x02EB), and the server's WeenieError 0x0026 trailer rendered cryptically as "WeenieError 0x0026". Three small changes: 1. WeenieErrorMessages: add 0x0026 ThatIsNotAValidCommand -> "That is not a valid command." Plus 0x0414 / 0x050F that Phase J already added are now covered by tests too. 2. ChatLog.OnSystemMessage dedup. Track last system text + arrival time; if a second identical text shows up within 1 second, suppress. ACE's two-path send (gag warnings, command errors, etc.) collapses to a single chat line. Long bursts of repeated text still skip the duplicates without resetting the timer. 3. Client-side /help and /clear in ChatPanel. Intercepted BEFORE the parser passes to the server bus: - /help, /?, /h (case-insensitive) -> render local cheat-sheet listing acdream's slash prefixes via ChatLog.OnSystemMessage. Avoids the round-trip to ACE that produced the duplicate "Unknown command: help" lines AND gives users discoverability. - /clear, /cls -> drains the chat log so the panel starts empty. New ChatVM.ShowSystemMessage() + ChatVM.Clear() expose the minimum surface the panel needs to dispatch client-only feedback without coupling the panel to ChatLog directly. 12 new tests: - 3 WeenieErrorMessages template adds (0x0026 / 0x0414 / 0x050F). - 4 ChatLog dedup cases (immediate dup, different text, triplet, bookended-by-different-text). - 5 ChatPanel client-command cases (/help, 3 alias variants, /clear). Solution total: 1033 green (243 Core.Net + 130 UI + 660 Core), 0 warnings. Acceptance: type /help in chat -> local help banner appears, no server round-trip, no "Unknown command: help" duplicates. Type /clear -> chat tail empty. Welcome banner + WeenieError-templated "You are not in an allegiance!" / "You do not belong to a Fellowship." continue rendering once each. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Chat/ChatLog.cs | 32 ++++++++- src/AcDream.Core/Chat/WeenieErrorMessages.cs | 3 + .../Panels/Chat/ChatPanel.cs | 67 +++++++++++++++++- .../Panels/Chat/ChatVM.cs | 12 ++++ .../Chat/ChatLogSystemDedupTests.cs | 61 ++++++++++++++++ .../Chat/WeenieErrorMessagesTests.cs | 27 ++++++++ .../Panels/Chat/ChatPanelInputTests.cs | 69 +++++++++++++++++++ 7 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Chat/ChatLogSystemDedupTests.cs diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs index 49d5f76..f02364f 100644 --- a/src/AcDream.Core/Chat/ChatLog.cs +++ b/src/AcDream.Core/Chat/ChatLog.cs @@ -26,6 +26,17 @@ public sealed class ChatLog private readonly int _maxEntries; private uint _localPlayerGuid; + // Phase J follow-up: ACE often sends the same system text via two + // wire paths (GameMessageSystemChat 0xF7E0 + GameEventCommunication- + // TransientString 0x02EB) for back-compat — we wired both to + // OnSystemMessage in I.5/J, so the user saw lines like "Unknown + // command: help" twice. Dedupe within a short window: track the + // last system text + arrival time; if a second identical text + // shows up within one second, skip. + private string _lastSystemText = ""; + private DateTime _lastSystemAt = DateTime.MinValue; + private static readonly TimeSpan SystemDedupWindow = TimeSpan.FromSeconds(1); + public ChatLog(int maxEntries = 500) { if (maxEntries < 1) throw new ArgumentOutOfRangeException(nameof(maxEntries)); @@ -178,9 +189,28 @@ public sealed class ChatLog ChannelId: 0)); } - /// GameEvent CommunicationTransientString (0x02EB) — e.g. "Your spell fizzled!" + /// + /// System chat — covers GameMessageSystemChat (0xF7E0 + /// ServerMessage) and GameEventCommunicationTransientString + /// (0x02EB). Phase J follow-up: dedupe identical text arriving + /// within so flows that fire on + /// both opcodes (e.g. "Unknown command: help" via help-command + /// failure path) only show once. + /// public void OnSystemMessage(string text, uint chatType) { + var now = DateTime.UtcNow; + if (text == _lastSystemText && (now - _lastSystemAt) < SystemDedupWindow) + { + // Suppress the dup — the wire-level duplicate isn't a + // user-meaningful signal. Reset the timer so a long burst + // of the same text still skips. + _lastSystemAt = now; + return; + } + _lastSystemText = text; + _lastSystemAt = now; + Append(new ChatEntry( Kind: ChatKind.System, Sender: "", diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs index 079d142..8d3846b 100644 --- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -68,6 +68,9 @@ public static class WeenieErrorMessages /// private static readonly Dictionary NoParamTemplates = new() { + // Command parser + [0x0026] = "That is not a valid command.", // ThatIsNotAValidCommand + // Tell-related [0x052B] = "That person is not available now.", // CharacterNotAvailable diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index 16633e2..4c90dd2 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -94,7 +94,20 @@ public sealed class ChatPanel : IPanel if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) && submitted is not null) { - var parsed = ChatInputParser.Parse(submitted.Trim(), ChatChannelKind.Say, _vm.LastIncomingTellSender); + 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.End(); + return; + } + + var parsed = ChatInputParser.Parse(trimmed, ChatChannelKind.Say, _vm.LastIncomingTellSender); if (parsed is { } p) { ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); @@ -119,4 +132,56 @@ public sealed class ChatPanel : IPanel CombatLineKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1.0f), _ => 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; + + if (trimmed.Equals("/help", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("/?", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("/h", StringComparison.OrdinalIgnoreCase)) + { + _vm.ShowSystemMessage(BuildHelpText()); + return true; + } + + if (trimmed.Equals("/clear", StringComparison.OrdinalIgnoreCase) || + trimmed.Equals("/cls", StringComparison.OrdinalIgnoreCase)) + { + _vm.Clear(); + 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() => + "acdream chat commands:\n" + + " /say (default), /tell , /reply, /general, /trade,\n" + + " /fellowship, /allegiance, /patron, /vassals, /monarch,\n" + + " /covassals, /lfg, /roleplay, /society, /olthoi\n" + + " /help (this), /clear (clear chat tail)\n" + + "ACE server commands: prefix with @ (e.g. @acehelp, @acecommands)."; } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index ade923d..0099e12 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -73,6 +73,18 @@ public sealed class ChatVM LastIncomingTellSender = entry.Sender; } + /// + /// Append a client-side system line to the chat log. Used by + /// client-handled commands (/help, /clear, future) to surface + /// local feedback without round-tripping the server. + /// + public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0); + + /// + /// Drain the chat log. Used by the /clear client-side command. + /// + public void Clear() => _log.Clear(); + /// /// Snapshot the tail of the chat log, formatted as display strings, /// oldest-first. Never returns null; returns an empty array if the diff --git a/tests/AcDream.Core.Tests/Chat/ChatLogSystemDedupTests.cs b/tests/AcDream.Core.Tests/Chat/ChatLogSystemDedupTests.cs new file mode 100644 index 0000000..f6247f8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Chat/ChatLogSystemDedupTests.cs @@ -0,0 +1,61 @@ +using AcDream.Core.Chat; + +namespace AcDream.Core.Tests.Chat; + +/// +/// Phase J follow-up: ACE often sends the same system text via two +/// wire paths (GameMessageSystemChat 0xF7E0 + GameEventCommunication- +/// TransientString 0x02EB) for back-compat — both get routed to +/// in our wiring, which would +/// double-print every system line. Dedupe consecutive identical +/// system text within a short window. +/// +public sealed class ChatLogSystemDedupTests +{ + [Fact] + public void OnSystemMessage_ImmediateDuplicate_OnlyOneEntry() + { + var log = new ChatLog(); + log.OnSystemMessage("Unknown command: help", chatType: 0); + log.OnSystemMessage("Unknown command: help", chatType: 0); + + // Second identical message arrived <1s later — suppressed. + Assert.Single(log.Snapshot()); + } + + [Fact] + public void OnSystemMessage_DifferentText_BothEntries() + { + var log = new ChatLog(); + log.OnSystemMessage("Welcome to Asheron's Call", chatType: 0); + log.OnSystemMessage("Use @acecommands to get a complete list of commands.", chatType: 0); + + // Different text — both retained even back-to-back. + Assert.Equal(2, log.Snapshot().Length); + } + + [Fact] + public void OnSystemMessage_TripletDuplicate_StillOnlyOneEntry() + { + var log = new ChatLog(); + log.OnSystemMessage("Unknown command: help", chatType: 0); + log.OnSystemMessage("Unknown command: help", chatType: 0); + log.OnSystemMessage("Unknown command: help", chatType: 0); + + Assert.Single(log.Snapshot()); + } + + [Fact] + public void OnSystemMessage_DuplicateBookendingNonDuplicate_KeepsBothUnique() + { + var log = new ChatLog(); + log.OnSystemMessage("Unknown command: help", chatType: 0); + log.OnSystemMessage("Welcome to Asheron's Call", chatType: 0); + log.OnSystemMessage("Unknown command: help", chatType: 0); + + // First "Unknown..." retained, "Welcome..." retained, second + // "Unknown..." is no longer the *immediate* duplicate (the + // most-recent entry is "Welcome..."), so it's kept too. + Assert.Equal(3, log.Snapshot().Length); + } +} diff --git a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs index edf47fc..1c49f4c 100644 --- a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs +++ b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs @@ -69,6 +69,33 @@ public sealed class WeenieErrorMessagesTests Assert.Equal("Trade Complete!", WeenieErrorMessages.Format(0x0529, null)); } + [Fact] + public void Format_ThatIsNotAValidCommand() + { + // 0x0026 fires on /-prefixed text that ACE's command parser + // can't resolve. Filed after a 2026-04-25 trace where /help + // produced cryptic "WeenieError 0x0026" lines. + Assert.Equal( + "That is not a valid command.", + WeenieErrorMessages.Format(0x0026, null)); + } + + [Fact] + public void Format_YouAreNotInAllegiance() + { + Assert.Equal( + "You are not in an allegiance!", + WeenieErrorMessages.Format(0x0414, null)); + } + + [Fact] + public void Format_YouDoNotBelongToAFellowship() + { + Assert.Equal( + "You do not belong to a Fellowship.", + WeenieErrorMessages.Format(0x050F, null)); + } + // ── unknown codes — graceful fallback preserves debug info ─────── [Fact] diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 8e32f71..0c12a84 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -18,6 +18,75 @@ public sealed class ChatPanelInputTests public void Publish(T command) where T : notnull => Published.Add(command); } + [Fact] + public void Submit_HelpCommand_RendersLocalHelpAndDoesNotPublish() + { + // Phase J follow-up: client-side commands (/help, /?, /h) are + // intercepted before the parser. They render a local cheat-sheet + // via ChatLog.OnSystemMessage and do NOT round-trip the server + // — that's what prevented the "Unknown command: help" duplicate + // ACE was firing back. + var log = new ChatLog(); + var vm = new ChatVM(log); + var panel = new ChatPanel(vm); + var bus = new RecordingBus(); + var renderer = new FakePanelRenderer + { + InputTextSubmitNextSubmitted = "/help", + 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("acdream chat commands:", entry.Text); + Assert.Contains("/tell", entry.Text); + } + + [Theory] + [InlineData("/?")] + [InlineData("/h")] + [InlineData("/HELP")] + public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw) + { + 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); + Assert.Single(log.Snapshot()); + } + + [Fact] + public void Submit_ClearCommand_DrainsLog_AndDoesNotPublish() + { + var log = new ChatLog(); + log.OnSystemMessage("seed line", chatType: 0); + var vm = new ChatVM(log); + var panel = new ChatPanel(vm); + var bus = new RecordingBus(); + var renderer = new FakePanelRenderer + { + InputTextSubmitNextSubmitted = "/clear", + InputTextSubmitNextBufferAfter = "", + }; + + panel.Render(new PanelContext(0.016f, bus), renderer); + + Assert.Empty(bus.Published); + Assert.Empty(log.Snapshot()); + } + [Fact] public void Submit_PlainText_PublishesSayCommand() {