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