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
|
|
@ -39,29 +39,61 @@ public static class ChatInputParser
|
|||
// Alias tables. Order matters only for error messages — verb
|
||||
// matching is exact-token, not prefix.
|
||||
private static readonly string[] SayAliases = { "/say", "/s" };
|
||||
private static readonly string[] TellAliases = { "/tell", "/t" };
|
||||
private static readonly string[] ReplyAliases = { "/reply", "/r" };
|
||||
// Campaign CH slice CH4 (2026-08-09): retail's DoTell @0x00577E40
|
||||
// registers "tell" under FOUR verb strings — tell/t/send/whisper/w —
|
||||
// per the command-registry doc §2.2.
|
||||
private static readonly string[] TellAliases = { "/tell", "/t", "/send", "/whisper", "/w" };
|
||||
// Campaign CH slice CH4 (2026-08-09): retail's DoReply @0x00577910
|
||||
// registers "reply" under THREE verb strings — reply/r/rp — confirmed
|
||||
// by retail's own help text ("You may also use @r or @rp",
|
||||
// acclient_2013_pseudo_c.txt:1030742). "/rp" moved here from the
|
||||
// Roleplay channel table below, where it was a Tier-1 correctness bug
|
||||
// (a private reply becoming a global Roleplay broadcast).
|
||||
private static readonly string[] ReplyAliases = { "/reply", "/r", "/rp" };
|
||||
// Phase J Tier 2: /retell <msg> — resend to last person YOU tell'd.
|
||||
// Mirrors retail's @retell. Distinct from /reply which targets the
|
||||
// last person who tell'd US.
|
||||
private static readonly string[] RetellAliases = { "/retell" };
|
||||
// last person who tell'd US. Campaign CH slice CH4 added the "/rt"
|
||||
// alias — retail's DoReTell registers both "retell" and "rt".
|
||||
private static readonly string[] RetellAliases = { "/retell", "/rt" };
|
||||
|
||||
// Channel aliases. Each maps a single verb token to a channel kind.
|
||||
// The same list drives both the verb test and the prefix-strip.
|
||||
// Long-form aliases mirror retail muscle memory (e.g. "/allegiance"
|
||||
// for "/a", "/patron" for "/p"). Phase J added the long forms after
|
||||
// a 2026-04-25 live session showed "/patron hello" falling through
|
||||
// as plain Say with the literal "/patron " prefix.
|
||||
// Long-form aliases mirror retail muscle memory (e.g. "/patron" for
|
||||
// "/p"). Phase J added the long forms after a 2026-04-25 live session
|
||||
// showed "/patron hello" falling through as plain Say with the literal
|
||||
// "/patron " prefix.
|
||||
//
|
||||
// Campaign CH slice CH4 (2026-08-09) reconciled this table against the
|
||||
// retail command-registry doc §2.3/§2.4 (Tier 1 fixes #1-3, alias
|
||||
// sweep #16):
|
||||
// - "/g" moves from General to FELLOWSHIP (retail: g/group/party/
|
||||
// fellow/fellows/fellowship all bind Fellowship, 0x800). Sending
|
||||
// fellowship chatter to General was a live correctness bug.
|
||||
// - "/allegiance" is DELETED — retail's allegiance/all is the
|
||||
// allegiance MANAGEMENT COMMAND (RetailClientCommandCatalog),
|
||||
// not a channel verb. The channel-send verbs are a/ab/guild/gu.
|
||||
// - "/gen", "/cv", "/lookingforgroup", "/tr", "/role" are DELETED —
|
||||
// none are registered retail verbs (doc §4 "candidates for
|
||||
// removal").
|
||||
// - Missing retail aliases added: guild, gu (Allegiance Turbine);
|
||||
// co-vassals, covassal, c (CoVassals); vassal (Vassals); cg
|
||||
// (General Turbine); ct (Trade Turbine); crp (Roleplay Turbine);
|
||||
// clfg (LFG Turbine); soc (Society Turbine); o (Olthoi Turbine);
|
||||
// fellows, group, party (Fellowship).
|
||||
private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
|
||||
{
|
||||
("/g", ChatChannelKind.General),
|
||||
("/general", ChatChannelKind.General),
|
||||
("/gen", ChatChannelKind.General),
|
||||
("/cg", ChatChannelKind.General),
|
||||
("/f", ChatChannelKind.Fellowship),
|
||||
("/fellow", ChatChannelKind.Fellowship),
|
||||
("/fellows", ChatChannelKind.Fellowship),
|
||||
("/fellowship", ChatChannelKind.Fellowship),
|
||||
("/g", ChatChannelKind.Fellowship),
|
||||
("/group", ChatChannelKind.Fellowship),
|
||||
("/party", ChatChannelKind.Fellowship),
|
||||
("/a", ChatChannelKind.Allegiance),
|
||||
("/allegiance", ChatChannelKind.Allegiance),
|
||||
("/guild", ChatChannelKind.Allegiance),
|
||||
("/gu", ChatChannelKind.Allegiance),
|
||||
// CH3 (2026-08-09): retail's @ab — DoAllegianceBroadcast, the
|
||||
// legacy 0x02000000 monarch/speaker broadcast — confirmed against
|
||||
// the retail command registry (§2.3/§2.5). "/allegiancebroadcast"
|
||||
|
|
@ -74,18 +106,22 @@ public static class ChatInputParser
|
|||
("/p", ChatChannelKind.Patron),
|
||||
("/patron", ChatChannelKind.Patron),
|
||||
("/v", ChatChannelKind.Vassals),
|
||||
("/vassal", ChatChannelKind.Vassals),
|
||||
("/vassals", ChatChannelKind.Vassals),
|
||||
("/cv", ChatChannelKind.CoVassals),
|
||||
("/c", ChatChannelKind.CoVassals),
|
||||
("/covassal", ChatChannelKind.CoVassals),
|
||||
("/covassals", ChatChannelKind.CoVassals),
|
||||
("/co-vassals", ChatChannelKind.CoVassals),
|
||||
("/lfg", ChatChannelKind.Lfg),
|
||||
("/lookingforgroup", ChatChannelKind.Lfg),
|
||||
("/clfg", ChatChannelKind.Lfg),
|
||||
("/trade", ChatChannelKind.Trade),
|
||||
("/tr", ChatChannelKind.Trade),
|
||||
("/role", ChatChannelKind.Roleplay),
|
||||
("/rp", ChatChannelKind.Roleplay),
|
||||
("/ct", ChatChannelKind.Trade),
|
||||
("/crp", ChatChannelKind.Roleplay),
|
||||
("/roleplay", ChatChannelKind.Roleplay),
|
||||
("/society", ChatChannelKind.Society),
|
||||
("/soc", ChatChannelKind.Society),
|
||||
("/olthoi", ChatChannelKind.Olthoi),
|
||||
("/o", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -123,7 +159,7 @@ public static class ChatInputParser
|
|||
{
|
||||
string substituted = "/" + trimmed.Substring(1);
|
||||
string verb = ExtractVerb(substituted);
|
||||
if (AllKnownVerbs.Contains(verb))
|
||||
if (IsKnownVerb(verb))
|
||||
{
|
||||
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
|
||||
}
|
||||
|
|
@ -193,10 +229,17 @@ public static class ChatInputParser
|
|||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Match holtburger's <c>parse_targeted_chat_command</c>: split on
|
||||
/// first whitespace into verb / rest, check verb against aliases,
|
||||
/// then split rest into target / message. Returns false if either
|
||||
/// the verb is wrong or target / message is empty.
|
||||
/// Match holtburger's <c>parse_targeted_chat_command</c>, corrected for
|
||||
/// retail's ACTUAL <c>@tell</c> shape (Campaign CH slice CH4,
|
||||
/// 2026-08-09): split on first whitespace into verb / rest, check verb
|
||||
/// against aliases, then split rest on the FIRST COMMA into target /
|
||||
/// message — retail's <c>DoTell @ 0x00577E40</c> requires a comma after
|
||||
/// the name ("you must put a comma after the character's name",
|
||||
/// acclient_2013_pseudo_c.txt:1030771) precisely because names can be
|
||||
/// multiple words ("@tell Aunt Agatha, hello" addresses "Aunt Agatha").
|
||||
/// Splitting on the first WHITESPACE (the old behavior) truncated
|
||||
/// multi-word names to their first token. Returns false if either the
|
||||
/// verb is wrong, there's no comma, or target / message is empty.
|
||||
/// </summary>
|
||||
private static bool TryParseTargeted(string command, string[] aliases, out string target, out string message)
|
||||
{
|
||||
|
|
@ -206,25 +249,35 @@ public static class ChatInputParser
|
|||
int firstWs = IndexOfWhitespace(command);
|
||||
if (firstWs < 0) return false;
|
||||
|
||||
var verb = command.Substring(0, firstWs);
|
||||
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
||||
if (!ContainsExact(aliases, verb)) return false;
|
||||
|
||||
var rest = command.Substring(firstWs + 1).TrimStart();
|
||||
if (rest.Length == 0) return false;
|
||||
|
||||
int targetEnd = IndexOfWhitespace(rest);
|
||||
if (targetEnd < 0) return false; // target only, no message
|
||||
int commaIndex = rest.IndexOf(',');
|
||||
if (commaIndex < 0)
|
||||
{
|
||||
// No comma at all: retail's help text is explicit that one is
|
||||
// required. Fall back to the pre-CH4 whitespace split so a
|
||||
// single-word target typed without a comma ("/t Bestie hi")
|
||||
// still works — this is strictly more permissive than retail,
|
||||
// not less, and every existing single-word-target test still
|
||||
// passes. Pre-existing Phase I fix: strip trailing punctuation
|
||||
// other than comma too (":", ".", "!", "?", ";") for the same
|
||||
// "retail muscle memory" reason, now that comma itself is
|
||||
// handled by the branch above.
|
||||
int targetEnd = IndexOfWhitespace(rest);
|
||||
if (targetEnd < 0) return false; // target only, no message
|
||||
target = rest.Substring(0, targetEnd).TrimEnd(',', ';', ':', '.', '!', '?');
|
||||
message = rest.Substring(targetEnd + 1).TrimStart();
|
||||
}
|
||||
else
|
||||
{
|
||||
target = rest.Substring(0, commaIndex).TrimEnd();
|
||||
message = rest.Substring(commaIndex + 1).TrimStart();
|
||||
}
|
||||
|
||||
target = rest.Substring(0, targetEnd);
|
||||
message = rest.Substring(targetEnd + 1).TrimStart();
|
||||
// Phase I (post-launch fix): retail muscle memory is
|
||||
// "/t Name, message" — comma is the separator. Our split-on-
|
||||
// whitespace pulls "Name," (with trailing comma) as the target,
|
||||
// which then 0x052B-fails on the server lookup. Strip a
|
||||
// trailing punctuation from the target so both forms work:
|
||||
// "/t Caith hi" -> target="Caith"
|
||||
// "/t Caith, hi" -> target="Caith"
|
||||
target = target.TrimEnd(',', ';', ':', '.', '!', '?');
|
||||
if (target.Length == 0 || message.Length == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -240,7 +293,7 @@ public static class ChatInputParser
|
|||
int firstWs = IndexOfWhitespace(command);
|
||||
if (firstWs < 0) return false;
|
||||
|
||||
var verb = command.Substring(0, firstWs);
|
||||
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
||||
if (!ContainsExact(aliases, verb)) return false;
|
||||
|
||||
message = command.Substring(firstWs + 1).TrimStart();
|
||||
|
|
@ -253,8 +306,9 @@ public static class ChatInputParser
|
|||
/// </summary>
|
||||
private static bool IsBareVerb(string command, string[] aliases)
|
||||
{
|
||||
string trimmedVerb = TrimVerbComma(command);
|
||||
foreach (var alias in aliases)
|
||||
if (command == alias) return true;
|
||||
if (trimmedVerb == alias) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -268,7 +322,7 @@ public static class ChatInputParser
|
|||
{
|
||||
int firstWs = IndexOfWhitespace(command);
|
||||
if (firstWs < 0) return false;
|
||||
var verb = command.Substring(0, firstWs);
|
||||
var verb = TrimVerbComma(command.Substring(0, firstWs));
|
||||
if (!ContainsExact(aliases, verb)) return false;
|
||||
|
||||
var rest = command.Substring(firstWs + 1).TrimStart();
|
||||
|
|
@ -328,9 +382,27 @@ public static class ChatInputParser
|
|||
/// need to distinguish "unknown slash command" from "known
|
||||
/// verb with bad arguments" without reproducing the alias
|
||||
/// tables. <c>@</c>-prefixed verbs need to be normalized to
|
||||
/// <c>/</c> before passing.
|
||||
/// <c>/</c> before passing. Trims a trailing comma first — retail's
|
||||
/// <c>DoCommand @ 0x0057E2E0</c> right-trims <c>','</c> off the verb
|
||||
/// token before ANY lookup (Campaign CH slice CH4, 2026-08-09), so
|
||||
/// <c>"/f,"</c> is recognized exactly like <c>"/f"</c>.
|
||||
/// </summary>
|
||||
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(verb);
|
||||
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(TrimVerbComma(verb));
|
||||
|
||||
/// <summary>
|
||||
/// Every chat-alias / channel verb this parser recognizes (with
|
||||
/// leading <c>/</c>). Used by the CH4 conformance test to enforce the
|
||||
/// ownership rule in both directions.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> KnownVerbs => AllKnownVerbs;
|
||||
|
||||
/// <summary>
|
||||
/// Right-trim a trailing <c>','</c> from a verb token — retail's
|
||||
/// <c>DoCommand</c> trim-char set (<c>0x0079452C</c>, right-trim only)
|
||||
/// applied before every verb-hash-table lookup. <c>"@f, hi"</c> ≡
|
||||
/// <c>"@f hi"</c>.
|
||||
/// </summary>
|
||||
private static string TrimVerbComma(string verb) => verb.TrimEnd(',');
|
||||
|
||||
/// <summary>
|
||||
/// Pull the first whitespace-separated token (the command verb)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue