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:
parent
7726f62528
commit
3501194083
7 changed files with 269 additions and 2 deletions
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
61
tests/AcDream.Core.Tests/Chat/ChatLogSystemDedupTests.cs
Normal file
61
tests/AcDream.Core.Tests/Chat/ChatLogSystemDedupTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.Core.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="ChatLog.OnSystemMessage"/> in our wiring, which would
|
||||
/// double-print every system line. Dedupe consecutive identical
|
||||
/// system text within a short window.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,75 @@ public sealed class ChatPanelInputTests
|
|||
public void Publish<T>(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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue