fix(chat): block / unknown commands from broadcasting as speech

User reported typing /ls (a command-style request, not chat) gets
echoed by the server as "You say, \"/ls\"". Slash-prefix is a
COMMAND surface, never a chat surface. Filed after the same flow
that produced @help and the welcome-message work.

Behavior change at the ChatPanel submit layer:
- Any /-prefixed input whose verb isn't in our alias tables now
  renders a local "[System] Unknown command: /foo. Type /help for
  the list." line and is NEVER published to the bus. No SendChatCmd,
  no Talk packet. The server never sees /foo.
- Known /-verbs (/say /tell /reply /retell /general /allegiance
  /patron /vassals /monarch /covassals /fellowship /lookingforgroup
  /trade /roleplay /society /olthoi /help /clear /framerate /loc
  and friends) still flow through ChatInputParser.Parse → SendChatCmd
  exactly as before.
- @-prefix unchanged: ACE's CommandManager handles unknown @ verbs
  server-side and replies via SystemChat ("Unknown command: foo")
  per ACE GameActionTalk.cs:21. Our @ -> / normalization for known
  verbs (Phase J Tier 1) and the @-passthrough fallthrough for
  unknown verbs both still apply.

ChatInputParser now exposes:
- IsKnownVerb(string verb): query against the union of every alias
  table. Used by ChatPanel to discriminate "unknown verb" from
  "known verb with bad args".
- GetVerbToken(string command): public alias of the existing
  ExtractVerb so callers can pull the first whitespace token without
  reproducing the helper.

Parse itself is unchanged — its existing fall-through (Say with
literal text) still applies for unknown /-verbs called directly via
the parser, but ChatPanel intercepts before reaching that path so
the fall-through never fires through the live submit pipeline. Tests
that directly call Parse continue to pass; the new ChatPanel-level
tests pin the unknown-command rejection.

19 new tests:
- ChatInputParserTests: 10 IsKnownVerb Theory cases + 4 GetVerbToken
  Theory cases.
- ChatPanelInputTests: 5 Theory cases for Submit_UnknownSlashCommand
  covering /foo, /ls, /mp <path>, /genio, and bare /.

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

Acceptance: type /ls, /mp /path, /anything-not-known — see local
"[System] Unknown command: /xxx. Type /help for the list of
supported commands." Nothing reaches the wire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 21:49:56 +02:00
parent a44488e277
commit 579cbfb48b
4 changed files with 101 additions and 2 deletions

View file

@ -304,4 +304,21 @@ public static class ChatInputParser
foreach (var (v, _) in ChannelVerbs) set.Add(v); foreach (var (v, _) in ChannelVerbs) set.Add(v);
return set; return set;
} }
/// <summary>
/// Returns true if <paramref name="verb"/> (with leading
/// <c>/</c>) is one this parser routes — used by callers that
/// need to distinguish "unknown slash command" from "known
/// verb with bad arguments" without reproducing the alias
/// tables. <c>@</c>-prefixed verbs need to be normalized to
/// <c>/</c> before passing.
/// </summary>
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(verb);
/// <summary>
/// Pull the first whitespace-separated token (the command verb)
/// from <paramref name="command"/>. Returns the entire string if
/// there is no whitespace.
/// </summary>
public static string GetVerbToken(string command) => ExtractVerb(command);
} }

View file

