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:
Erik 2026-08-09 21:10:17 +02:00
parent 9247d5d5b5
commit 090825e703
23 changed files with 2069 additions and 97 deletions

View file

@ -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"));
}
}

View file

@ -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

View file

@ -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"

View file

@ -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)
{

View file

@ -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()
{

View file

@ -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.");
}
}
}