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

@ -40,6 +40,25 @@ public static class ClientCommandRequests
public const uint RemoveSpellOpcode = 0x01A8u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
// GameActionType opcodes cross-checked against ACE
// (Source/ACE.Server/Network/GameAction/GameActionType.cs) — ACE's
// numbering is the same wire protocol retail uses, matching this
// file's existing cross-check convention (see ClientCommandRequests
// class doc). Each handler's payload shape was read directly from
// ACE's GameAction Actions/*.cs reader (cited per-method below) rather
// than guessed.
public const uint IndexChannelsOpcode = 0x0149u;
public const uint ListChannelsOpcode = 0x0148u;
public const uint AddChannelOpcode = 0x0145u;
public const uint RemoveChannelOpcode = 0x0146u;
public const uint RecallAllegianceHometownOpcode = 0x02ABu;
public const uint AllegianceInfoRequestOpcode = 0x027Bu;
public const uint ListAvailableHousesOpcode = 0x0270u;
public const uint AddPlayerPermissionOpcode = 0x0219u;
public const uint RemovePlayerPermissionOpcode = 0x021Au;
public const uint AbandonHouseOpcode = 0x021Fu;
// Named-retail anchors:
// CM_Character::Event_TeleToMarketplace @ 0x006A1C20
// CM_Character::Event_TeleToPKArena @ 0x006A1CB0
@ -216,6 +235,53 @@ public static class ClientCommandRequests
public static byte[] BuildRemoveSpell(uint sequence, uint spellId) =>
BuildUInt32(sequence, RemoveSpellOpcode, spellId);
// @index — GameActionChannelIndex.Handle: no payload read.
public static byte[] BuildIndexChannels(uint sequence) =>
BuildParameterless(sequence, IndexChannelsOpcode);
// @clist <channel> — GameActionChannelList.Handle:
// ReadUInt32() as the Channel bitflag.
public static byte[] BuildListChannel(uint sequence, uint channelId) =>
BuildUInt32(sequence, ListChannelsOpcode, channelId);
// @on <channel> — GameActionAddChannel.Handle: ReadUInt32() Channel id.
public static byte[] BuildOnChannel(uint sequence, uint channelId) =>
BuildUInt32(sequence, AddChannelOpcode, channelId);
// @off <channel> — GameActionRemoveChannel.Handle: ReadUInt32() Channel id.
public static byte[] BuildOffChannel(uint sequence, uint channelId) =>
BuildUInt32(sequence, RemoveChannelOpcode, channelId);
// @alh / @ah / "@allegiance hometown" —
// GameActionRecallAllegianceHometown.Handle: no payload read.
public static byte[] BuildRecallAllegianceHometown(uint sequence) =>
BuildParameterless(sequence, RecallAllegianceHometownOpcode);
// "@allegiance info [name]" — GameActionAllegianceInfoRequest.Handle:
// ReadString16L() player name (empty string = self).
public static byte[] BuildAllegianceInfoRequest(uint sequence, string playerName) =>
BuildString(sequence, AllegianceInfoRequestOpcode, playerName);
// @hslist <type> / "@house available" —
// GameActionHouseListAvailable.Handle: ReadUInt32() as the ACE
// HouseType enum (Undef=0, Cottage=1, Villa=2, Mansion=3, Apartment=4).
public static byte[] BuildListAvailableHouses(uint sequence, uint houseType) =>
BuildUInt32(sequence, ListAvailableHousesOpcode, houseType);
// @permit add <name> — GameActionAddPlayerPermission.Handle:
// ReadString16L() player name.
public static byte[] BuildAddPlayerPermission(uint sequence, string playerName) =>
BuildString(sequence, AddPlayerPermissionOpcode, playerName);
// @permit remove <name> — GameActionRemovePlayerPermission.Handle:
// ReadString16L() player name.
public static byte[] BuildRemovePlayerPermission(uint sequence, string playerName) =>
BuildString(sequence, RemovePlayerPermissionOpcode, playerName);
// "@house abandon" — GameActionHouseAbandon.Handle: no payload read.
public static byte[] BuildAbandonHouse(uint sequence) =>
BuildParameterless(sequence, AbandonHouseOpcode);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -2245,6 +2245,77 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildModifyGlobalSquelch(seq, add, messageType));
}
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
/// <summary>Send retail @index (0x0149).</summary>
public void SendIndexChannels()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildIndexChannels(seq));
}
/// <summary>Send retail @clist &lt;channel&gt; (0x0148).</summary>
public void SendListChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListChannel(seq, channelId));
}
/// <summary>Send retail @on &lt;channel&gt; (0x0145).</summary>
public void SendOnChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildOnChannel(seq, channelId));
}
/// <summary>Send retail @off &lt;channel&gt; (0x0146).</summary>
public void SendOffChannel(uint channelId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildOffChannel(seq, channelId));
}
/// <summary>Send retail @alh / @ah / "@allegiance hometown" (0x02AB).</summary>
public void SendRecallAllegianceHometown()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRecallAllegianceHometown(seq));
}
/// <summary>Send retail "@allegiance info [name]" (0x027B).</summary>
public void SendAllegianceInfoRequest(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAllegianceInfoRequest(seq, playerName));
}
/// <summary>Send retail @hslist &lt;type&gt; (0x0270).</summary>
public void SendListAvailableHouses(uint houseType)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildListAvailableHouses(seq, houseType));
}
/// <summary>Send retail @permit add &lt;name&gt; (0x0219).</summary>
public void SendAddPlayerPermission(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAddPlayerPermission(seq, playerName));
}
/// <summary>Send retail @permit remove &lt;name&gt; (0x021A).</summary>
public void SendRemovePlayerPermission(string playerName)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemovePlayerPermission(seq, playerName));
}
/// <summary>Send retail "@house abandon" (0x021F).</summary>
public void SendAbandonHouse()
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildAbandonHouse(seq));
}
public void SendClearConsent()
{
uint seq = NextGameActionSequence();