feat(chat): Campaign CH slice CH4 — command registry completion

Brings acdream's / and @ command parsing to parity with the complete
retail registry (130 registered verbs + 22 unregistered GetChannelID
fallback tags = 152 client-parsed verbs), per
docs/research/2026-08-09-chat-retail-command-registry.md.

Parser semantics (retail OnChatCommand/DoCommand):
- : and ; rewrite to "@emote <rest>" before dispatch.
- Verb trailing-comma trim ("@f, hi" == "@f hi") applied at every
  verb-lookup site in the catalog and the parser.
- @tell/aliases split the target on the FIRST COMMA, not the first
  whitespace token, so multi-word names work ("@tell Aunt Agatha, hi").
- The 22 unregistered GM/faction channel tags (admin, sentinel,
  celestialhand, ...) now broadcast for real via a new
  RetailChannelTagTable + SendRawChannelCmd bypass, reusing the existing
  BuildChatChannel wire builder.

Binding corrections:
- /g, /group, /party -> Fellowship (0x800), not General.
- /rp -> reply alias (retail's own help text confirms "@r or @rp"), not
  Roleplay; /role (an acdream invention) deleted.
- /allegiance, /all -> the allegiance management command
  (RetailClientCommandCatalog), not a channel verb.
- /house no longer swallows unrecognized subcommands with a local usage
  error; they now correctly fall through to ACE.
- @mr/@pr pinned as permanently non-executable (retail registers them
  with a null function pointer).

New verbs with real local execution: endurance, speaker, title (silent,
AP-182), chat, notell, join, leave, permit, hslist, index, clist, on,
off, alh/ah (+ "@allegiance hometown"/"ho"), "@allegiance info",
"@house abandon"; a missing-alias sweep across pkl/hou/message_types/
msgtypes/msg_types/rt/send/whisper/w/vassal/covassal/co-vassals/c/
fellows/group/party/guild/gu/cg/ct/clfg/crp/soc/o; the non-retail
inventions gen/cv/lookingforgroup/tr/role/h are deleted. New Core.Net
wire builders (IndexChannels, ListChannels, AddChannel, RemoveChannel,
RecallAllegianceHometown, AllegianceInfoRequest, ListAvailableHouses,
AddPlayerPermission, RemovePlayerPermission, AbandonHouse) are all
parameterless or single-field payloads cross-checked against ACE's
GameAction readers, not guessed.

Deferred (filed as #360/#361/#362, register rows TS-68/TS-69/TS-70):
the ~22 remaining allegiance/house subcommands + standalone @motd
(largest single item, needs its own slice per the doc), the three
still-inert pure-local commands (day/log/render), and the inbound
GameEvent responses for the new outbound requests. All correctly fall
through to ACE server-passthrough rather than being silently swallowed
or faking success.

RetailCommandRegistryConformanceTests pins the complete 152-verb
registry against production: every verb resolves through exactly one
production surface if Implemented, through none if HelpOnly/
ServerPassthrough, and two reverse-direction tests fail the build if
RetailClientCommandCatalog or ChatInputParser ever claims a verb
outside this registry again. Final tally: 138 Implemented / 5
ServerPassthrough / 9 HelpOnly = 152.

Release suite: 12,190 passed / 4 skipped / 0 failed (up from CH3's
11,964/4/0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-09 21:10:17 +02:00
parent 9247d5d5b5
commit 090825e703
23 changed files with 2069 additions and 97 deletions

View file

@ -2,6 +2,7 @@ using AcDream.Core.Physics;
using AcDream.Core.Ui;
using AcDream.Core.Social;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI;
@ -58,7 +59,21 @@ public sealed class ClientCommandController
Action ClearDesiredComponents,
Func<bool> HasOpenVendor,
Action<uint?, uint> FillComponentBuyList,
Action EnterPkLite);
Action EnterPkLite,
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
Func<bool> IsUsingTurbineChat,
Action<string> SetChatTitle,
Action<uint, bool> SetSingleCharacterOption,
Action<string> AddPlayerPermission,
Action<string> RemovePlayerPermission,
Action<uint> RequestAvailableHouses,
Action RequestChannelIndex,
Action<uint> RequestChannelList,
Action<uint> JoinGmChannel,
Action<uint> LeaveGmChannel,
Action RecallAllegianceHometown,
Action<string> RequestAllegianceInfo,
Action AbandonHouse);
private readonly Bindings _bindings;
@ -133,8 +148,15 @@ public sealed class ClientCommandController
case ClientCommandId.ToggleUiLock:
_bindings.ToggleUiLock();
break;
// ClientCommunicationSystem::DoVersion @ 0x0057E1B0. Exact
// retail text: acclient_2013_pseudo_c.txt:1031450/1031454
// (data_7e0eec "Client version %s\n" / data_7e0f00 "Using
// Turbine Chat.\n"). PSR-only extra lines are not modeled —
// acdream claims no privileged PSR mode.
case ClientCommandId.ShowVersion:
_bindings.ShowSystemMessage($"Client version {_bindings.ClientVersion()}");
_bindings.ShowSystemMessage(_bindings.IsUsingTurbineChat()
? $"Client version {_bindings.ClientVersion()}\nUsing Turbine Chat."
: $"Client version {_bindings.ClientVersion()}");
break;
case ClientCommandId.ShowLocation:
Position? position = _bindings.CurrentPosition();
@ -222,6 +244,90 @@ public sealed class ClientCommandController
case ClientCommandId.FillComponents:
ExecuteFillComponents(command.Arguments);
break;
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
// ClientCommunicationSystem::DoEndurance @ 0x0057C5F0.
case ClientCommandId.Endurance:
_bindings.ShowSystemMessage(RetailEnduranceText);
break;
// ClientCommunicationSystem::DoSpeaker @ 0x0057DAB0.
case ClientCommandId.Speaker:
_bindings.ShowSystemMessage(
"This command is no longer in use, please see @allegiance officer.");
break;
// ClientCommunicationSystem::DoTitle @ 0x0057A640. No local
// chat-window title chrome exists yet (AP-182) — the value is
// stored for a future consumer, matching retail's silent
// success (no confirmation text was found at the success site).
case ClientCommandId.SetChatTitle:
_bindings.SetChatTitle(command.Arguments.Trim());
break;
// ClientCommunicationSystem::DoChatToggle @ 0x0056FAD0 —
// "on" removes the global Speech (type 2) squelch, "off" adds it.
case ClientCommandId.ChatToggle:
_bindings.ModifyGlobalSquelch(
command.Arguments.Equals("off", StringComparison.OrdinalIgnoreCase),
2u);
break;
// ClientCommunicationSystem::DoNoTell @ 0x0056FBD0 — same
// mechanism, message type 3 (Tell). "on" (retail: "you will
// not receive any tells") ADDS the squelch.
case ClientCommandId.NoTellToggle:
_bindings.ModifyGlobalSquelch(
command.Arguments.Equals("on", StringComparison.OrdinalIgnoreCase),
3u);
break;
// ClientCommunicationSystem::DoJoinChat @ 0x0056F510.
case ClientCommandId.JoinChannel:
if (RetailClientCommandCatalog.TryResolveJoinLeaveOption(command.Arguments, out uint joinOption))
_bindings.SetSingleCharacterOption(joinOption, true);
break;
// ClientCommunicationSystem::DoLeaveChat @ 0x0056F7F0.
case ClientCommandId.LeaveChannel:
if (RetailClientCommandCatalog.TryResolveJoinLeaveOption(command.Arguments, out uint leaveOption))
_bindings.SetSingleCharacterOption(leaveOption, false);
break;
// ClientCommunicationSystem::DoPermit @ 0x005785A0.
case ClientCommandId.Permit:
ExecutePermit(command.Arguments);
break;
// ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510.
case ClientCommandId.HouseAvailableList:
if (RetailClientCommandCatalog.TryResolveHouseType(command.Arguments, out uint houseType))
_bindings.RequestAvailableHouses(houseType);
break;
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640.
case ClientCommandId.IndexChannels:
_bindings.RequestChannelIndex();
break;
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0.
case ClientCommandId.ListChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint listChannelId))
_bindings.RequestChannelList(listChannelId);
break;
// ClientCommunicationSystem::DoChannelOn @ 0x0057AA80.
case ClientCommandId.OnChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint onChannelId))
_bindings.JoinGmChannel(onChannelId);
break;
// ClientCommunicationSystem::DoChannelOff @ 0x0057AB50.
case ClientCommandId.OffChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint offChannelId))
_bindings.LeaveGmChannel(offChannelId);
break;
// GameActionRecallAllegianceHometown — @alh/@ah/"@allegiance hometown".
case ClientCommandId.AllegianceHometown:
_bindings.RecallAllegianceHometown();
break;
// GameActionAllegianceInfoRequest — "@allegiance info [name]".
case ClientCommandId.AllegianceInfo:
_bindings.RequestAllegianceInfo(command.Arguments.Trim());
break;
// GameActionHouseAbandon — "@house abandon".
case ClientCommandId.HouseAbandon:
_bindings.AbandonHouse();
break;
default:
throw new ArgumentOutOfRangeException(
nameof(command), command.Command, "Unknown retail client command.");
@ -538,6 +644,19 @@ public sealed class ClientCommandController
.Select(pair => pair.Value));
}
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Argument shape
// already validated by RetailClientCommandCatalog (exactly "add <name>"
// or "remove <name>").
private void ExecutePermit(string arguments)
{
string[] parts = SplitArguments(arguments);
string name = parts[1];
if (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase))
_bindings.AddPlayerPermission(name);
else
_bindings.RemovePlayerPermission(name);
}
private void ExecuteFillComponents(string arguments)
{
string[] parts = SplitArguments(arguments);
@ -662,6 +781,18 @@ public sealed class ClientCommandController
+ "Note: These commands should be bound on either side by asterisks. (Example: *wave*)\n"
+ "ShakeFist; Beckon; BeSeeingYou; BlowKiss; BowDeep; ClapHands; Cry; Laugh; Nod; Point; Shrug; Wave; Akimbo; HeartyLaugh; Salute; TapFoot; WaveHigh; WaveLow; Yawn; Stretch; Cringe; Kneel; Plead; Shiver; Shoo; Slouch; Spit; Surrender; Woah; Winded; YMCA; Eat; Drink; Teapot; Pray; Mock; Cheer; Helper; Warm Hands; Scratch Head; Shake Head\n\n";
// ClientCommunicationSystem::DoEndurance @ 0x0057C5F0. Exact retail
// text: acclient_2013_pseudo_c.txt:1031097 (data_7de2f8), verbatim.
private const string RetailEnduranceText =
"The endurance attribute has a number of abilities tied to it.\n"
+ "First, some combination of strength and endurance (with endurance being more important) now allows one to regenerate hit points at a faster rate the higher one's endurance is. This bonus is in addition to any regeneration spells one may have placed upon themselves. This endurance regeneration bonus caps at around 110%.\n"
+ "Second, the higher a player's Endurance, the less stamina one uses while attacking. This benefit is tied to Endurance only, and it caps out at around 50% less stamina used per attack. The minimum stamina used per attack remains one.\n"
+ "Third, the higher a player's Endurance, the more likely they are not to use a point of stamina to successfully evade a missile or melee attack. A player is required to have Melee Defense for melee attacks or Missile Defense for missile attacks trained or specialized in order for this specific ability to work. This benefit is tied to Endurance only, and it caps out at around a 75% chance to avoid losing a point of stamina per successful evasion.\n"
+ "Fourth, some combination of strength and endurance (the two are roughly of equivalent importance) now allows one to partially resist drain and harm attacks, up to a maximum of roughly 50%.\n"
+ "Fifth, some combination of strength and endurance (the two are roughly of equivalent importance) now allows one to have a level of \"natural resistances\" to the 7 damage types, the same as a certain level of life protections. This caps out at a 50% resistance (the equivalent to level 5 life prots) to these damage types. This resistance is not additive to life protections: higher level life protections will overwrite these natural resistances, although life vulns will take these natural resistances into account, if the player does not have a higher level life protection cast upon him.\n"
+ "The natural resistances, drain resistances, and regeneration rate info are now visible on the Character Information Panel, in what was once the Burden panel. This panel now displays the above three Endurance benefits, the burden info, as well as information about your age, birth date, and number of deaths.\n"
+ "The 5 categories for the endurance benefits are, in order from lowest benefit to highest: Poor, Mediocre, Hardy, Resilient, and Indomitable, with each range of benefits divided up equally amongst the 5 (e.g. Poor describes having anywhere from 1-10% resistance against drain health attacks, etc.).\n";
private const string AwayHelp =
"@afk - Turns on AFK (away-from-keyboard) mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"
+ "@afk on - Turns on AFK mode. When set to AFK, other players that send you directed chatyou will receive a customizable message that your are not currently at the keyboard.\n"