using System.Collections.Frozen; namespace AcDream.UI.Abstractions.Panels.Chat; /// /// Immutable catalog of commands the named retail client executes locally. /// This is intentionally separate from chat aliases and ACE server commands. /// /// /// Aliases and ownership come from the named-retail command table. Packet /// and local-state behavior is recorded in the command-family pseudocode /// notes under docs/research/. /// /// public static class RetailClientCommandCatalog { private sealed record Definition( ClientCommandId Command, string Usage, string HelpText, Func ValidateArguments, string? InvalidArgumentsText = null); /// A resolved retail command plus its raw, trimmed argument text. public readonly record struct Match( ClientCommandId Command, string Arguments, string Usage, bool HasValidArguments, string? InvalidArgumentsText); private static readonly Definition Lifestone = new( ClientCommandId.LifestoneRecall, Usage: "/lifestone", HelpText: "/lifestone (/lif, /ls) - Returns you to the last lifestone you used without killing you.", ValidateArguments: static arguments => arguments.Length == 0); private static readonly Definition Marketplace = NoArguments( ClientCommandId.MarketplaceRecall, "/marketplace", "/marketplace (/mar, /mp) - Teleports you to the Marketplace of Dereth."); private static readonly Definition PkArena = NoArguments( ClientCommandId.PkArenaRecall, "/pkarena", "/pkarena (/pka) - Teleports a Player Killer to the PK Arena."); private static readonly Definition PkLiteArena = NoArguments( ClientCommandId.PkLiteArenaRecall, "/pklarena", "/pklarena (/pla) - Teleports a PKLite player to the PKLite Arena."); // ClientCommunicationSystem::DoPKLite/HelpPKLite @ 0x0057A490/0x0057A540. // Retail registers exactly one verb string ("pklite" @ 0x007E16B0) — // Campaign CH slice CH4 (2026-08-09) added the "pkl" alias per the // command-registry doc §2.6. private static readonly Definition PkLite = NoArguments( ClientCommandId.EnterPkLite, "/pklite", "@pklite (@pkl) - Sets your status to Player Killer Lite. Type @help pklite for more details."); private static readonly Definition HouseRecall = NoArguments( ClientCommandId.HouseRecall, "/house recall", "/house recall (/hor, /hr) - Teleports you to your house."); private static readonly Definition MansionRecall = NoArguments( ClientCommandId.MansionRecall, "/house mansion_recall", "/house mansion_recall (/hom, /hoa) - Teleports you to your allegiance mansion."); // GameActionHouseAbandon.Handle / retail help data_7dd3d0: // "@house abandon - Abandons your house.\n" private static readonly Definition HouseAbandon = NoArguments( ClientCommandId.HouseAbandon, "/house abandon", "@house abandon - Abandons your house."); private static readonly Definition QueryAge = NoArguments( ClientCommandId.QueryAge, "/age", "/age - Displays how long your character has been played."); private static readonly Definition QueryBirth = NoArguments( ClientCommandId.QueryBirth, "/birth", "/birth - Displays when your character was created."); private static readonly Definition FrameRate = NoArguments( ClientCommandId.ToggleFrameRate, "/framerate", "/framerate - Toggles the framerate display."); private static readonly Definition LockUi = NoArguments( ClientCommandId.ToggleUiLock, "/lockui", "/lockui - Toggles whether the interface can be moved or resized."); private static readonly Definition Version = NoArguments( ClientCommandId.ShowVersion, "/version", "/version - Displays the client version."); private static readonly Definition Location = NoArguments( ClientCommandId.ShowLocation, "/loc", "/loc - Displays your current position."); private static readonly Definition Corpse = new( ClientCommandId.ShowLastCorpseLocation, Usage: "/corpse", HelpText: "/corpse (/cor) - Displays the location of your last outdoor death.", // DoCorpse @ 0x00578220 ignores its argument count. ValidateArguments: static _ => true); private static readonly Definition Die = new( ClientCommandId.Die, Usage: "/die", HelpText: "/die - Kills your character after confirmation.", ValidateArguments: static arguments => arguments.Length == 0, InvalidArgumentsText: "Please see @help die for more information on how to use this command."); private static readonly Definition Clear = AnyArguments( ClientCommandId.ClearChat, "/clear [all]", "/clear [all] - Clears the current chat window, or every chat window."); private static readonly Definition SaveUi = AnyArguments( ClientCommandId.SaveUi, "/saveui [filename]", "/saveui [filename] - Saves the current interface layout."); private static readonly Definition LoadUi = AnyArguments( ClientCommandId.LoadUi, "/loadui [filename]", "/loadui [filename] - Loads a saved interface layout."); private static readonly Definition SaveAutoUi = AnyArguments( ClientCommandId.SaveAutoUi, "/saveautoui", "/saveautoui - Saves the automatic character-and-resolution interface layout."); private static readonly Definition LoadAutoUi = AnyArguments( ClientCommandId.LoadAutoUi, "/loadautoui", "/loadautoui - Loads the automatic character-and-resolution interface layout."); private static readonly Definition Away = AnyArguments( ClientCommandId.Away, "/afk [on|off|msg ]", "/afk [on|off|msg ] - Sets your away-from-keyboard status."); private static readonly Definition Consent = AnyArguments( ClientCommandId.Consent, "/consent >", "/consent - Manages corpse-looting consent."); private static readonly Definition Emote = AnyArguments( ClientCommandId.Emote, "/emote ", "/emote (/e, /em, /me) - Performs a text emote."); private static readonly Definition Emotes = NoArguments( ClientCommandId.ListEmotes, "/emotes", "/emotes - Lists all standard emotes."); private static readonly Definition Friends = AnyArguments( ClientCommandId.Friends, "/friends [add|remove|online|old]", "/friends - Helps you manage your friends list."); private static readonly Definition FriendsAdd = AnyArguments( ClientCommandId.FriendsAdd, "/friends_add ", "/friends_add - Adds a character to your friends list."); private static readonly Definition FriendsRemove = AnyArguments( ClientCommandId.FriendsRemove, "/friends_remove ", "/friends_remove - Removes friends from your list."); private static readonly Definition Squelch = AnyArguments( ClientCommandId.Squelch, "/squelch [options] ", "/squelch - Ignores messages from a player or account."); private static readonly Definition Unsquelch = AnyArguments( ClientCommandId.Unsquelch, "/unsquelch [options] ", "/unsquelch - Stops ignoring messages from a player or account."); private static readonly Definition Filter = AnyArguments( ClientCommandId.Filter, "/filter -", "/filter - - Globally hides a message category."); private static readonly Definition Unfilter = AnyArguments( ClientCommandId.Unfilter, "/unfilter -", "/unfilter - - Shows a globally hidden message category."); // Campaign CH slice CH4 (2026-08-09): retail registers exactly one // handler (DoMessageTypes @ 0x0057A010) under four verb strings — // "messagetypes", "message_types", "msgtypes", "msg_types" — all // aliases of the SAME definition, not separate commands. private static readonly Definition MessageTypes = NoArguments( ClientCommandId.ListMessageTypes, "/messagetypes", "/messagetypes (/message_types, /msgtypes, /msg_types) - Lists valid filter and squelch message types."); private static readonly Definition FillComponents = AnyArguments( ClientCommandId.FillComponents, "/fillcomps [component type] [pyreal value]", "/fillcomps - Helps you buy components in bulk."); // ── Campaign CH slice CH4 (2026-08-09) additions ──────────────────── // ClientCommunicationSystem::DoEndurance @ 0x0057C5F0. Exact retail // text extracted from acclient_2013_pseudo_c.txt:1031097 // (data_7de2f8) — the first paragraph only; the full multi-paragraph // block is reproduced verbatim by DoEndurance itself and is long // enough that only the opening line is duplicated here as a teaser — // this is the SAME text callers already see through // ClientCommandController. private static readonly Definition Endurance = NoArguments( ClientCommandId.Endurance, "/endurance", "The endurance attribute has a number of abilities tied to it. Type @help endurance for the full description."); // ClientCommunicationSystem::DoSpeaker @ 0x0057DAB0. Exact retail text: // acclient_2013_pseudo_c.txt:393309 / 1031426 (data_7e0cd8). private static readonly Definition Speaker = NoArguments( ClientCommandId.Speaker, "/speaker", "This command is no longer in use, please see @allegiance officer."); // ClientCommunicationSystem::DoTitle @ 0x0057A640. Exact retail help: // acclient_2013_pseudo_c.txt:1031162 (data_7df2c4) — "@title - Sets the title of the popup chat window.\n". No confirmation // text was found at the success site; acdream's binding is a pure // no-op (the value is neither stored nor consumed — no title-bar // chrome exists to render it yet, AP-182; corrected 2026-08-09 at the // CH4 REJECT-review nit 11, which found the earlier "acdream stores // the title" wording false). private static readonly Definition SetTitle = AnyArguments( ClientCommandId.SetChatTitle, "/title ", "@title - Sets the title of the popup chat window."); // ClientCommunicationSystem::DoChatToggle @ 0x0056FAD0 — Event_ // ModifyGlobalSquelch(remove, 2) for "on", (add, 2) for "off". Exact // retail help: acclient_2013_pseudo_c.txt:1030716/1030720. private static readonly Definition ChatToggle = new( ClientCommandId.ChatToggle, Usage: "/chat ", HelpText: "@chat - Sets whether or not you receive normal chat. When set to \"off\", you will no longer receive any spoken speech (normal chat). However, you will still receive tells.", ValidateArguments: static arguments => arguments.Equals("on", StringComparison.OrdinalIgnoreCase) || arguments.Equals("off", StringComparison.OrdinalIgnoreCase)); // ClientCommunicationSystem::DoNoTell @ 0x0056FBD0 — same mechanism, // message type 3 (Tell). Exact retail help: // acclient_2013_pseudo_c.txt:1030724/1030728. private static readonly Definition NoTellToggle = new( ClientCommandId.NoTellToggle, Usage: "/notell ", HelpText: "@notell - Sets whether or not you receive @tells. When set to \"on\", you will not receive any tells.", ValidateArguments: static arguments => arguments.Equals("on", StringComparison.OrdinalIgnoreCase) || arguments.Equals("off", StringComparison.OrdinalIgnoreCase)); /// /// Tags accepted by @join/@leave mapped to the linear /// SetSingleCharacterOption (0x0005) option id — retail /// PlayerModule::SetHear*Chat flags, per /// AcDream.Core.Net.Messages.CharacterOptionId. /// public static bool TryResolveJoinLeaveOption(string tag, out uint optionId) => JoinLeaveTags.TryGetValue(tag.Trim(), out optionId); private static readonly FrozenDictionary JoinLeaveTags = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["allegiance"] = 0x1Bu, // CharacterOptionId.ListenToAllegianceChat ["general"] = 0x23u, // CharacterOptionId.ListenToGeneralChat ["trade"] = 0x24u, // CharacterOptionId.ListenToTradeChat ["lfg"] = 0x25u, // CharacterOptionId.ListenToLFGChat ["roleplay"] = 0x26u, // CharacterOptionId.ListenToRoleplayChat ["society"] = 0x2Eu, // CharacterOptionId.ListenToSocietyChat ["soc"] = 0x2Eu, }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); // ClientCommunicationSystem::DoJoinChat/DoLeaveChat @ 0x0056F510/ // 0x0056F7F0. Tags per the registry doc §2.2: Allegiance, General, // Trade, LFG, Roleplay, Society, Soc. Exact retail help: // acclient_2013_pseudo_c.txt:1030689/1030693. private static readonly Definition JoinChannel = new( ClientCommandId.JoinChannel, Usage: "/join ", HelpText: "@join - Allows you to hear and speak on the given channel.", ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim())); private static readonly Definition LeaveChannel = new( ClientCommandId.LeaveChannel, Usage: "/leave ", HelpText: "@leave - Prevents you from hearing or speaking on the given channel.", ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim())); // ClientCommunicationSystem::DoPermit @ 0x005785A0. Exact retail help: // acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8). CH4 // REJECT-review SHOULD-FIX 5 (2026-08-09): DoPermit joins every token // after "add"/"remove" into the name (JoinArgsAsName) so a multi-word // character name works — "@permit add Aunt Agatha" grants Aunt Agatha, // not just "Aunt". The old exactly-2-tokens gate rejected that input // outright; the shape check now only requires a mode word plus AT // LEAST one more token, and ExecutePermit // (ClientCommandController.cs) joins the remainder. private static readonly Definition Permit = new( ClientCommandId.Permit, Usage: "/permit ", HelpText: "@permit add - Allows another player to loot your corpse. @permit remove - Removes permission to access your corpse from the named character.", ValidateArguments: static arguments => { string[] parts = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); return parts.Length >= 2 && (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase) || parts[0].Equals("remove", StringComparison.OrdinalIgnoreCase)); }); /// /// Retail house-type spellings mapped to ACE's HouseType enum /// value (the @hslist/ListAvailableHouses payload). /// public static bool TryResolveHouseType(string type, out uint houseType) => HouseTypes.TryGetValue(type.Trim(), out houseType); private static readonly FrozenDictionary HouseTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["cottage"] = 1u, ["villa"] = 2u, ["mansion"] = 3u, ["apartment"] = 4u, }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); // ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510. Exact // retail help: acclient_2013_pseudo_c.txt:1031049 (data_7dd9d0). // #363 / register row AP-183: the bad-args refusal is retail's own // specific string too, verified at acclient_2013_pseudo_c.txt:381481 // (AddTextToScroll(..., 0x1a, ...)), full text at // acclient_2013_pseudo_c.txt:1029383 (data_7d0a98) — NOT the // acdream-synthesized "Usage: /hslist " line this // Definition used to fall back to. private static readonly Definition HouseAvailableList = new( ClientCommandId.HouseAvailableList, Usage: "/hslist ", HelpText: "@hslist - Lists the number and, if appropriate, positions of houses currently available for purchase. Types include: Apartment, Cottage, Villa, Mansion", ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim()), InvalidArgumentsText: "Please see @help hslist for more information on how to use this command"); // ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help // string was extracted for the bare form; the verb is admin/advocate/ // PSR gated server-side (GameActionChannelIndex.Handle). CH4 // REJECT-review nit 14 (2026-08-09): DoChannelIndex ignores its argc — // "@index foo" sends the SAME Event_ChannelIndex() as bare "@index" — // so acdream must accept (and discard) any arguments too, not just none. private static readonly Definition IndexChannels = AnyArguments( ClientCommandId.IndexChannels, "/index", "@index - Requests the channel index (restricted)."); // ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. Exact retail // no-arg text: acclient_2013_pseudo_c.txt:1031202 (data_7dfaf8) // "Please specify the channel name." CH4 REJECT-review SHOULD-FIX 6 // (2026-08-09): retail's own argc check is "!= 1" — a resolved-but- // UNKNOWN tag still reaches the handler and raises // HandleFailureEvent(0x422) ("That channel doesn't exist.", // WeenieErrorMessages[0x422]); only a MISSING or MULTI-WORD argument // prints this usage line locally. Using tag resolution itself as the // argument-shape gate (the old behavior) silently swallowed an unknown // tag instead of raising 0x422 — see ClientCommandController's // dispatch (ListChannel/OnChannel/OffChannel cases) for the // ShowWeenieError(0x422) call this shape-only gate now allows through. private static readonly Definition ListChannel = new( ClientCommandId.ListChannel, Usage: "/clist ", HelpText: "@clist - Requests the member list of a channel (restricted).", ValidateArguments: static arguments => IsSingleToken(arguments), InvalidArgumentsText: "Please specify the channel name."); private static readonly Definition OnChannel = new( ClientCommandId.OnChannel, Usage: "/on ", HelpText: "@on - Joins a channel (restricted).", ValidateArguments: static arguments => IsSingleToken(arguments), InvalidArgumentsText: "Please specify the channel name."); private static readonly Definition OffChannel = new( ClientCommandId.OffChannel, Usage: "/off ", HelpText: "@off - Leaves a channel (restricted).", ValidateArguments: static arguments => IsSingleToken(arguments), InvalidArgumentsText: "Please specify the channel name."); private static bool IsSingleToken(string arguments) => arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length == 1; // GameActionRecallAllegianceHometown.Handle. Exact retail help: // acclient_2013_pseudo_c.txt:1031230 — "@allegiance hometown - // Recalls you to your allegiance bindstone, if your allegiance has // tied to one.\n" private static readonly Definition AllegianceHometown = NoArguments( ClientCommandId.AllegianceHometown, "/alh", "@allegiance hometown (@alh, @ah) - Recalls you to your allegiance bindstone, if your allegiance has tied to one."); // GameActionAllegianceInfoRequest.Handle — String16L name, empty = self. // Exact retail help: acclient_2013_pseudo_c.txt:1031214 — // "@allegiance info - Requests information on a member of your // allegiance.\n" private static readonly Definition AllegianceInfo = AnyArguments( ClientCommandId.AllegianceInfo, "/allegiance info [name]", "@allegiance info - Requests information on a member of your allegiance."); // ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0. Exact retail // text: acclient_2013_pseudo_c.txt:1031375 (data_7e0bd0) — "Please see // @help Allegiance for more information on how to use this command.". // Printed at label_57da4b (0x0057DA4B) whenever NO subcommand string // matches ANY of the 12 retail dispatches (boot/info/chat/broadcast/ // ban/officer/title/hometown/ho/motd/name/lock/house) — retail keeps // this ENTIRELY client-side; DoAllegiance never falls through to // DoChannelCommand or the server for an unrecognized subcommand. CH4 // REJECT-review Blocker 1 (2026-08-09): acdream previously let an // unmatched subcommand escape TryMatchAllegiance (return false, "not // owned"), which fell all the way through to the unregistered-tag // channel-fallback and broadcast the raw subcommand text ("boot Bob") // to the legacy Allegiance channel (0x02000000) — a real chat-visible // bug. TryMatchAllegiance below now claims ownership of "allegiance"/ // "all" UNCONDITIONALLY, exactly like retail's registered-command hash // table does, and shows this refusal for every subcommand beyond the // 2 ported ones (info/hometown/ho — TS-68 tracks the other 10). private static readonly Definition AllegianceUnrecognizedSubcommand = new( ClientCommandId.AllegianceUnrecognizedSubcommand, Usage: "/allegiance ", HelpText: "Please see @help Allegiance for more information on how to use this command.", ValidateArguments: static _ => false, InvalidArgumentsText: "Please see @help Allegiance for more information on how to use this command."); private static readonly FrozenDictionary ByVerb = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["lifestone"] = Lifestone, ["lif"] = Lifestone, ["ls"] = Lifestone, ["marketplace"] = Marketplace, ["mar"] = Marketplace, ["mp"] = Marketplace, ["pkarena"] = PkArena, ["pka"] = PkArena, ["pklarena"] = PkLiteArena, ["pla"] = PkLiteArena, ["pklite"] = PkLite, ["pkl"] = PkLite, ["hor"] = HouseRecall, ["hr"] = HouseRecall, ["hom"] = MansionRecall, ["hoa"] = MansionRecall, ["age"] = QueryAge, ["birth"] = QueryBirth, ["framerate"] = FrameRate, ["lockui"] = LockUi, ["version"] = Version, ["loc"] = Location, ["corpse"] = Corpse, ["cor"] = Corpse, ["die"] = Die, ["clear"] = Clear, ["saveui"] = SaveUi, ["loadui"] = LoadUi, ["saveautoui"] = SaveAutoUi, ["loadautoui"] = LoadAutoUi, ["afk"] = Away, ["consent"] = Consent, ["e"] = Emote, ["em"] = Emote, ["emote"] = Emote, ["me"] = Emote, ["emotes"] = Emotes, ["friends"] = Friends, ["friends_add"] = FriendsAdd, ["friends_remove"] = FriendsRemove, ["squelch"] = Squelch, ["unsquelch"] = Unsquelch, ["filter"] = Filter, ["unfilter"] = Unfilter, ["messagetypes"] = MessageTypes, ["message_types"] = MessageTypes, ["msgtypes"] = MessageTypes, ["msg_types"] = MessageTypes, ["fillcomps"] = FillComponents, ["endurance"] = Endurance, ["speaker"] = Speaker, ["title"] = SetTitle, ["chat"] = ChatToggle, ["notell"] = NoTellToggle, ["join"] = JoinChannel, ["leave"] = LeaveChannel, ["permit"] = Permit, ["hslist"] = HouseAvailableList, ["index"] = IndexChannels, ["clist"] = ListChannel, ["on"] = OnChannel, ["off"] = OffChannel, ["alh"] = AllegianceHometown, ["ah"] = AllegianceHometown, }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); /// /// Resolve a complete chat-bar line. Returns false when its verb is not /// client-owned; callers can then try chat aliases or the server-command /// path. Both retail command prefixes are accepted. /// public static bool TryMatch(string input, out Match match) { match = default; if (string.IsNullOrWhiteSpace(input)) return false; string trimmed = input.Trim(); if (trimmed.Length < 2 || trimmed[0] is not ('/' or '@')) return false; int separator = IndexOfWhitespace(trimmed); string verb = separator < 0 ? trimmed[1..] : trimmed.Substring(1, separator - 1); // Retail DoCommand @ 0x0057E2E0 right-trims ',' off the verb token // before lookup (trim char set 0x0079452C) — "@f, hi" resolves the // SAME as "@f hi". Campaign CH slice CH4 (2026-08-09). verb = verb.TrimEnd(','); string arguments = separator < 0 ? string.Empty : trimmed[(separator + 1)..].Trim(); Definition? definition; if (verb.Equals("house", StringComparison.OrdinalIgnoreCase) || verb.Equals("hou", StringComparison.OrdinalIgnoreCase)) { return TryMatchHouse(arguments, out match); } if (verb.Equals("allegiance", StringComparison.OrdinalIgnoreCase) || verb.Equals("all", StringComparison.OrdinalIgnoreCase)) { return TryMatchAllegiance(arguments, out match); } if (!ByVerb.TryGetValue(verb, out definition)) { return false; } match = new Match( definition.Command, arguments, definition.Usage, definition.ValidateArguments(arguments), definition.InvalidArgumentsText); return true; } /// /// @house <sub> / @hou <sub> dispatcher. /// Retail's real DoHouse @ 0x00580860 handles 15 subcommands /// (see the registry doc §2.5b) locally; acdream Campaign CH slice CH4 /// (2026-08-09) ports 4 of them (recall/re, mansion_recall/alleg_recall/ /// ma, abandon). Every OTHER subcommand — open, close, storage, remove, /// boot, boot_all, remove_all, guest, available, hooks, on, off, and /// any misspelling of the 4 ported ones — returns false /// uniformly (there is no separate local-swallow branch; CH4 /// REJECT-review nit 10, 2026-08-09, corrected this comment, which /// previously described a swallow path that does not exist in the code /// below), letting fall through to /// server passthrough (ACE replies "Unknown command") rather than /// being swallowed locally with a wrong usage message — the Tier-1 #4 /// fix from the command-registry doc. The 12 unported subcommands are /// tracked by TS-68. /// private static bool TryMatchHouse(string arguments, out Match match) { match = default; string subcommand = arguments.ToLowerInvariant(); Definition? definition = subcommand switch { "recall" or "re" => HouseRecall, "mansion_recall" or "alleg_recall" or "ma" => MansionRecall, "abandon" => HouseAbandon, _ => null, }; if (definition is null) return false; match = new Match( definition.Command, Arguments: string.Empty, definition.Usage, HasValidArguments: true, InvalidArgumentsText: null); return true; } /// /// @allegiance <sub> / @all <sub> dispatcher. /// Retail's real DoAllegiance @ 0x0057D5A0 handles 12 /// subcommands (see the registry doc §2.5) locally; acdream Campaign CH /// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho). /// /// /// CH4 REJECT-review Blocker 1 correction (2026-08-09): every /// OTHER subcommand — boot, ban, officer, title, name, lock, house, /// motd, chat, broadcast, or garbage — is NOT yet ported (TS-68), but /// unlike this method NEVER returns /// false for the "allegiance"/"all" verb: retail's own /// DoAllegiance claims the ENTIRE verb unconditionally and /// prints its own client-local refusal /// () for an unrecognized /// subcommand — it never falls through to DoChannelCommand or /// the server. The original CH4 implementation returned false /// here (matching 's reasoning), which let /// an unmatched subcommand escape all the way to the unregistered-tag /// channel-fallback and broadcast the raw text to the Allegiance /// channel — a real bug, not merely an incomplete port. /// private static bool TryMatchAllegiance(string arguments, out Match match) { int separator = IndexOfWhitespace(arguments); string subcommand = separator < 0 ? arguments : arguments[..separator]; string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim(); if (subcommand.Equals("hometown", StringComparison.OrdinalIgnoreCase) || subcommand.Equals("ho", StringComparison.OrdinalIgnoreCase)) { match = new Match( AllegianceHometown.Command, Arguments: string.Empty, AllegianceHometown.Usage, HasValidArguments: true, InvalidArgumentsText: null); return true; } if (subcommand.Equals("info", StringComparison.OrdinalIgnoreCase)) { match = new Match( AllegianceInfo.Command, rest, AllegianceInfo.Usage, HasValidArguments: true, InvalidArgumentsText: null); return true; } // Every other subcommand (or none at all) — claim ownership // anyway and show retail's own refusal text. See the remarks // above; this is what stops "allegiance"/"all" from ever reaching // ChatCommandRouter's channel-fallback or server-passthrough path. match = new Match( AllegianceUnrecognizedSubcommand.Command, arguments, AllegianceUnrecognizedSubcommand.Usage, HasValidArguments: false, AllegianceUnrecognizedSubcommand.InvalidArgumentsText); return true; } /// /// Every verb string this catalog dispatches, INCLUDING the /// specially-parsed "house"/"hou"/"allegiance"/"all" verbs (which are /// not literal keys of the backing dictionary because their dispatch /// depends on the subcommand). Used by the CH4 conformance test to /// enforce the ownership rule in both directions: every retail-registry /// verb this catalog claims must actually be in the registry, and vice /// versa. /// // CH4 re-review nit 6 (2026-08-09): FrozenSet with an explicit // OrdinalIgnoreCase comparer, matching ByVerb/JoinLeaveTags/HouseTypes // above — callers (ChatCommandRouter.TryDispatchChannelFallback, the // CH4 conformance test) already treat this collection as // case-insensitive; the array was doing that per-call via LINQ's // Contains(item, comparer) overload instead of baking it into the set. public static IReadOnlyCollection KnownVerbs { get; } = ByVerb.Keys.Concat(["house", "hou", "allegiance", "all"]) .ToFrozenSet(StringComparer.OrdinalIgnoreCase); /// /// /help <verb> lookup for a catalog-dispatched command — /// retail's DoHelp @ 0x0057F9E0 looking up the verb's registered /// help callback. Does not cover chat-alias or channel verbs (see /// for those) nor the deferred /// allegiance/house subcommand overviews (also /// ). /// public static bool TryGetHelpText(string verb, out string helpText) { string trimmedVerb = verb.TrimEnd(','); if (trimmedVerb.Equals("house", StringComparison.OrdinalIgnoreCase) || trimmedVerb.Equals("hou", StringComparison.OrdinalIgnoreCase)) { helpText = RetailCommandHelpTable.HouseOverview; return true; } if (trimmedVerb.Equals("allegiance", StringComparison.OrdinalIgnoreCase) || trimmedVerb.Equals("all", StringComparison.OrdinalIgnoreCase)) { helpText = RetailCommandHelpTable.AllegianceOverview; return true; } if (ByVerb.TryGetValue(trimmedVerb, out Definition? definition)) { helpText = definition.HelpText; return true; } helpText = string.Empty; return false; } private static Definition NoArguments( ClientCommandId command, string usage, string helpText) => new(command, usage, helpText, static arguments => arguments.Length == 0); private static Definition AnyArguments( ClientCommandId command, string usage, string helpText) => new(command, usage, helpText, static _ => true); private static int IndexOfWhitespace(string value) { for (int i = 1; i < value.Length; i++) { if (char.IsWhiteSpace(value[i])) return i; } return -1; } }