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

@ -191,18 +191,46 @@ public sealed class ChatInputParserTests
Assert.Null(ChatInputParser.Parse("/a", ChatChannelKind.Say, lastTellSender: null));
}
// -- Unknown slash command: holtburger fall-through to Talk(literal)
// ("/wave hello" → Talk("/wave hello") + treat-as-Say). See
// chat.rs::handle_slash_command default arm at line ~744.
// -- Unknown slash command: retail / ≡ @ equivalence. ACE's
// GameActionTalk only intercepts @-prefixed Talk on the wire, so
// the parser rewrites the unknown /-verb to its @ form for the
// server's CommandManager. (Deliberate divergence from holtburger's
// literal fall-through, which would speak the command out loud.)
[Fact]
public void UnknownSlashCommand_FallsBackToDefaultChannelWithLiteralText()
public void UnknownSlashCommand_IsRewrittenToAtFormForServer()
{
var parsed = ChatInputParser.Parse("/xyz hello", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal("/xyz hello", parsed.Value.Text);
Assert.Equal("@xyz hello", parsed.Value.Text);
}
[Theory]
[InlineData("/ci 629", "@ci 629")] // ACE admin: create inventory item
[InlineData("/tele holtburg", "@tele holtburg")] // ACE admin: teleport
[InlineData("@ci 629", "@ci 629")] // @ form already server-ready — untouched
[InlineData("@acehelp", "@acehelp")]
public void ServerCommandVerbs_ReachTheWireInAtForm(string input, string expectedText)
{
var parsed = ChatInputParser.Parse(input, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Equal(expectedText, parsed.Value.Text);
}
[Fact]
public void SlashWithoutLetterVerb_StaysLiteralAtParseLevel()
{
// "/ hello" and "//shrug" have no letter verb after the slash, so
// the pure Parse function leaves them untouched. In the real
// submit flows they never reach the wire: ChatCommandRouter's
// degenerate-prefix guard refuses them locally (Phase J Tier 4 —
// /-prefixed text must never broadcast as speech).
Assert.Equal("/ hello", ChatInputParser.Parse("/ hello", ChatChannelKind.Say, lastTellSender: null)!.Value.Text);
Assert.Equal("//shrug", ChatInputParser.Parse("//shrug", ChatChannelKind.Say, lastTellSender: null)!.Value.Text);
}
// -- Default-channel parameter is honoured when no prefix is given --
@ -227,17 +255,14 @@ public sealed class ChatInputParserTests
{
// Phase J added long-form aliases (/general, /allegiance,
// /patron, etc.). The exact-token rule still applies — a
// verb prefix that ISN'T one of the listed aliases falls
// through. The Parse-level behaviour for unknown /-verbs is
// still "Say with the literal text" (matches holtburger);
// the ChatPanel layer is what catches unknowns and shows the
// local "Unknown command" line. ChatPanelInputTests cover
// that end-to-end behaviour.
// verb prefix that ISN'T one of the listed aliases is NOT a
// client verb; it routes to the server in @ form like any
// other unknown verb (retail / ≡ @).
var parsed = ChatInputParser.Parse("/genio public", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Equal("/genio public", parsed.Value.Text);
Assert.Equal("@genio public", parsed.Value.Text);
}
[Theory]