fix(chat): / and @ are equivalent command prefixes (retail parity)

Retail treats / and @ interchangeably for commands, but two client-side
layers broke the / spelling for server commands:

- ChatCommandRouter refused ANY unknown /verb with a local "Unknown
  command" guess — so /tele, /ci, /acehelp never reached ACE even
  though ACE supports them. The guard is gone; the server is now the
  single authority on what's a valid command (ACE replies "Unknown
  command: x" itself).
- ChatInputParser passed unknown /verbs through as literal speech.
  ACE's GameActionTalk only intercepts the @ form on the wire (the /
  acceptance in CommandManager is the server CONSOLE path), so the
  parser now rewrites unknown /xyz -> @xyz.

Both command pass-throughs (@ and rewritten /) now also force the Say
channel: GameActionTalk (0x0015) is the only wire action ACE parses
commands on — previously a command typed with a chat channel active
would broadcast as channel speech.

Phase J Tier 4 ("/-text must never broadcast as speech") still holds:
letter-verbed input goes out as an @command (never speech), and
command-shaped-but-verbless input ("/", "//shrug") is refused locally
by a narrow router guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 09:46:50 +02:00
parent 59ea3252cd
commit d3cab1ab10
5 changed files with 141 additions and 51 deletions

View file

@ -2,7 +2,11 @@ using System;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>What a submit did, so the caller can clear its input + give feedback.</summary>
/// <summary>What a submit did, so the caller can clear its input + give feedback.
/// <c>UnknownCommand</c> is now produced only for command-SHAPED but verbless
/// input ("/", "//x", "@ x"); real unknown verbs route to the server (retail
/// treats / and @ as equivalent command prefixes, and ACE answers unknown
/// commands itself).</summary>
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
/// <summary>
@ -10,9 +14,15 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
/// chat window route through here so command handling stays in one place.
///
/// Order mirrors the prior inline <see cref="ChatPanel"/> flow:
/// client-command intercept → unknown-slash-verb guard → <see cref="ChatInputParser.Parse"/>
/// → <c>Publish(SendChatCmd)</c>.
/// Flow: client-command intercept → degenerate-prefix guard →
/// <see cref="ChatInputParser.Parse"/> → <c>Publish(SendChatCmd)</c>.
/// Unknown slash/at verbs pass through the parser to the server (the parser
/// rewrites <c>/xyz</c> → <c>@xyz</c> and forces the Say action, the only
/// wire path ACE parses commands on); ACE replies "Unknown command: xyz"
/// for verbs it doesn't know, so the server stays the single authority on
/// what's a valid command. Prefix text with no letter verb is refused
/// locally — /-prefixed input must NEVER broadcast as speech (Phase J
/// Tier 4, from the 2026-04-25 "/ls echoed as speech" trace).
/// </summary>
public static class ChatCommandRouter
{
@ -26,15 +36,15 @@ public static class ChatCommandRouter
if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled;
if (trimmed[0] == '/')
// Command-shaped but no letter verb ("/", "//shrug", "@ x"): refuse
// locally. Rewriting to @ would put junk on the wire, and letting it
// fall through would broadcast /-text as speech (Tier-4 violation).
if (trimmed[0] is '/' or '@'
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
{
var verb = ChatInputParser.GetVerbToken(trimmed);
if (!ChatInputParser.IsKnownVerb(verb))
{
vm.ShowSystemMessage(
$"Unknown command: {verb}. Type /help for the list of supported commands.");
return SubmitOutcome.UnknownCommand;
}
vm.ShowSystemMessage(
$"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands.");
return SubmitOutcome.UnknownCommand;
}
var parsed = ChatInputParser.Parse(

View file

@ -16,11 +16,12 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// <item><c>/say</c> with no message → <c>null</c></item>
/// <item><c>/t</c> with no target / no message → <c>null</c></item>
/// <item><c>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
/// <item>unknown <c>/xyz</c> verb → fall through to default channel
/// carrying the literal text (matches holtburger
/// <c>handle_slash_command</c> default arm at line ~744 — they
/// send <c>ClientCommand::Talk(command)</c> with the original
/// slash-prefixed string)</item>
/// <item>unknown <c>/xyz</c> verb → rewritten to <c>@xyz</c> and passed
/// through on the default channel. Retail treats / and @ as
/// equivalent command prefixes; ACE's <c>GameActionTalk</c> only
/// intercepts the <c>@</c> form on the wire. Deliberate divergence
/// from holtburger's literal fall-through (which would SAY the
/// command text out loud).</item>
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
/// </list>
@ -122,8 +123,12 @@ public static class ChatInputParser
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);
// it server-side. Always emit as Say: ACE's GameActionTalk
// (the 0x0015 Talk action) is the ONLY wire path that parses
// @commands — on a chat channel the text would broadcast as
// ordinary channel speech. Retail likewise resolves commands
// before channel routing.
return new ParsedInput(ChatChannelKind.Say, null, trimmed);
}
// /say <msg>
@ -165,11 +170,18 @@ public static class ChatInputParser
return null;
}
// Unknown slash command: holtburger falls back to Talk(literal).
// We mirror that — emit on the default channel with the
// slash-prefixed text intact so the server treats it as speech.
// No special-case for known-but-unimplemented verbs; the user's
// text round-trips so their intent isn't silently dropped.
// Unknown slash verb: retail treats / and @ as equivalent command
// prefixes, but ACE's GameActionTalk only intercepts the @ form on
// the wire (a /-prefixed Talk is plain speech server-side). Rewrite
// to @ and emit as Say — same reasoning as the unknown-@ branch at
// the top: only the Talk action parses commands. (Deliberate
// divergence from holtburger's literal fall-through, which would
// SAY "/ci 629" out loud.)
if (trimmed.Length > 1 && trimmed[0] == '/' && char.IsLetter(trimmed[1]))
return new ParsedInput(ChatChannelKind.Say, null, "@" + trimmed.Substring(1));
// Plain speech (no recognized verb): emit on the default channel
// so the user's text round-trips instead of being silently dropped.
return new ParsedInput(defaultChannel, null, trimmed);
}