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:
parent
59ea3252cd
commit
d3cab1ab10
5 changed files with 141 additions and 51 deletions
|
|
@ -2,7 +2,11 @@ using System;
|
||||||
|
|
||||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
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 }
|
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -10,9 +14,15 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
|
||||||
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
|
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
|
||||||
/// chat window route through here so command handling stays in one place.
|
/// chat window route through here so command handling stays in one place.
|
||||||
///
|
///
|
||||||
/// Order mirrors the prior inline <see cref="ChatPanel"/> flow:
|
/// Flow: client-command intercept → degenerate-prefix guard →
|
||||||
/// client-command intercept → unknown-slash-verb guard → <see cref="ChatInputParser.Parse"/>
|
/// <see cref="ChatInputParser.Parse"/> → <c>Publish(SendChatCmd)</c>.
|
||||||
/// → <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>
|
/// </summary>
|
||||||
public static class ChatCommandRouter
|
public static class ChatCommandRouter
|
||||||
{
|
{
|
||||||
|
|
@ -26,16 +36,16 @@ public static class ChatCommandRouter
|
||||||
|
|
||||||
if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled;
|
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
|
||||||
var verb = ChatInputParser.GetVerbToken(trimmed);
|
// fall through would broadcast /-text as speech (Tier-4 violation).
|
||||||
if (!ChatInputParser.IsKnownVerb(verb))
|
if (trimmed[0] is '/' or '@'
|
||||||
|
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
|
||||||
{
|
{
|
||||||
vm.ShowSystemMessage(
|
vm.ShowSystemMessage(
|
||||||
$"Unknown command: {verb}. Type /help for the list of supported commands.");
|
$"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands.");
|
||||||
return SubmitOutcome.UnknownCommand;
|
return SubmitOutcome.UnknownCommand;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var parsed = ChatInputParser.Parse(
|
var parsed = ChatInputParser.Parse(
|
||||||
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
|
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
/// <item><c>/say</c> with no message → <c>null</c></item>
|
/// <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>/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><c>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
|
||||||
/// <item>unknown <c>/xyz</c> verb → fall through to default channel
|
/// <item>unknown <c>/xyz</c> verb → rewritten to <c>@xyz</c> and passed
|
||||||
/// carrying the literal text (matches holtburger
|
/// through on the default channel. Retail treats / and @ as
|
||||||
/// <c>handle_slash_command</c> default arm at line ~744 — they
|
/// equivalent command prefixes; ACE's <c>GameActionTalk</c> only
|
||||||
/// send <c>ClientCommand::Talk(command)</c> with the original
|
/// intercepts the <c>@</c> form on the wire. Deliberate divergence
|
||||||
/// slash-prefixed string)</item>
|
/// 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,
|
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
|
||||||
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
|
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
|
|
@ -122,8 +123,12 @@ public static class ChatInputParser
|
||||||
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
|
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
|
||||||
}
|
}
|
||||||
// Unknown @-verb — keep the original @ so ACE recognizes
|
// Unknown @-verb — keep the original @ so ACE recognizes
|
||||||
// it server-side when the message arrives via Talk.
|
// it server-side. Always emit as Say: ACE's GameActionTalk
|
||||||
return new ParsedInput(defaultChannel, null, trimmed);
|
// (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>
|
// /say <msg>
|
||||||
|
|
@ -165,11 +170,18 @@ public static class ChatInputParser
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown slash command: holtburger falls back to Talk(literal).
|
// Unknown slash verb: retail treats / and @ as equivalent command
|
||||||
// We mirror that — emit on the default channel with the
|
// prefixes, but ACE's GameActionTalk only intercepts the @ form on
|
||||||
// slash-prefixed text intact so the server treats it as speech.
|
// the wire (a /-prefixed Talk is plain speech server-side). Rewrite
|
||||||
// No special-case for known-but-unimplemented verbs; the user's
|
// to @ and emit as Say — same reasoning as the unknown-@ branch at
|
||||||
// text round-trips so their intent isn't silently dropped.
|
// 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);
|
return new ParsedInput(defaultChannel, null, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,31 @@ public class ChatCommandRouterTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 (vm, log, bus) = Fixture();
|
||||||
var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say);
|
var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say);
|
||||||
Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||||||
Assert.Null(bus.Last);
|
Assert.NotNull(bus.Last);
|
||||||
Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command"));
|
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]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -191,18 +191,46 @@ public sealed class ChatInputParserTests
|
||||||
Assert.Null(ChatInputParser.Parse("/a", ChatChannelKind.Say, lastTellSender: null));
|
Assert.Null(ChatInputParser.Parse("/a", ChatChannelKind.Say, lastTellSender: null));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Unknown slash command: holtburger fall-through to Talk(literal)
|
// -- Unknown slash command: retail / ≡ @ equivalence. ACE's
|
||||||
// ("/wave hello" → Talk("/wave hello") + treat-as-Say). See
|
// GameActionTalk only intercepts @-prefixed Talk on the wire, so
|
||||||
// chat.rs::handle_slash_command default arm at line ~744.
|
// 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]
|
[Fact]
|
||||||
public void UnknownSlashCommand_FallsBackToDefaultChannelWithLiteralText()
|
public void UnknownSlashCommand_IsRewrittenToAtFormForServer()
|
||||||
{
|
{
|
||||||
var parsed = ChatInputParser.Parse("/xyz hello", ChatChannelKind.Say, lastTellSender: null);
|
var parsed = ChatInputParser.Parse("/xyz hello", ChatChannelKind.Say, lastTellSender: null);
|
||||||
|
|
||||||
Assert.NotNull(parsed);
|
Assert.NotNull(parsed);
|
||||||
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
|
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
|
||||||
Assert.Null(parsed.Value.TargetName);
|
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 --
|
// -- 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,
|
// 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 is NOT a
|
||||||
// through. The Parse-level behaviour for unknown /-verbs is
|
// client verb; it routes to the server in @ form like any
|
||||||
// still "Say with the literal text" (matches holtburger);
|
// other unknown verb (retail / ≡ @).
|
||||||
// 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);
|
||||||
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
|
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
|
||||||
Assert.Equal("/genio public", parsed.Value.Text);
|
Assert.Equal("@genio public", parsed.Value.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
|
||||||
|
|
@ -113,18 +113,43 @@ public sealed class ChatPanelInputTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/foo")]
|
[InlineData("/foo", "@foo")]
|
||||||
[InlineData("/ls")]
|
[InlineData("/ls", "@ls")]
|
||||||
[InlineData("/mp /tools/script.py")]
|
[InlineData("/mp /tools/script.py", "@mp /tools/script.py")]
|
||||||
[InlineData("/genio public")]
|
[InlineData("/genio public", "@genio public")]
|
||||||
[InlineData("/")]
|
public void Submit_UnknownSlashCommand_RoutesToServerAsAtCommand(string raw, string expectedText)
|
||||||
public void Submit_UnknownSlashCommand_ShowsUnknownAndDoesNotPublish(string raw)
|
|
||||||
{
|
{
|
||||||
// Phase J Tier 4: /-prefixed text is NEVER broadcast as plain
|
// Phase J Tier 4 held: /-prefixed text is still NEVER broadcast
|
||||||
// speech. Filed after a 2026-04-25 trace where typing /ls (a
|
// as plain speech. Retail treats / and @ as equivalent command
|
||||||
// command-style request the user wanted) was getting echoed by
|
// prefixes, so unknown verbs now go to the SERVER as @commands
|
||||||
// the server as "You say, \"/ls\"". Now we intercept and show
|
// (ACE's GameActionTalk intercepts @ on the Say action and
|
||||||
// a local "Unknown command" line; nothing goes on the wire.
|
// 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<SendChatCmd>(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 log = new ChatLog();
|
||||||
var vm = new ChatVM(log);
|
var vm = new ChatVM(log);
|
||||||
var panel = new ChatPanel(vm);
|
var panel = new ChatPanel(vm);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue