Brings acdream's / and @ command parsing to parity with the complete
retail registry (130 registered verbs + 22 unregistered GetChannelID
fallback tags = 152 client-parsed verbs), per
docs/research/2026-08-09-chat-retail-command-registry.md.
Parser semantics (retail OnChatCommand/DoCommand):
- : and ; rewrite to "@emote <rest>" before dispatch.
- Verb trailing-comma trim ("@f, hi" == "@f hi") applied at every
verb-lookup site in the catalog and the parser.
- @tell/aliases split the target on the FIRST COMMA, not the first
whitespace token, so multi-word names work ("@tell Aunt Agatha, hi").
- The 22 unregistered GM/faction channel tags (admin, sentinel,
celestialhand, ...) now broadcast for real via a new
RetailChannelTagTable + SendRawChannelCmd bypass, reusing the existing
BuildChatChannel wire builder.
Binding corrections:
- /g, /group, /party -> Fellowship (0x800), not General.
- /rp -> reply alias (retail's own help text confirms "@r or @rp"), not
Roleplay; /role (an acdream invention) deleted.
- /allegiance, /all -> the allegiance management command
(RetailClientCommandCatalog), not a channel verb.
- /house no longer swallows unrecognized subcommands with a local usage
error; they now correctly fall through to ACE.
- @mr/@pr pinned as permanently non-executable (retail registers them
with a null function pointer).
New verbs with real local execution: endurance, speaker, title (silent,
AP-182), chat, notell, join, leave, permit, hslist, index, clist, on,
off, alh/ah (+ "@allegiance hometown"/"ho"), "@allegiance info",
"@house abandon"; a missing-alias sweep across pkl/hou/message_types/
msgtypes/msg_types/rt/send/whisper/w/vassal/covassal/co-vassals/c/
fellows/group/party/guild/gu/cg/ct/clfg/crp/soc/o; the non-retail
inventions gen/cv/lookingforgroup/tr/role/h are deleted. New Core.Net
wire builders (IndexChannels, ListChannels, AddChannel, RemoveChannel,
RecallAllegianceHometown, AllegianceInfoRequest, ListAvailableHouses,
AddPlayerPermission, RemovePlayerPermission, AbandonHouse) are all
parameterless or single-field payloads cross-checked against ACE's
GameAction readers, not guessed.
Deferred (filed as #360/#361/#362, register rows TS-68/TS-69/TS-70):
the ~22 remaining allegiance/house subcommands + standalone @motd
(largest single item, needs its own slice per the doc), the three
still-inert pure-local commands (day/log/render), and the inbound
GameEvent responses for the new outbound requests. All correctly fall
through to ACE server-passthrough rather than being silently swallowed
or faking success.
RetailCommandRegistryConformanceTests pins the complete 152-verb
registry against production: every verb resolves through exactly one
production surface if Implemented, through none if HelpOnly/
ServerPassthrough, and two reverse-direction tests fail the build if
RetailClientCommandCatalog or ChatInputParser ever claims a verb
outside this registry again. Final tally: 138 Implemented / 5
ServerPassthrough / 9 HelpOnly = 152.
Release suite: 12,190 passed / 4 skipped / 0 failed (up from CH3's
11,964/4/0).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.5 KiB
C#
142 lines
5.5 KiB
C#
using AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// Phase J Tier 1: ACE accepts <c>/</c> and <c>@</c> as equivalent verb
|
|
/// prefixes (per ACE help: "Note: You may substitute a forward slash
|
|
/// (/) for the at symbol (@)."). For verbs the parser recognizes, we
|
|
/// normalize <c>@</c> to <c>/</c> and re-enter parsing. For unknown
|
|
/// <c>@</c>-verbs (e.g. <c>@acehelp</c>, <c>@tele</c>, <c>@die</c>),
|
|
/// we keep the original <c>@</c> intact so ACE's CommandManager
|
|
/// intercepts the message server-side.
|
|
/// </summary>
|
|
public sealed class ChatInputParserAtPrefixTests
|
|
{
|
|
[Theory]
|
|
[InlineData("@a hi gang", ChatChannelKind.Allegiance, "hi gang")]
|
|
[InlineData("@guild hi gang", ChatChannelKind.Allegiance, "hi gang")]
|
|
[InlineData("@p heads up", ChatChannelKind.Patron, "heads up")]
|
|
[InlineData("@patron heads up", ChatChannelKind.Patron, "heads up")]
|
|
[InlineData("@f buff time", ChatChannelKind.Fellowship, "buff time")]
|
|
// Campaign CH slice CH4 (2026-08-09): "/g" moved from General to
|
|
// Fellowship (Tier 1 fix #1 — retail binds g/group/party to
|
|
// Fellowship, 0x800); "/allegiance" is DELETED from this table — it
|
|
// is now RetailClientCommandCatalog's allegiance MANAGEMENT command,
|
|
// not a channel verb (Tier 1 fix #3).
|
|
[InlineData("@g general msg", ChatChannelKind.Fellowship, "general msg")]
|
|
[InlineData("@general general msg", ChatChannelKind.General, "general msg")]
|
|
public void AtPrefix_KnownChannelVerb_RoutesSameAsSlash(string raw, ChatChannelKind expected, string text)
|
|
{
|
|
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(expected, parsed!.Value.Channel);
|
|
Assert.Null(parsed.Value.TargetName);
|
|
Assert.Equal(text, parsed.Value.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AtTell_KnownVerb_RoutesAsTell()
|
|
{
|
|
var parsed = ChatInputParser.Parse("@tell Bestie hi", ChatChannelKind.Say, lastTellSender: null);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
|
Assert.Equal("Bestie", parsed.Value.TargetName);
|
|
Assert.Equal("hi", parsed.Value.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void AtReply_NeedsLastIncomingTell_LikeSlashReply()
|
|
{
|
|
var with = ChatInputParser.Parse("@reply back at you", ChatChannelKind.Say, lastTellSender: "Bestie");
|
|
Assert.NotNull(with);
|
|
Assert.Equal(ChatChannelKind.Tell, with!.Value.Channel);
|
|
Assert.Equal("Bestie", with.Value.TargetName);
|
|
Assert.Equal("back at you", with.Value.Text);
|
|
|
|
var without = ChatInputParser.Parse("@reply hi", ChatChannelKind.Say, lastTellSender: null);
|
|
Assert.Null(without);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("@acehelp")]
|
|
[InlineData("@acecommands")]
|
|
[InlineData("@tele 30 30 30")]
|
|
[InlineData("@die")]
|
|
[InlineData("@version")]
|
|
[InlineData("@loc")] // ACE has @loc server-side too; passes through
|
|
[InlineData("@nonsense filler")]
|
|
// "@allegiance"/"@all" are RetailClientCommandCatalog verbs (the
|
|
// management command), NOT ChatInputParser channel verbs — at this
|
|
// layer (pure Parse, no catalog check) they pass through unknown.
|
|
[InlineData("@allegiance boot Bob")]
|
|
[InlineData("@house open")]
|
|
public void AtPrefix_UnknownVerb_PassesThroughIntactAsDefaultChannel(string raw)
|
|
{
|
|
// Critical: the @-prefix is preserved in Text so ACE's
|
|
// CommandManager recognizes the message as a server-side
|
|
// command when it arrives via Talk. If we substituted to /,
|
|
// ACE would treat it as plain speech and broadcast it.
|
|
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
|
|
Assert.Equal(raw, parsed.Value.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Retell_WithLastOutgoingTarget_RoutesToTell()
|
|
{
|
|
var parsed = ChatInputParser.Parse(
|
|
"/retell once more with feeling",
|
|
ChatChannelKind.Say,
|
|
lastTellSender: null,
|
|
lastOutgoingTellTarget: "Caith");
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
|
Assert.Equal("Caith", parsed.Value.TargetName);
|
|
Assert.Equal("once more with feeling", parsed.Value.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Retell_AtPrefix_AlsoWorks()
|
|
{
|
|
var parsed = ChatInputParser.Parse(
|
|
"@retell hi again",
|
|
ChatChannelKind.Say,
|
|
lastTellSender: null,
|
|
lastOutgoingTellTarget: "Caith");
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
|
Assert.Equal("Caith", parsed.Value.TargetName);
|
|
Assert.Equal("hi again", parsed.Value.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Retell_NoPriorOutgoingTell_ReturnsNull()
|
|
{
|
|
var parsed = ChatInputParser.Parse(
|
|
"/retell hello",
|
|
ChatChannelKind.Say,
|
|
lastTellSender: "Bestie", // incoming is irrelevant for retell
|
|
lastOutgoingTellTarget: null);
|
|
|
|
Assert.Null(parsed);
|
|
}
|
|
|
|
[Fact]
|
|
public void Retell_BareVerb_ReturnsNull()
|
|
{
|
|
var parsed = ChatInputParser.Parse(
|
|
"/retell",
|
|
ChatChannelKind.Say,
|
|
lastTellSender: null,
|
|
lastOutgoingTellTarget: "Caith");
|
|
|
|
Assert.Null(parsed);
|
|
}
|
|
}
|