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

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
@ -42,6 +43,32 @@ public sealed class ChatVM
/// </summary>
public string? LastIncomingTellSender { get; private set; }
/// <summary>
/// Target of the most recent OUTGOING Tell (the player's own
/// <c>/tell &lt;name&gt; …</c>). Drives the <c>/retell &lt;msg&gt;</c>
/// (or <c>@retell</c>) slash command, which resends to the same
/// target. Mirrors retail's <c>@retell</c>. Self-sent echoes flow
/// through <see cref="ChatLog.OnSelfSent"/> with
/// <c>SenderGuid == 0</c> and the target name in <c>Sender</c> —
/// that's the discriminator we capture here.
/// </summary>
public string? LastOutgoingTellTarget { get; private set; }
/// <summary>
/// Optional callback exposing the live framerate. Wired by
/// <c>GameWindow</c> at construction so the client-side
/// <c>/framerate</c> command can print "Framerate: 144.2 FPS"
/// into chat without the panel knowing about the render-loop.
/// </summary>
public Func<float>? FpsProvider { get; init; }
/// <summary>
/// Optional callback exposing the local player's world position.
/// Used by <c>/loc</c> to print
/// "Location: (123.4, 567.8, 60.0)". Wired by <c>GameWindow</c>.
/// </summary>
public Func<Vector3>? PositionProvider { get; init; }
/// <summary>
/// Build a ChatVM bound to a <see cref="ChatLog"/> instance.
/// </summary>
@ -62,15 +89,17 @@ public sealed class ChatVM
private void OnEntryAppended(ChatEntry entry)
{
// Only INCOMING tells update the reply target. Self-sent
// echoes from OnSelfSent always carry SenderGuid == 0 (we
// never know our own guid here), so guid == 0 is a safe
// discriminator for "skip this one". An incoming tell from
// a player always carries the sender's real guid.
// Tell-tracking discriminator (SenderGuid):
// != 0 = real incoming whisper; capture sender for /reply.
// == 0 = our own outgoing echo via OnSelfSent; capture the
// target (Sender field) for /retell.
if (entry.Kind != ChatKind.Tell) return;
if (entry.SenderGuid == 0) return;
if (string.IsNullOrEmpty(entry.Sender)) return;
LastIncomingTellSender = entry.Sender;
if (entry.SenderGuid != 0)
LastIncomingTellSender = entry.Sender;
else
LastOutgoingTellTarget = entry.Sender;
}
/// <summary>
@ -85,6 +114,34 @@ public sealed class ChatVM
/// </summary>
public void Clear() => _log.Clear();
/// <summary>
/// Print the current framerate into chat. Used by
/// <c>/framerate</c> / <c>@framerate</c>. Falls back to a
/// helpful diagnostic line if no <see cref="FpsProvider"/>
/// is wired (test / pre-live-session scenarios).
/// </summary>
public void ShowFps()
{
var fps = FpsProvider?.Invoke();
ShowSystemMessage(fps is null
? "Framerate: (provider unavailable)"
: $"Framerate: {fps.Value:F1} FPS");
}
/// <summary>
/// Print the local player's world position into chat. Used by
/// <c>/loc</c> / <c>@loc</c>. Falls back to a helpful
/// diagnostic line if no <see cref="PositionProvider"/> is
/// wired (pre-EnterWorld / tests).
/// </summary>
public void ShowLocation()
{
var pos = PositionProvider?.Invoke();
ShowSystemMessage(pos is null
? "Location: (provider unavailable)"
: $"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})");
}
/// <summary>
/// Snapshot the tail of the chat log, formatted as display strings,
/// oldest-first. Never returns null; returns an empty array if the