feat(chat): Phase J Tier 1+2 - @ verb prefix, /retell, /framerate, /loc

Three-tier rollout per the 2026-04-25 retail @help dump showing the
full ACE command surface. Tier 1 + most of Tier 2 in one commit.

TIER 1 - @ as / equivalent

ACE accepts both / and @ as verb prefixes (per its own help text:
"Note: You may substitute a forward slash (/) for the at symbol
(@)."). ChatInputParser now normalises @ to / for the verb-match
phase and re-enters parsing. Critical: for verbs we don't recognise
(@acehelp, @tele, @die, @version, @loc-on-server, @nonsense, ...),
the original @ is kept in the message text so ACE's CommandManager
intercepts the message server-side. If we substituted / there too,
ACE would treat it as plain Talk and broadcast it.

Result: @a hi / @tell Bob hi / @help / @clear / @reply / @retell
all route exactly like their / counterparts. @acehelp / @tele /
@version / @die etc. pass through to the server intact.

TIER 2 - client-only commands

- /retell <msg> (also @retell): resend to the last person you
  tell'd. Mirrors retail @retell. ChatVM tracks
  LastOutgoingTellTarget on each OnSelfSent(Tell, ...) entry —
  SenderGuid==0 distinguishes outgoing echo from inbound whispers,
  same way LastIncomingTellSender already worked. ChatInputParser
  takes a new optional lastOutgoingTellTarget param.

- /framerate (also @framerate): prints "Framerate: 144.2 FPS"
  into chat. Wired via a new ChatVM.FpsProvider Func<float>
  callback set by GameWindow at construction (closes over
  _lastFps). Falls back to "(provider unavailable)" if no
  callback is wired (tests / pre-live).

- /loc (also @loc): prints "Location: (123.4, 567.8, 60.0)" into
  chat. Wired via ChatVM.PositionProvider Func<Vector3> closing
  over GetDebugPlayerPosition() in GameWindow. ACE has a server-
  side @loc too; client wins here (instantaneous + uses the local
  interpolated position).

ChatPanel.TryHandleClientCommand grew @ aliases for /help /clear
/framerate /loc and the new EqAny helper for case-insensitive
multi-string matching. Help text rewritten to reference the
/ <-> @ equivalence and point at @acehelp / @acecommands for ACE's
full command list.

TIER 3 - automatic (no code)

Most retail @-commands (@allegiance motd, @afk, @die, @lifestone,
@corpse, @marketplace, @pkarena, @emote/@emotes, @fillcomps,
@permit, @consent, @squelch, @unsquelch, @messagetypes, @age,
@birth, @day, @endurance, @pklite, @version, @filter, @unfilter,
@loadfile, @log, @marketplace, ...) are server-side ACE commands.
Tier 1's passthrough takes care of them automatically — they
arrive via Talk, ACE recognises the @ and intercepts, replies via
SystemChat (which our 0xF7E0 wiring renders as [System] lines).

DEFERRED

- @saveui / @loadui / @lockui: ImGui layout save/load, ~1 hr
  standalone task. Filed for follow-up.
- @title <text>: rename chat window. ImGui window-id complications.
- Toggle-style @framerate (FPS overlay on/off): print-once is
  simpler and matches retail's most-common usage.

30 new tests:
- ChatInputParserAtPrefixTests: 11 covering @-prefix recognition,
  unknown-@ passthrough, /retell and @retell.
- ChatVMRetellAndProvidersTests: 8 covering LastOutgoingTellTarget
  tracking, FpsProvider/PositionProvider callbacks, no-provider
  fallback.
- ChatPanelInputTests: +3 (/framerate, @loc, @acehelp passthrough).

Solution total: 1063 green (243 Core.Net + 160 UI + 660 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 21:34:13 +02:00
parent 3501194083
commit a316d6359c
7 changed files with 497 additions and 22 deletions

View file

@ -42,6 +42,10 @@ public static class ChatInputParser
private static readonly string[] SayAliases = { "/say", "/s" };
private static readonly string[] TellAliases = { "/tell", "/t" };
private static readonly string[] ReplyAliases = { "/reply", "/r" };
// Phase J Tier 2: /retell <msg> — resend to last person YOU tell'd.
// Mirrors retail's @retell. Distinct from /reply which targets the
// last person who tell'd US.
private static readonly string[] RetellAliases = { "/retell" };
// Channel aliases. Each maps a single verb token to a channel kind.
// The same list drives both the verb test and the prefix-strip.
@ -90,11 +94,38 @@ public static class ChatInputParser
/// <param name="lastTellSender">Sender of the most recent incoming
/// Tell, used to resolve <c>/r</c> reply target. Null if no Tell
/// has arrived this session.</param>
public static ParsedInput? Parse(string raw, ChatChannelKind defaultChannel, string? lastTellSender)
/// <param name="lastOutgoingTellTarget">Target of the most recent
/// outgoing Tell, used to resolve <c>/retell</c>. Null if the
/// player hasn't sent a Tell yet this session.</param>
public static ParsedInput? Parse(
string raw,
ChatChannelKind defaultChannel,
string? lastTellSender,
string? lastOutgoingTellTarget = null)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var trimmed = raw.Trim();
// Phase J Tier 1: ACE accepts both / and @ as equivalent verb
// prefixes (per ACE help: "Note: You may substitute a forward
// slash (/) for the at symbol (@)."). For verbs WE recognize,
// normalize @ to / and re-enter parsing. For unknown @-verbs
// (e.g. @acehelp, @tele, @die), pass the literal @-prefixed
// text through to the default channel so ACE's CommandManager
// server-side handler intercepts it.
if (trimmed.StartsWith("@"))
{
string substituted = "/" + trimmed.Substring(1);
string verb = ExtractVerb(substituted);
if (AllKnownVerbs.Contains(verb))
{
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
}
// Unknown @-verb — keep the original @ so ACE recognizes
// it server-side when the message arrives via Talk.
return new ParsedInput(defaultChannel, null, trimmed);
}
// /say <msg>
if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg))
return new ParsedInput(ChatChannelKind.Say, null, sayMsg);
@ -116,6 +147,15 @@ public static class ChatInputParser
if (IsBareVerb(trimmed, ReplyAliases))
return null;
// /retell <msg> — needs prior outgoing tell.
if (TryParseMessageOnly(trimmed, RetellAliases, out var retellMsg))
{
if (string.IsNullOrEmpty(lastOutgoingTellTarget)) return null;
return new ParsedInput(ChatChannelKind.Tell, lastOutgoingTellTarget, retellMsg);
}
if (IsBareVerb(trimmed, RetellAliases))
return null;
// Channel verbs (/g, /f, /a, ...).
foreach (var (verb, channel) in ChannelVerbs)
{
@ -232,4 +272,36 @@ public static class ChatInputParser
if (aliases[i] == verb) return true;
return false;
}
/// <summary>
/// Pull the first whitespace-separated token from <paramref name="command"/>.
/// Used by the @-prefix normalization to know whether the verb is
/// one we route locally (Tell / Channel / etc.) or an unknown
/// command that should pass through to the server intact.
/// </summary>
private static string ExtractVerb(string command)
{
int ws = IndexOfWhitespace(command);
return ws < 0 ? command : command.Substring(0, ws);
}
/// <summary>
/// Union of every alias verb (with leading <c>/</c>) the parser
/// recognises. Used by the <c>@</c>-prefix passthrough decision:
/// if the substituted verb is in this set, we normalize to the
/// <c>/</c> path; otherwise the original <c>@</c>-prefixed text
/// is preserved so ACE's command handler sees it.
/// </summary>
private static readonly HashSet<string> AllKnownVerbs = BuildKnownVerbs();
private static HashSet<string> BuildKnownVerbs()
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var v in SayAliases) set.Add(v);
foreach (var v in TellAliases) set.Add(v);
foreach (var v in ReplyAliases) set.Add(v);
foreach (var v in RetellAliases) set.Add(v);
foreach (var (v, _) in ChannelVerbs) set.Add(v);
return set;
}
}