Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped TryMatchAllegiance (which only claimed "info"/"hometown") and fell through the unregistered-tag channel fallback, broadcasting the raw subcommand text to the Allegiance chat channel (0x02000000). Retail's own DoAllegiance never reaches DoChannelCommand for an unrecognized subcommand — it claims the whole verb and prints its own client-local refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership guard in TryDispatchChannelFallback as defense in depth. Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation. Retail runs a real two-stage dialog before Event_AbandonHouse(); ported both verbatim strings and chained two ShowConfirmation calls. Should-fixes: a bare unregistered tag with no text now passes through silently instead of showing a refusal that belongs to a different retail function; @join/@leave update RuntimeCharacterOptionsState locally (new SetOptionBit) before the wire push so the Turbine membership gate stops refusing a just-joined room; @permit accepts multi-word names; @clist/ @on/@off validate shape only and raise WeenieError 0x422 for an unknown tag; @mr/@pr help text is now the verbatim retail strings; corrected issue #360, register row TS-68, the campaign doc's B.7 note, and a stale RetailChannelTagTable comment; filed issue #363 + register row AP-183 for the deferred error-typing debt. Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's olthoi false-positive, added /g and /rp binding-level conformance pins, made @index ignore extra arguments, and noted the six removed invented verbs in ISSUES.md. Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's 12,190/4/0 — net +26 tests, no removals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
13 KiB
C#
300 lines
13 KiB
C#
using AcDream.UI.Abstractions;
|
|
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.");
|
|
}
|
|
}
|
|
|
|
// ── CH4 REJECT-review nit 13 (2026-08-09) ───────────────────────────
|
|
//
|
|
// The tests above only prove a verb STRING is recognized somewhere —
|
|
// not which channel it actually resolves to. A rebind regression (e.g.
|
|
// "/g" quietly reverting to General, the exact Tier-1 #1 bug this
|
|
// campaign fixed) would still pass every case above. These two pin the
|
|
// real dispatch outcome so a rebind regression fails THIS suite, not
|
|
// just a narrower parser-only test.
|
|
|
|
[Fact]
|
|
public void GVerb_BindsToFellowship_NotGeneral()
|
|
{
|
|
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
|
|
"/g hi gang", ChatChannelKind.Say, lastTellSender: null);
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel);
|
|
}
|
|
|
|
[Fact]
|
|
public void RpVerb_BindsToReply_NotRoleplay()
|
|
{
|
|
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
|
|
"/rp hello back", ChatChannelKind.Say, lastTellSender: "Aunt Agatha");
|
|
|
|
Assert.NotNull(parsed);
|
|
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
|
Assert.Equal("Aunt Agatha", parsed!.Value.TargetName);
|
|
}
|
|
}
|