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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 21:22:07 +02:00
parent 7726f62528
commit 3501194083
7 changed files with 269 additions and 2 deletions

View file

@ -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));
}
/// <summary>GameEvent CommunicationTransientString (0x02EB) — e.g. "Your spell fizzled!"</summary>
/// <summary>
/// System chat — covers GameMessageSystemChat (0xF7E0
/// ServerMessage) and GameEventCommunicationTransientString
/// (0x02EB). Phase J follow-up: dedupe identical text arriving
/// within <see cref="SystemDedupWindow"/> so flows that fire on
/// both opcodes (e.g. "Unknown command: help" via help-command
/// failure path) only show once.
/// </summary>
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: "",

View file

@ -68,6 +68,9 @@ public static class WeenieErrorMessages
/// </summary>
private static readonly Dictionary<uint, string> NoParamTemplates = new()
{
// Command parser
[0x0026] = "That is not a valid command.", // ThatIsNotAValidCommand
// Tell-related
[0x052B] = "That person is not available now.", // CharacterNotAvailable

View file

@ -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),
};
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Recognised client-side commands:
/// <list type="bullet">
/// <item><c>/help</c>, <c>/?</c>, <c>/h</c> — 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.</item>
/// <item><c>/clear</c>, <c>/cls</c> — drain the chat log so the
/// panel starts empty.</item>
/// </list>
/// </remarks>
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;
}
/// <summary>
/// Multi-line cheat-sheet text rendered by <c>/help</c>. ImGui's
/// <c>Text</c> path flows embedded newlines naturally so this lands
/// as one ChatLog entry that visually wraps to several lines.
/// </summary>
private static string BuildHelpText() =>
"acdream chat commands:\n" +
" /say (default), /tell <name>, /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).";
}

View file

@ -73,6 +73,18 @@ public sealed class ChatVM
LastIncomingTellSender = entry.Sender;
}
/// <summary>
/// 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.
/// </summary>
public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0);
/// <summary>
/// Drain the chat log. Used by the /clear client-side command.
/// </summary>
public void Clear() => _log.Clear();
/// <summary>
/// Snapshot the tail of the chat log, formatted as display strings,
/// oldest-first. Never returns null; returns an empty array if the