ChatVM gains an OnInterfaceText hook + ShowInterfaceText(text), the
App-layer composition wires it to RuntimeCommunicationState.AddText,
and ChatCommandRouter routes every retail-0x1A command refusal through
it instead of the chat log's 0x00 sink. UI.Abstractions still never
references Runtime directly; unwired hosts (headless, tests) fall back
to the chat log tagged ClientLocal so no text is ever silently lost.
Reclassified per register row AP-183 (DoChannelList/On/Off, DoAllegiance,
DoHouseAvailableList — the last also corrected to retail's own bad-house-
type string instead of a synthesized "Usage:" line) and newly wired two
sites that previously showed nothing at all (DoStupidChannelHack's bare
legacy-channel-verb refusal, DoReply's message-but-no-last-teller
refusal). The generic bad-args fallback now resolves WeenieErrorMessages
0x026 ("That is not a valid command.", retail's HandleFailureEvent(0x26))
instead of synthesizing "Usage: {Usage}". DoSpeaker/DoEndurance/DoTitle
are untouched — already correct at 0x00.
Also closes #367 (DoHelp's "Unknown command" fallback and the degenerate-
prefix refusal now reach the SpewBox too) and retires register row
AP-186, whose own filing proposed exactly this seam shape.
Full Release suite: 12,542 passed / 4 skipped / 0 failed (baseline
12,466/4/0 at ff2784ea).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
750 lines
34 KiB
C#
750 lines
34 KiB
C#
using System.Collections.Frozen;
|
|
|
|
namespace AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// Immutable catalog of commands the named retail client executes locally.
|
|
/// This is intentionally separate from chat aliases and ACE server commands.
|
|
///
|
|
/// <para>
|
|
/// Aliases and ownership come from the named-retail command table. Packet
|
|
/// and local-state behavior is recorded in the command-family pseudocode
|
|
/// notes under <c>docs/research/</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class RetailClientCommandCatalog
|
|
{
|
|
private sealed record Definition(
|
|
ClientCommandId Command,
|
|
string Usage,
|
|
string HelpText,
|
|
Func<string, bool> ValidateArguments,
|
|
string? InvalidArgumentsText = null);
|
|
|
|
/// <summary>A resolved retail command plus its raw, trimmed argument text.</summary>
|
|
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 <message>]",
|
|
"/afk [on|off|msg <message>] - Sets your away-from-keyboard status.");
|
|
|
|
private static readonly Definition Consent = AnyArguments(
|
|
ClientCommandId.Consent,
|
|
"/consent <on|off|who|clear|remove <name>>",
|
|
"/consent - Manages corpse-looting consent.");
|
|
|
|
private static readonly Definition Emote = AnyArguments(
|
|
ClientCommandId.Emote,
|
|
"/emote <text>",
|
|
"/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 <name>",
|
|
"/friends_add <name> - Adds a character to your friends list.");
|
|
|
|
private static readonly Definition FriendsRemove = AnyArguments(
|
|
ClientCommandId.FriendsRemove,
|
|
"/friends_remove <name|-all>",
|
|
"/friends_remove <name|-all> - Removes friends from your list.");
|
|
|
|
private static readonly Definition Squelch = AnyArguments(
|
|
ClientCommandId.Squelch,
|
|
"/squelch [options] <name>",
|
|
"/squelch - Ignores messages from a player or account.");
|
|
|
|
private static readonly Definition Unsquelch = AnyArguments(
|
|
ClientCommandId.Unsquelch,
|
|
"/unsquelch [options] <name>",
|
|
"/unsquelch - Stops ignoring messages from a player or account.");
|
|
|
|
private static readonly Definition Filter = AnyArguments(
|
|
ClientCommandId.Filter,
|
|
"/filter -<message type>",
|
|
"/filter -<message type> - Globally hides a message category.");
|
|
|
|
private static readonly Definition Unfilter = AnyArguments(
|
|
ClientCommandId.Unfilter,
|
|
"/unfilter -<message type>",
|
|
"/unfilter -<message type> - 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 <new
|
|
// 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 <new title>",
|
|
"@title <new 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 <on|off>",
|
|
HelpText: "@chat <on/off> - 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 <on|off>",
|
|
HelpText: "@notell <on/off> - 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));
|
|
|
|
/// <summary>
|
|
/// Tags accepted by <c>@join</c>/<c>@leave</c> mapped to the linear
|
|
/// <c>SetSingleCharacterOption (0x0005)</c> option id — retail
|
|
/// PlayerModule::SetHear*Chat flags, per
|
|
/// <c>AcDream.Core.Net.Messages.CharacterOptionId</c>.
|
|
/// </summary>
|
|
public static bool TryResolveJoinLeaveOption(string tag, out uint optionId) =>
|
|
JoinLeaveTags.TryGetValue(tag.Trim(), out optionId);
|
|
|
|
private static readonly FrozenDictionary<string, uint> JoinLeaveTags =
|
|
new Dictionary<string, uint>(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 <channel tag>",
|
|
HelpText: "@join <channel tag> - 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 <channel tag>",
|
|
HelpText: "@leave <channel tag> - 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 <add|remove> <name>",
|
|
HelpText: "@permit add <name> - Allows another player to loot your corpse. @permit remove <name> - 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));
|
|
});
|
|
|
|
/// <summary>
|
|
/// Retail house-type spellings mapped to ACE's <c>HouseType</c> enum
|
|
/// value (the <c>@hslist</c>/ListAvailableHouses payload).
|
|
/// </summary>
|
|
public static bool TryResolveHouseType(string type, out uint houseType) =>
|
|
HouseTypes.TryGetValue(type.Trim(), out houseType);
|
|
|
|
private static readonly FrozenDictionary<string, uint> HouseTypes =
|
|
new Dictionary<string, uint>(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 <house type>" line this
|
|
// Definition used to fall back to.
|
|
private static readonly Definition HouseAvailableList = new(
|
|
ClientCommandId.HouseAvailableList,
|
|
Usage: "/hslist <house type>",
|
|
HelpText: "@hslist <house type> - 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 <channel>",
|
|
HelpText: "@clist <channel> - 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 <channel>",
|
|
HelpText: "@on <channel> - 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 <channel>",
|
|
HelpText: "@off <channel> - 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 <name> - Requests information on a member of your
|
|
// allegiance.\n"
|
|
private static readonly Definition AllegianceInfo = AnyArguments(
|
|
ClientCommandId.AllegianceInfo,
|
|
"/allegiance info [name]",
|
|
"@allegiance info <name> - 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 <sub>",
|
|
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<string, Definition> ByVerb =
|
|
new Dictionary<string, Definition>(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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>@house <sub></c> / <c>@hou <sub></c> dispatcher.
|
|
/// Retail's real <c>DoHouse @ 0x00580860</c> 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 <c>false</c>
|
|
/// 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 <see cref="ChatCommandRouter"/> 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>@allegiance <sub></c> / <c>@all <sub></c> dispatcher.
|
|
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> 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).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>CH4 REJECT-review Blocker 1 correction (2026-08-09):</b> every
|
|
/// OTHER subcommand — boot, ban, officer, title, name, lock, house,
|
|
/// motd, chat, broadcast, or garbage — is NOT yet ported (TS-68), but
|
|
/// unlike <see cref="TryMatchHouse"/> this method NEVER returns
|
|
/// <c>false</c> for the "allegiance"/"all" verb: retail's own
|
|
/// <c>DoAllegiance</c> claims the ENTIRE verb unconditionally and
|
|
/// prints its own client-local refusal
|
|
/// (<see cref="AllegianceUnrecognizedSubcommand"/>) for an unrecognized
|
|
/// subcommand — it never falls through to <c>DoChannelCommand</c> or
|
|
/// the server. The original CH4 implementation returned <c>false</c>
|
|
/// here (matching <see cref="TryMatchHouse"/>'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.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
// 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<string> KnownVerbs { get; } =
|
|
ByVerb.Keys.Concat(["house", "hou", "allegiance", "all"])
|
|
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// <c>/help <verb></c> lookup for a catalog-dispatched command —
|
|
/// retail's <c>DoHelp @ 0x0057F9E0</c> looking up the verb's registered
|
|
/// help callback. Does not cover chat-alias or channel verbs (see
|
|
/// <see cref="RetailCommandHelpTable"/> for those) nor the deferred
|
|
/// allegiance/house subcommand overviews (also
|
|
/// <see cref="RetailCommandHelpTable"/>).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|