@ -131,6 +131,27 @@ public sealed class ChatPanel : IPanel
return; return;
} }
// Phase J Tier 4: any /-prefixed input that ISN'T one of our
// known verbs gets a local "Unknown command" message instead
// of being broadcast to the server as plain speech. The
// user reported "/ls" / "/mp /path" leaking out as chat —
// a / prefix is a command, never speech. (@-prefixed unknown
// verbs still pass through to ACE because ACE's
// CommandManager intercepts @ server-side and replies with
// its own "Unknown command" / valid command output.)
if (trimmed.Length > 0 && trimmed[0] == '/')
{
string verb = ChatInputParser.GetVerbToken(trimmed);
if (!ChatInputParser.IsKnownVerb(verb))
{
_vm.ShowSystemMessage(
$"Unknown command: {verb}. Type /help for the list of supported commands.");
_input = string.Empty;
renderer.End();
return;
}
}
var parsed = ChatInputParser.Parse( var parsed = ChatInputParser.Parse(
trimmed, trimmed,
ChatChannelKind.Say, ChatChannelKind.Say,

View file

@ -228,8 +228,11 @@ public sealed class ChatInputParserTests
// Phase J added long-form aliases (/general, /allegiance, // Phase J added long-form aliases (/general, /allegiance,
// /patron, etc.). The exact-token rule still applies — a // /patron, etc.). The exact-token rule still applies — a
// verb prefix that ISN'T one of the listed aliases falls // verb prefix that ISN'T one of the listed aliases falls
// through to the default channel. "/genio" is not /g, /general, // through. The Parse-level behaviour for unknown /-verbs is
// or /gen — must stay as Say carrying the literal text. // 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.
var parsed = ChatInputParser.Parse("/genio public", ChatChannelKind.Say, lastTellSender: null); var parsed = ChatInputParser.Parse("/genio public", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed); Assert.NotNull(parsed);
@ -237,6 +240,32 @@ public sealed class ChatInputParserTests
Assert.Equal("/genio public", parsed.Value.Text); Assert.Equal("/genio public", parsed.Value.Text);
} }
[Theory]
[InlineData("/g", true)]
[InlineData("/say", true)]
[InlineData("/tell", true)]
[InlineData("/retell", true)]
[InlineData("/allegiance", true)]
[InlineData("/lookingforgroup", true)]
[InlineData("/genio", false)]
[InlineData("/ls", false)]
[InlineData("/foo", false)]
[InlineData("/", false)]
public void IsKnownVerb_ChecksAgainstAliasTables(string verb, bool expected)
{
Assert.Equal(expected, ChatInputParser.IsKnownVerb(verb));
}
[Theory]
[InlineData("/g hello", "/g")]
[InlineData("/tell Bob hi", "/tell")]
[InlineData("/foo", "/foo")]
[InlineData("/", "/")]
public void GetVerbToken_PullsFirstWhitespaceToken(string command, string expected)
{
Assert.Equal(expected, ChatInputParser.GetVerbToken(command));
}
[Theory] [Theory]
[InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")] [InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")]
[InlineData("/allegiance recall", ChatChannelKind.Allegiance, "recall")] [InlineData("/allegiance recall", ChatChannelKind.Allegiance, "recall")]

View file

@ -112,6 +112,38 @@ public sealed class ChatPanelInputTests
Assert.Contains("(10.0, 20.0, 30.0)", entry.Text); Assert.Contains("(10.0, 20.0, 30.0)", entry.Text);
} }
[Theory]
[InlineData("/foo")]
[InlineData("/ls")]
[InlineData("/mp /tools/script.py")]
[InlineData("/genio public")]
[InlineData("/")]
public void Submit_UnknownSlashCommand_ShowsUnknownAndDoesNotPublish(string raw)
{
// Phase J Tier 4: /-prefixed text is NEVER broadcast as plain
// speech. Filed after a 2026-04-25 trace where typing /ls (a
// command-style request the user wanted) was getting echoed by
// the server as "You say, \"/ls\"". Now we intercept and show
// a local "Unknown command" line; nothing goes on the wire.
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);
var bus = new RecordingBus();
var renderer = new FakePanelRenderer
{
InputTextSubmitNextSubmitted = raw,
InputTextSubmitNextBufferAfter = "",
};
panel.Render(new PanelContext(0.016f, bus), renderer);
Assert.Empty(bus.Published);
var entry = Assert.Single(log.Snapshot());
Assert.Equal(ChatKind.System, entry.Kind);
Assert.Contains("Unknown command", entry.Text);
Assert.Contains("/help", entry.Text);
}
[Fact] [Fact]
public void Submit_AtAcehelp_PassesThroughToSayWithAtIntact() public void Submit_AtAcehelp_PassesThroughToSayWithAtIntact()
{ {