feat(chat): Campaign CH slice CH4 — command registry completion
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>
This commit is contained in:
parent
9247d5d5b5
commit
090825e703
23 changed files with 2069 additions and 97 deletions
|
|
@ -139,4 +139,84 @@ public class ChatCommandRouterTests
|
|||
Assert.Equal(SubmitOutcome.Empty, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
}
|
||||
|
||||
// ── Campaign CH slice CH4 (2026-08-09) ──────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(":waves")]
|
||||
[InlineData(";waves")]
|
||||
public void EmotePrefix_RewritesToAtEmote(string raw)
|
||||
{
|
||||
var (vm, _, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit(raw, vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ClientCommandId.Emote, command.Command);
|
||||
Assert.Equal("waves", command.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnregisteredChannelTag_PublishesRawChannelBroadcast()
|
||||
{
|
||||
var (vm, _, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/admin server is misbehaving", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||||
var command = Assert.IsType<SendRawChannelCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(0x00000002u, command.ChannelId);
|
||||
Assert.Equal("server is misbehaving", command.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnregisteredChannelTag_WithNoText_ShowsRetailRefusal_AndPublishesNothing()
|
||||
{
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/sentinel", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Contains(log.Snapshot(), entry => entry.Text == "You must specify the text you wish to say!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredChannelVerb_NeverReachesTheRawFallback()
|
||||
{
|
||||
// "/f" is a KNOWN ChatInputParser verb (Fellowship) — it must ride
|
||||
// the normal SendChatCmd/self-echo path, not the raw fallback.
|
||||
var (vm, _, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/f hi gang", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||||
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ChatChannelKind.Fellowship, command.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelpVerb_ShowsCatalogHelpText()
|
||||
{
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/help lifestone", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("Returns you to the last lifestone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelpVerb_UnknownVerb_ShowsFallbackMessage()
|
||||
{
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("No help available"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,17 @@ public sealed class ChatInputParserAtPrefixTests
|
|||
{
|
||||
[Theory]
|
||||
[InlineData("@a hi gang", ChatChannelKind.Allegiance, "hi gang")]
|
||||
[InlineData("@allegiance recall", ChatChannelKind.Allegiance, "recall")]
|
||||
[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")]
|
||||
[InlineData("@g general msg", ChatChannelKind.General, "general msg")]
|
||||
// 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);
|
||||
|
|
@ -62,6 +68,11 @@ public sealed class ChatInputParserAtPrefixTests
|
|||
[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
|
||||
|
|
|
|||
|
|
@ -116,19 +116,89 @@ public sealed class ChatInputParserTests
|
|||
Assert.Null(parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RpAlias_IsReply_NotRoleplay()
|
||||
{
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #2: retail's own
|
||||
// help text confirms "@reply <text> ... You may also use @r or
|
||||
// @rp." — a private reply, not a Roleplay broadcast.
|
||||
var parsed = ChatInputParser.Parse("/rp back at you", ChatChannelKind.Say, lastTellSender: "Bestie");
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
||||
Assert.Equal("Bestie", parsed.Value.TargetName);
|
||||
Assert.Equal("back at you", parsed.Value.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TellAliases_SendWhisperW_AllRouteAsTell()
|
||||
{
|
||||
// Campaign CH slice CH4 (2026-08-09): retail's DoTell registers
|
||||
// FOUR verb strings — tell/t/send/whisper/w.
|
||||
foreach (string verb in new[] { "/send", "/whisper", "/w" })
|
||||
{
|
||||
var parsed = ChatInputParser.Parse($"{verb} 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 RetellAlias_Rt_RoutesLikeRetell()
|
||||
{
|
||||
var parsed = ChatInputParser.Parse(
|
||||
"/rt once more",
|
||||
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", parsed.Value.Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/tell Aunt Agatha, hello", "Aunt Agatha", "hello")]
|
||||
[InlineData("/t Aunt Agatha, hello", "Aunt Agatha", "hello")]
|
||||
public void TellTarget_SplitsOnFirstComma_NotFirstWhitespace(string raw, string expectedTarget, string expectedText)
|
||||
{
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #6 (A.3): retail's
|
||||
// DoTell requires a comma after the name precisely because names
|
||||
// can be multiple words. Splitting on the first WHITESPACE (the
|
||||
// pre-CH4 behavior) truncated "Aunt Agatha" to "Aunt".
|
||||
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
||||
Assert.Equal(expectedTarget, parsed.Value.TargetName);
|
||||
Assert.Equal(expectedText, parsed.Value.Text);
|
||||
}
|
||||
|
||||
// -- Channel aliases (single-message) -------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("/g raid time", ChatChannelKind.General, "raid time")]
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #1: "/g" is
|
||||
// Fellowship in retail (g/group/party/fellow/fellows/fellowship all
|
||||
// bind 0x800), NOT General — sending fellowship chatter to General
|
||||
// was a live correctness bug.
|
||||
[InlineData("/g raid time", ChatChannelKind.Fellowship, "raid time")]
|
||||
[InlineData("/f buff up", ChatChannelKind.Fellowship, "buff up")]
|
||||
[InlineData("/a swearing in", ChatChannelKind.Allegiance, "swearing in")]
|
||||
[InlineData("/m monarch broadcast", ChatChannelKind.Monarch, "monarch broadcast")]
|
||||
[InlineData("/p patron only", ChatChannelKind.Patron, "patron only")]
|
||||
[InlineData("/v vassals only", ChatChannelKind.Vassals, "vassals only")]
|
||||
[InlineData("/cv covassals only", ChatChannelKind.CoVassals, "covassals only")]
|
||||
// "/cv" (an acdream invention) is DELETED; "/c" is the real retail
|
||||
// Co-vassals alias (registry doc §2.3).
|
||||
[InlineData("/c covassals only", ChatChannelKind.CoVassals, "covassals only")]
|
||||
[InlineData("/lfg need 3 more", ChatChannelKind.Lfg, "need 3 more")]
|
||||
[InlineData("/trade wts gem", ChatChannelKind.Trade, "wts gem")]
|
||||
[InlineData("/role *waves*", ChatChannelKind.Roleplay, "*waves*")]
|
||||
// "/role" (an acdream invention) is DELETED; "/roleplay" is retail's
|
||||
// real verb (see LongFormAliases_RouteToTheirChannel for the full
|
||||
// roleplay/reply-alias split).
|
||||
[InlineData("/roleplay *waves*", ChatChannelKind.Roleplay, "*waves*")]
|
||||
[InlineData("/society olthoi raid", ChatChannelKind.Society, "olthoi raid")]
|
||||
[InlineData("/olthoi for the queen", ChatChannelKind.Olthoi, "for the queen")]
|
||||
public void ChannelPrefixes_RouteToTheirChannel(string raw, ChatChannelKind expectedChannel, string expectedText)
|
||||
|
|
@ -270,8 +340,17 @@ public sealed class ChatInputParserTests
|
|||
[InlineData("/say", true)]
|
||||
[InlineData("/tell", true)]
|
||||
[InlineData("/retell", true)]
|
||||
[InlineData("/allegiance", true)]
|
||||
[InlineData("/lookingforgroup", true)]
|
||||
[InlineData("/rt", true)]
|
||||
[InlineData("/rp", true)] // reply alias now, not Roleplay (Tier 1 fix #2)
|
||||
[InlineData("/guild", true)]
|
||||
// Campaign CH slice CH4 (2026-08-09): "/allegiance"/"/lookingforgroup"
|
||||
// are DELETED from ChatInputParser — "allegiance" is now
|
||||
// RetailClientCommandCatalog's management command (Tier 1 fix #3);
|
||||
// "lookingforgroup" was never a retail verb (doc §4 removal list).
|
||||
[InlineData("/allegiance", false)]
|
||||
[InlineData("/lookingforgroup", false)]
|
||||
[InlineData("/role", false)] // acdream invention, deleted
|
||||
[InlineData("/cv", false)] // acdream invention, deleted
|
||||
[InlineData("/genio", false)]
|
||||
[InlineData("/ls", false)]
|
||||
[InlineData("/foo", false)]
|
||||
|
|
@ -281,6 +360,31 @@ public sealed class ChatInputParserTests
|
|||
Assert.Equal(expected, ChatInputParser.IsKnownVerb(verb));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsKnownVerb_TrimsTrailingComma()
|
||||
{
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #5: retail's
|
||||
// DoCommand right-trims ',' off the verb before lookup — the verb
|
||||
// TOKEN itself ("/f,", as GetVerbToken would extract from "/f, hi").
|
||||
Assert.True(ChatInputParser.IsKnownVerb("/f,"));
|
||||
Assert.True(ChatInputParser.IsKnownVerb("/f"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommaTrimmedVerb_ParsesTheSameAsWithoutComma()
|
||||
{
|
||||
// "/f, hi" == "/f hi" end to end through Parse.
|
||||
var withComma = ChatInputParser.Parse("/f, hi", ChatChannelKind.Say, lastTellSender: null);
|
||||
var withoutComma = ChatInputParser.Parse("/f hi", ChatChannelKind.Say, lastTellSender: null);
|
||||
|
||||
Assert.NotNull(withComma);
|
||||
Assert.NotNull(withoutComma);
|
||||
Assert.Equal(withoutComma!.Value.Channel, withComma!.Value.Channel);
|
||||
Assert.Equal(withoutComma.Value.Text, withComma.Value.Text);
|
||||
Assert.Equal(ChatChannelKind.Fellowship, withComma.Value.Channel);
|
||||
Assert.Equal("hi", withComma.Value.Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/g hello", "/g")]
|
||||
[InlineData("/tell Bob hi", "/tell")]
|
||||
|
|
@ -293,16 +397,19 @@ public sealed class ChatInputParserTests
|
|||
|
||||
[Theory]
|
||||
[InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")]
|
||||
[InlineData("/allegiance recall", ChatChannelKind.Allegiance, "recall")]
|
||||
[InlineData("/guild recall", ChatChannelKind.Allegiance, "recall")]
|
||||
[InlineData("/patron need help", ChatChannelKind.Patron, "need help")]
|
||||
[InlineData("/vassals listen up", ChatChannelKind.Vassals, "listen up")]
|
||||
[InlineData("/monarch heads up", ChatChannelKind.Monarch, "heads up")]
|
||||
[InlineData("/covassals tax season", ChatChannelKind.CoVassals, "tax season")]
|
||||
[InlineData("/fellowship buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/fellow buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/lookingforgroup hunt invite", ChatChannelKind.Lfg, "hunt invite")]
|
||||
[InlineData("/fellows buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/group buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/party buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/clfg hunt invite", ChatChannelKind.Lfg, "hunt invite")]
|
||||
[InlineData("/roleplay walk-up", ChatChannelKind.Roleplay, "walk-up")]
|
||||
[InlineData("/rp walk-up", ChatChannelKind.Roleplay, "walk-up")]
|
||||
[InlineData("/crp walk-up", ChatChannelKind.Roleplay, "walk-up")]
|
||||
public void LongFormAliases_RouteToTheirChannel(string raw, ChatChannelKind expected, string text)
|
||||
{
|
||||
// Phase J: retail muscle memory uses long forms ("/patron"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ public sealed class ChatPanelInputTests
|
|||
|
||||
[Theory]
|
||||
[InlineData("/?")]
|
||||
[InlineData("/h")]
|
||||
// "/h" is DELETED (Campaign CH slice CH4, 2026-08-09) — it is not a
|
||||
// retail-registered verb (registry doc §4's removal list).
|
||||
[InlineData("/HELP")]
|
||||
public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -90,13 +90,156 @@ public sealed class RetailClientCommandCatalogTests
|
|||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedHouseSubcommand_RemainsClientOwnedAndShowsUsage()
|
||||
[Theory]
|
||||
[InlineData("/house open")]
|
||||
[InlineData("/house close")]
|
||||
[InlineData("/house guest add Bob")]
|
||||
[InlineData("/house storage add Bob")]
|
||||
[InlineData("/house nope")]
|
||||
[InlineData("/house available")]
|
||||
public void UnsupportedHouseSubcommand_FallsThroughToServerPassthrough(string input)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch("/house nope", out var match));
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #4: unrecognized
|
||||
// house subcommands must reach ACE (TS-68), not be swallowed
|
||||
// locally with a wrong usage message.
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch(input, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/house abandon", ClientCommandId.HouseAbandon)]
|
||||
[InlineData("/house re", ClientCommandId.HouseRecall)]
|
||||
[InlineData("/house ma", ClientCommandId.MansionRecall)]
|
||||
[InlineData("/hou recall", ClientCommandId.HouseRecall)]
|
||||
public void HouseAliasesAndShortcuts_Resolve(string input, ClientCommandId expected)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/allegiance boot Bob")]
|
||||
[InlineData("/allegiance ban add Bob")]
|
||||
[InlineData("/allegiance motd")]
|
||||
[InlineData("/allegiance")]
|
||||
[InlineData("/all officer add 2 Bob")]
|
||||
public void UnsupportedAllegianceSubcommand_FallsThroughToServerPassthrough(string input)
|
||||
{
|
||||
// Same Tier-1-class fix, applied to the allegiance management
|
||||
// dispatcher (TS-68): unrecognized subcommands reach ACE.
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch(input, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/allegiance hometown", ClientCommandId.AllegianceHometown, "")]
|
||||
[InlineData("/allegiance ho", ClientCommandId.AllegianceHometown, "")]
|
||||
[InlineData("/alh", ClientCommandId.AllegianceHometown, "")]
|
||||
[InlineData("/ah", ClientCommandId.AllegianceHometown, "")]
|
||||
[InlineData("/allegiance info", ClientCommandId.AllegianceInfo, "")]
|
||||
[InlineData("/allegiance info Bob", ClientCommandId.AllegianceInfo, "Bob")]
|
||||
[InlineData("/all info Bob", ClientCommandId.AllegianceInfo, "Bob")]
|
||||
public void AllegianceImplementedSubcommands_Resolve(
|
||||
string input, ClientCommandId expected, string expectedArguments)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
Assert.Equal(expectedArguments, match.Arguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/pkl", ClientCommandId.EnterPkLite)]
|
||||
[InlineData("/message_types", ClientCommandId.ListMessageTypes)]
|
||||
[InlineData("/msgtypes", ClientCommandId.ListMessageTypes)]
|
||||
[InlineData("/msg_types", ClientCommandId.ListMessageTypes)]
|
||||
[InlineData("/endurance", ClientCommandId.Endurance)]
|
||||
[InlineData("/speaker", ClientCommandId.Speaker)]
|
||||
[InlineData("/index", ClientCommandId.IndexChannels)]
|
||||
public void MissingAliasesSweep_Resolve(string input, ClientCommandId expected)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/chat on")]
|
||||
[InlineData("/chat off")]
|
||||
[InlineData("/notell on")]
|
||||
[InlineData("/notell off")]
|
||||
public void ChatNoTellToggle_ValidArguments_Resolve(string input)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatToggle_InvalidArgument_IsRejected()
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch("/chat maybe", out var match));
|
||||
Assert.False(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/join allegiance")]
|
||||
[InlineData("/join general")]
|
||||
[InlineData("/leave society")]
|
||||
[InlineData("/leave soc")]
|
||||
public void JoinLeave_ValidTags_Resolve(string input)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoinLeave_InvalidTag_IsRejected()
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch("/join nonsense", out var match));
|
||||
Assert.False(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/permit add Bob", true)]
|
||||
[InlineData("/permit remove Bob", true)]
|
||||
[InlineData("/permit add", false)]
|
||||
[InlineData("/permit maybe Bob", false)]
|
||||
public void Permit_ArgumentShape(string input, bool expectedValid)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expectedValid, match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/hslist Cottage", true)]
|
||||
[InlineData("/hslist mansion", true)]
|
||||
[InlineData("/hslist nonsense", false)]
|
||||
public void HouseAvailableList_ArgumentShape(string input, bool expectedValid)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expectedValid, match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/clist fellowship", true)]
|
||||
[InlineData("/on admin", true)]
|
||||
[InlineData("/off nonsense", false)]
|
||||
public void ChannelArgumentCommands_ResolveTagsAgainstRetailChannelTagTable(string input, bool expectedValid)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expectedValid, match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MrPr_AreNeverExecutable()
|
||||
{
|
||||
// Retail registers @mr/@pr with a NULL function pointer — they
|
||||
// must never resolve as client-owned commands (Tier B.9).
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch("/mr", out _));
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch("/pr", out _));
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch("/mr hello", out _));
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch("/pr hello", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LifestoneArgument_IsRecognizedButInvalid()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH4 (2026-08-09): pins acdream's complete chat-command
|
||||
/// surface against the retail registry enumeration in
|
||||
/// <c>docs/research/2026-08-09-chat-retail-command-registry.md</c> §2/§4 —
|
||||
/// 130 verbs registered by <c>InitializeCommands</c> +
|
||||
/// <c>StartupTurbineChatSystem</c>, plus the 22 unregistered
|
||||
/// <c>ChannelSystem::GetChannelID</c> fallback tags (152 total).
|
||||
///
|
||||
/// <para>
|
||||
/// Every verb has an explicit <see cref="Status"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Status.Implemented"/> — executes locally (typed client
|
||||
/// command, chat alias, channel send, or local presentation).</item>
|
||||
/// <item><see cref="Status.HelpOnly"/> — retail registers it with a NULL
|
||||
/// function pointer (verified byte-level in the registry doc); it can
|
||||
/// never execute in retail OR acdream. Typing it bare reaches the server
|
||||
/// as literal text.</item>
|
||||
/// <item><see cref="Status.ServerPassthrough"/> — a real retail verb
|
||||
/// acdream has not yet ported (TS-68/TS-69/TS-70, or the deliberate
|
||||
/// @loadfile product decision); falls through to ACE as literal text.</item>
|
||||
/// </list>
|
||||
/// The FIRST test enforces that every verb resolves to the RIGHT status.
|
||||
/// The SECOND enforces the ownership rule in reverse: nothing in
|
||||
/// <see cref="RetailClientCommandCatalog"/> or <see cref="ChatInputParser"/>
|
||||
/// exists that ISN'T in this registry (no invented verbs slip back in).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RetailCommandRegistryConformanceTests
|
||||
{
|
||||
private enum Status { Implemented, HelpOnly, ServerPassthrough }
|
||||
|
||||
private sealed record Entry(Status Status, params string[] Verbs);
|
||||
|
||||
/// <summary>
|
||||
/// The complete 152-verb registry, one entry per retail handler (or
|
||||
/// per fallback-tag family), transcribed directly from the
|
||||
/// command-registry doc's §2 tables. Verb-string counts per section
|
||||
/// were cross-checked against the doc's own §4 totals (130 + 22 = 152;
|
||||
/// the Turbine §2.4 net-new count here is exactly 14, matching the
|
||||
/// doc's "15 added, 14 net-new (a replaced)" note) before this test
|
||||
/// was written.
|
||||
/// </summary>
|
||||
private static readonly Entry[] Registry =
|
||||
[
|
||||
// §2.1 Help / group nodes (9)
|
||||
new(Status.Implemented, "help", "?"),
|
||||
new(Status.HelpOnly, "commands"),
|
||||
new(Status.HelpOnly, "allegiances"),
|
||||
new(Status.HelpOnly, "channels"),
|
||||
new(Status.HelpOnly, "chatting"),
|
||||
new(Status.HelpOnly, "death"),
|
||||
new(Status.HelpOnly, "status"),
|
||||
new(Status.HelpOnly, "text"),
|
||||
|
||||
// §2.2 Local chat routing (31)
|
||||
new(Status.Implemented, "say", "s"),
|
||||
new(Status.Implemented, "tell", "t", "send", "whisper", "w"),
|
||||
new(Status.Implemented, "reply", "r", "rp"),
|
||||
new(Status.HelpOnly, "mr"),
|
||||
new(Status.HelpOnly, "pr"),
|
||||
new(Status.Implemented, "retell", "rt"),
|
||||
new(Status.Implemented, "chat"),
|
||||
new(Status.Implemented, "notell"),
|
||||
new(Status.Implemented, "join"),
|
||||
new(Status.Implemented, "leave"),
|
||||
new(Status.Implemented, "index"),
|
||||
new(Status.Implemented, "clist"),
|
||||
new(Status.Implemented, "on"),
|
||||
new(Status.Implemented, "off"),
|
||||
new(Status.Implemented, "title"),
|
||||
new(Status.ServerPassthrough, "log"), // TS-69
|
||||
new(Status.Implemented, "clear"),
|
||||
new(Status.Implemented, "filter"),
|
||||
new(Status.Implemented, "unfilter"),
|
||||
new(Status.Implemented, "messagetypes", "message_types", "msgtypes", "msg_types"),
|
||||
new(Status.ServerPassthrough, "loadfile"), // deliberate — doc §3 "explicitly do NOT implement"
|
||||
|
||||
// §2.3 Chat channels (20)
|
||||
new(Status.Implemented, "a", "ab"),
|
||||
new(Status.Implemented, "co-vassals", "covassals", "covassal", "c"),
|
||||
new(Status.Implemented, "monarch", "m"),
|
||||
new(Status.Implemented, "patron", "p"),
|
||||
new(Status.Implemented, "vassals", "vassal", "v"),
|
||||
new(Status.Implemented, "fellowship", "fellows", "fellow", "f", "group", "g", "party"),
|
||||
|
||||
// §2.3 fallback: 22 GetChannelID tags with NO registered verb (22)
|
||||
new(Status.Implemented, "av", "av1", "advocate", "advocate1"),
|
||||
new(Status.Implemented, "av2", "advocate2"),
|
||||
new(Status.Implemented, "av3", "advocate3"),
|
||||
new(Status.Implemented, "abuse"),
|
||||
new(Status.Implemented, "ad", "admin"),
|
||||
new(Status.Implemented, "au", "audit"),
|
||||
new(Status.Implemented, "sent", "sentinel"),
|
||||
new(Status.Implemented, "celestialhand", "celhan"),
|
||||
new(Status.Implemented, "eldrytchweb", "eldweb"),
|
||||
new(Status.Implemented, "radiantblood", "radblo"),
|
||||
new(Status.Implemented, "ol"),
|
||||
|
||||
// §2.4 Turbine chat, 14 net-new (a already counted in §2.3) (14)
|
||||
new(Status.Implemented, "guild", "gu"),
|
||||
new(Status.Implemented, "general", "cg"),
|
||||
new(Status.Implemented, "trade", "ct"),
|
||||
new(Status.Implemented, "lfg", "clfg"),
|
||||
new(Status.Implemented, "roleplay", "crp"),
|
||||
new(Status.Implemented, "society", "soc"),
|
||||
new(Status.Implemented, "olthoi", "o"),
|
||||
|
||||
// §2.5 Allegiance management (6; "ab" already counted in §2.3)
|
||||
new(Status.Implemented, "allegiance", "all"),
|
||||
new(Status.Implemented, "alh", "ah"),
|
||||
new(Status.ServerPassthrough, "motd"), // TS-68
|
||||
new(Status.Implemented, "speaker"),
|
||||
|
||||
// §2.5b Housing (7)
|
||||
new(Status.Implemented, "house", "hou"),
|
||||
new(Status.Implemented, "hor", "hr"),
|
||||
new(Status.Implemented, "hom", "hoa"),
|
||||
new(Status.Implemented, "hslist"),
|
||||
|
||||
// §2.6 Death / recall / PK (17)
|
||||
new(Status.Implemented, "lifestone", "lif", "ls"),
|
||||
new(Status.Implemented, "marketplace", "mar", "mp"),
|
||||
new(Status.Implemented, "pkarena", "pka"),
|
||||
new(Status.Implemented, "pklarena", "pla"),
|
||||
new(Status.Implemented, "pklite", "pkl"),
|
||||
new(Status.Implemented, "die"),
|
||||
new(Status.Implemented, "corpse", "cor"),
|
||||
new(Status.Implemented, "consent"),
|
||||
new(Status.Implemented, "permit"),
|
||||
|
||||
// §2.7 Status / display (8)
|
||||
new(Status.Implemented, "age"),
|
||||
new(Status.Implemented, "birth"),
|
||||
new(Status.ServerPassthrough, "day"), // TS-69 — no sky/time-of-day override hook
|
||||
new(Status.Implemented, "endurance"),
|
||||
new(Status.Implemented, "framerate"),
|
||||
new(Status.Implemented, "loc"),
|
||||
new(Status.Implemented, "version"),
|
||||
new(Status.ServerPassthrough, "render"), // TS-69 — no SmartBox equivalent
|
||||
|
||||
// §2.8 Interface layout, emotes, social, components (18)
|
||||
new(Status.Implemented, "saveui"),
|
||||
new(Status.Implemented, "loadui"),
|
||||
new(Status.Implemented, "saveautoui"),
|
||||
new(Status.Implemented, "loadautoui"),
|
||||
new(Status.Implemented, "lockui"),
|
||||
new(Status.Implemented, "emote", "e", "em", "me"),
|
||||
new(Status.Implemented, "emotes"),
|
||||
new(Status.Implemented, "afk"),
|
||||
new(Status.Implemented, "friends"),
|
||||
new(Status.Implemented, "friends_add"),
|
||||
new(Status.Implemented, "friends_remove"),
|
||||
new(Status.Implemented, "squelch"),
|
||||
new(Status.Implemented, "unsquelch"),
|
||||
new(Status.Implemented, "fillcomps"),
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> CatalogVerbs =
|
||||
new(RetailClientCommandCatalog.KnownVerbs, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsExecutable(string verb)
|
||||
{
|
||||
if (verb is "help" or "?")
|
||||
return true; // local presentation — not a catalog/parser entry.
|
||||
if (CatalogVerbs.Contains(verb))
|
||||
return true;
|
||||
if (ChatInputParser.IsKnownVerb("/" + verb))
|
||||
return true;
|
||||
if (RetailChannelTagTable.IsUnregisteredFallbackTag(verb))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> AllVerbsWithStatus() =>
|
||||
Registry.SelectMany(entry => entry.Verbs.Select(verb => new object[] { verb, entry.Status }));
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllVerbsWithStatus))]
|
||||
public void EveryRegistryVerb_HasTheCorrectOwnershipStatus(string verb, object statusObj)
|
||||
{
|
||||
var status = (Status)statusObj;
|
||||
bool executable = IsExecutable(verb);
|
||||
if (status == Status.Implemented)
|
||||
{
|
||||
Assert.True(executable,
|
||||
$"'{verb}' is marked Implemented in the registry but no production surface " +
|
||||
"(RetailClientCommandCatalog, ChatInputParser, or RetailChannelTagTable) recognizes it.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(executable,
|
||||
$"'{verb}' is marked {status} (must fall through to server passthrough) but a " +
|
||||
"production surface claims to execute it locally — that's a real behavior change " +
|
||||
"the registry doesn't know about yet.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_EnumeratesExactly152Verbs()
|
||||
{
|
||||
int total = Registry.Sum(entry => entry.Verbs.Length);
|
||||
Assert.Equal(152, total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_StatusCountsMatchTheAuditedTotals()
|
||||
{
|
||||
Assert.Equal(9, Registry.Where(e => e.Status == Status.HelpOnly).Sum(e => e.Verbs.Length));
|
||||
Assert.Equal(5, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length));
|
||||
Assert.Equal(138, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoDuplicateVerbsAcrossEntries()
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Entry entry in Registry)
|
||||
{
|
||||
foreach (string verb in entry.Verbs)
|
||||
{
|
||||
Assert.True(seen.Add(verb), $"'{verb}' appears more than once in the registry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ownership-rule enforcement, reverse direction ──────────────────
|
||||
//
|
||||
// A verb RetailClientCommandCatalog or ChatInputParser claims to
|
||||
// execute that ISN'T in the registry above is exactly the "acdream
|
||||
// invented a verb retail doesn't have" class of bug the doc's §4
|
||||
// "candidates for removal" list called out (gen, cv, lookingforgroup,
|
||||
// tr, role, h) — deleted at CH4. This test fails the build the next
|
||||
// time one slips back in.
|
||||
|
||||
[Fact]
|
||||
public void RetailClientCommandCatalog_HasNoVerbsOutsideTheRegistry()
|
||||
{
|
||||
var registryVerbs = new HashSet<string>(
|
||||
Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (string verb in RetailClientCommandCatalog.KnownVerbs)
|
||||
{
|
||||
Assert.True(registryVerbs.Contains(verb),
|
||||
$"RetailClientCommandCatalog recognizes '{verb}', which is not in the retail " +
|
||||
"command-registry doc — either it's a genuine retail verb missing from this " +
|
||||
"test's registry, or it's an invented alias that must be deleted.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatInputParser_HasNoVerbsOutsideTheRegistry()
|
||||
{
|
||||
var registryVerbs = new HashSet<string>(
|
||||
Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (string verbWithSlash in ChatInputParser.KnownVerbs)
|
||||
{
|
||||
string verb = verbWithSlash.TrimStart('/');
|
||||
Assert.True(registryVerbs.Contains(verb),
|
||||
$"ChatInputParser recognizes '{verbWithSlash}', which is not in the retail " +
|
||||
"command-registry doc — either it's a genuine retail verb missing from this " +
|
||||
"test's registry, or it's an invented alias that must be deleted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue