diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs
index 9158d2d0..81034765 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs
@@ -2,7 +2,11 @@ using System;
namespace AcDream.UI.Abstractions.Panels.Chat;
-/// What a submit did, so the caller can clear its input + give feedback.
+/// What a submit did, so the caller can clear its input + give feedback.
+/// UnknownCommand 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).
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
///
@@ -10,9 +14,15 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
/// analogue). Both the ImGui devtools and the retail
/// chat window route through here so command handling stays in one place.
///
-/// Order mirrors the prior inline flow:
-/// client-command intercept → unknown-slash-verb guard →
-/// → Publish(SendChatCmd).
+/// Flow: client-command intercept → degenerate-prefix guard →
+/// → Publish(SendChatCmd).
+/// Unknown slash/at verbs pass through the parser to the server (the parser
+/// rewrites /xyz → @xyz 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).
///
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(
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
index 82aaf760..73141255 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
@@ -16,11 +16,12 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// - /say with no message → null
/// - /t with no target / no message → null
/// - /r with no lastTellSender → null
-/// - unknown /xyz verb → fall through to default channel
-/// carrying the literal text (matches holtburger
-/// handle_slash_command default arm at line ~744 — they
-/// send ClientCommand::Talk(command) with the original
-/// slash-prefixed string)
+/// - unknown /xyz verb → rewritten to @xyz and passed
+/// through on the default channel. Retail treats / and @ as
+/// equivalent command prefixes; ACE's GameActionTalk only
+/// intercepts the @ form on the wire. Deliberate divergence
+/// from holtburger's literal fall-through (which would SAY the
+/// command text out loud).
/// - multi-word /t target: first whitespace token is target,
/// rest is message (matches Rust split_once semantics)
///
@@ -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
@@ -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);
}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs
index e0f1daad..bd180c7e 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs
@@ -54,13 +54,31 @@ public class ChatCommandRouterTests
}
[Fact]
- public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish()
+ public void UnknownSlashVerb_RoutesToServerInAtForm()
{
+ // Retail / ≡ @: unknown verbs are the server's to judge. The
+ // parser rewrites /-form to @-form (ACE's GameActionTalk only
+ // intercepts @ on the wire); ACE answers "Unknown command: x"
+ // for verbs it doesn't know — no local guess.
var (vm, log, bus) = Fixture();
var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say);
- Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
- Assert.Null(bus.Last);
- Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command"));
+ Assert.Equal(SubmitOutcome.Sent, outcome);
+ Assert.NotNull(bus.Last);
+ Assert.Equal("@notacommand", bus.Last!.Text);
+ Assert.DoesNotContain(log.Snapshot(), e => e.Text.Contains("Unknown command"));
+ }
+
+ [Fact]
+ public void ServerCommandWithArgs_PublishesAtFormAsSay_EvenOnChannelDefault()
+ {
+ // Commands resolve before channel routing (retail behavior):
+ // even with Fellowship as the active input channel, a command
+ // goes out as Say/Talk — the only wire action ACE parses @ on.
+ var (vm, _, bus) = Fixture();
+ var outcome = ChatCommandRouter.Submit("/ci 629 5", vm, bus, ChatChannelKind.Fellowship);
+ Assert.Equal(SubmitOutcome.Sent, outcome);
+ Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel);
+ Assert.Equal("@ci 629 5", bus.Last.Text);
}
[Fact]
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
index 052aa6bb..c039af43 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserTests.cs
@@ -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]
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
index 07b3bf5a..1977688c 100644
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
+++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
@@ -113,18 +113,43 @@ public sealed class ChatPanelInputTests
}
[Theory]
- [InlineData("/foo")]
- [InlineData("/ls")]
- [InlineData("/mp /tools/script.py")]
- [InlineData("/genio public")]
- [InlineData("/")]
- public void Submit_UnknownSlashCommand_ShowsUnknownAndDoesNotPublish(string raw)
+ [InlineData("/foo", "@foo")]
+ [InlineData("/ls", "@ls")]
+ [InlineData("/mp /tools/script.py", "@mp /tools/script.py")]
+ [InlineData("/genio public", "@genio public")]
+ public void Submit_UnknownSlashCommand_RoutesToServerAsAtCommand(string raw, string expectedText)
{
- // 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.
+ // Phase J Tier 4 held: /-prefixed text is still NEVER broadcast
+ // as plain speech. Retail treats / and @ as equivalent command
+ // prefixes, so unknown verbs now go to the SERVER as @commands
+ // (ACE's GameActionTalk intercepts @ on the Say action and
+ // answers "Unknown command: x" itself) instead of a local guess.
+ 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);
+
+ var sendCmd = Assert.IsType(Assert.Single(bus.Published));
+ Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
+ Assert.Equal(expectedText, sendCmd.Text);
+ Assert.Empty(log.Snapshot()); // no local "Unknown command" guess
+ }
+
+ [Theory]
+ [InlineData("/")]
+ [InlineData("//shrug")]
+ public void Submit_CommandShapedWithoutVerb_ShowsUnknownAndDoesNotPublish(string raw)
+ {
+ // Command-shaped but no letter verb: refused locally — this is
+ // the remaining Tier-4 guard (never broadcast /-text as speech,
+ // and don't put junk @-rewrites on the wire either).
var log = new ChatLog();
var vm = new ChatVM(log);
var panel = new ChatPanel(vm);