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:
parent
3501194083
commit
a316d6359c
7 changed files with 497 additions and 22 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,11 @@ public sealed class ChatPanel : IPanel
|
|||
return;
|
||||
}
|
||||
|
||||
var parsed = ChatInputParser.Parse(trimmed, ChatChannelKind.Say, _vm.LastIncomingTellSender);
|
||||
var parsed = ChatInputParser.Parse(
|
||||
trimmed,
|
||||
ChatChannelKind.Say,
|
||||
_vm.LastIncomingTellSender,
|
||||
_vm.LastOutgoingTellTarget);
|
||||
if (parsed is { } p)
|
||||
{
|
||||
ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text));
|
||||
|
|
@ -154,21 +158,44 @@ public sealed class ChatPanel : IPanel
|
|||
{
|
||||
if (trimmed.Length == 0) return false;
|
||||
|
||||
if (trimmed.Equals("/help", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Equals("/?", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Equals("/h", StringComparison.OrdinalIgnoreCase))
|
||||
// /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence.
|
||||
if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
|
||||
{
|
||||
_vm.ShowSystemMessage(BuildHelpText());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trimmed.Equals("/clear", StringComparison.OrdinalIgnoreCase) ||
|
||||
trimmed.Equals("/cls", StringComparison.OrdinalIgnoreCase))
|
||||
// /clear, /cls — also @clear, @cls.
|
||||
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
|
||||
{
|
||||
_vm.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
// /framerate — also @framerate. Prints current FPS to chat.
|
||||
if (EqAny(trimmed, "/framerate", "@framerate"))
|
||||
{
|
||||
_vm.ShowFps();
|
||||
return true;
|
||||
}
|
||||
|
||||
// /loc — also @loc. Prints current player position to chat.
|
||||
// ACE has a server-side @loc too; client-side wins here
|
||||
// (instantaneous + uses our local interpolated position).
|
||||
if (EqAny(trimmed, "/loc", "@loc"))
|
||||
{
|
||||
_vm.ShowLocation();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Case-insensitive multi-string equality test.</summary>
|
||||
private static bool EqAny(string s, params string[] options)
|
||||
{
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -178,10 +205,11 @@ public sealed class ChatPanel : IPanel
|
|||
/// 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).";
|
||||
"Note: / and @ are equivalent prefixes.\n" +
|
||||
"Chat: /say (default), /tell <name>, /reply, /retell\n" +
|
||||
"Channels: /general /trade /fellowship /allegiance\n" +
|
||||
" /patron /vassals /monarch /covassals\n" +
|
||||
" /lfg /roleplay /society /olthoi\n" +
|
||||
"Client: /help (this) /clear /framerate /loc\n" +
|
||||
"Server: type @acehelp or @acecommands for ACE's full list.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <name> …</c>). Drives the <c>/retell <msg></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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue