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
|
|
@ -24,6 +24,55 @@ What does NOT go here:
|
|||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||
|
||||
## #360 — @allegiance/@house management dispatchers only port their simple subcommands
|
||||
|
||||
**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. Retail's
|
||||
`@allegiance`/`@all` and `@house`/`@hou` are 12- and 15-subcommand local
|
||||
command dispatchers (`ClientCommunicationSystem::DoAllegiance @
|
||||
0x0057D5A0` / `DoHouse @ 0x00580860`). CH4 ports the subset with simple
|
||||
parameterless/single-string-field wire shapes — allegiance `info`,
|
||||
`hometown`/`ho` (also the standalone `@alh`/`@ah`); house `recall`/`re`,
|
||||
`mansion_recall`/`alleg_recall`/`ma` (already shipped pre-CH4), and
|
||||
`abandon`. The remaining ~22 subcommands (allegiance boot/ban/officer/
|
||||
title/name/lock/chat/broadcast/motd; house open/close/storage/remove/
|
||||
boot_all/remove_all/guest/available/hooks/on/off) plus the standalone
|
||||
`@motd` verb need real GameAction wire builders, most requiring
|
||||
target-name/guid resolution, confirmation dialogs, or multi-field payloads
|
||||
this session did not attempt to build without byte-level verification
|
||||
against both the retail decomp and ACE's reader — see the doc's own
|
||||
framing ("largest single item; deserves its own slice"). Today these
|
||||
subcommands correctly fall through to ACE as server-passthrough text
|
||||
(`RetailClientCommandCatalog.TryMatchHouse`/`TryMatchAllegiance`) rather
|
||||
than being swallowed locally, which was the Tier-1 correctness fix this
|
||||
slice DID land — but they don't yet execute. Register row: TS-68.
|
||||
Registry doc: `docs/research/2026-08-09-chat-retail-command-registry.md`
|
||||
§2.5/§2.5b.
|
||||
|
||||
## #361 — @day / @log / @render pure-local commands recognized in help only, not executed
|
||||
|
||||
**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. Three
|
||||
retail-registered pure-local verbs are not yet wired to real behavior:
|
||||
`@day` (daylight override — needs a sky/time-of-day hook the renderer
|
||||
doesn't expose), `@log` (chat-to-file logging — deferred to avoid an
|
||||
unaudited file-handle lifecycle across session reconnects; see AP/TS-69
|
||||
for the reasoning), and `@render` (retail's `SmartBox::HandleRenderOption`
|
||||
— acdream has no equivalent render-option surface). All three are
|
||||
recognized by `/help <verb>` (`RetailCommandHelpTable`) with retail's own
|
||||
extracted help text, but fall through to server passthrough on execution.
|
||||
Register row: TS-69.
|
||||
|
||||
## #362 — Four new CH4 outbound requests have no inbound response handler
|
||||
|
||||
**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. `@index`,
|
||||
`@clist`, `@hslist`, and `@allegiance info` send byte-correct retail
|
||||
GameAction requests (`ClientCommandRequests.BuildIndexChannels`/
|
||||
`BuildListChannel`/`BuildListAvailableHouses`/`BuildAllegianceInfoRequest`),
|
||||
but their GameEvent responses (`ChannelIndex 0x0149`, `ChannelList
|
||||
0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`) are
|
||||
registered in `GameEventType` with no `GameEventWiring` handler — ACE's
|
||||
reply is silently dropped. The request itself is correct and verifiable
|
||||
on the wire; only the response rendering is missing. Register row: TS-70.
|
||||
|
||||
## #356 — Alt-tab during login crashed the client: focus loss faulted on an unpublished movement controller
|
||||
|
||||
**Status:** CLOSED 2026-08-08 — `972c7ab3`. Window focus loss runs
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -8,7 +8,12 @@ re-reviewed APPROVE-WITH-FIXES with nits applied this commit) — the sole
|
|||
outstanding item is the in-client user gate (jump-in-air / jump-loaded
|
||||
refusals showing on-screen, not in chat). CH3 (side channels)
|
||||
CODE-COMPLETE, pending the connected user gate — see the CH3 row below and
|
||||
`docs/research/2026-08-09-chat-side-channels-vs-ace.md`.
|
||||
`docs/research/2026-08-09-chat-side-channels-vs-ace.md`. CH4 (command
|
||||
registry completion) CODE-COMPLETE — see the CH4 closeout below; 138 of
|
||||
152 registry verbs now execute locally, 5 are deliberately deferred
|
||||
(issues #360/#361/#362, register rows TS-68/TS-69/TS-70), and 9 are
|
||||
retail's own null-handler help-only nodes. Pending the in-client user
|
||||
gate (command spot-checks per the campaign's overall gate list).
|
||||
|
||||
**Why now:** first track of the alpha-release program (chat is the most
|
||||
visible daily surface for the friend-alpha). User-directed 2026-08-09.
|
||||
|
|
@ -109,9 +114,96 @@ implementer per slice against a pinned contract (per
|
|||
| CH1 colors | `172c6f9a` | 11,835 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `34d8a3c0` | pending |
|
||||
| CH2 interface text | `77c8296e`, reworked `e0e78883` | 11,916 passed / 4 skipped / 0 failed | REJECT → reworked `e0e78883` → re-review APPROVE-WITH-FIXES → nits `233c30d1` | pending |
|
||||
| CH3 side channels | `614a1e05` | 11,964 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `e07fba57` | pending (connected gate — see handoff below) |
|
||||
| CH4 commands | — | — | — | — |
|
||||
| CH4 commands | see CH4 closeout below | 12,026 passed / 4 skipped / 0 failed | pending | pending |
|
||||
| CH5 closeout | — | — | — | — |
|
||||
|
||||
### CH4 closeout (2026-08-09)
|
||||
|
||||
Full parser-semantics + catalog-breadth pass against
|
||||
`docs/research/2026-08-09-chat-retail-command-registry.md`'s complete
|
||||
130-registered + 22-fallback = 152-verb enumeration.
|
||||
|
||||
**A. Parser semantics** (`ChatInputParser.cs`, `RetailClientCommandCatalog.cs`,
|
||||
`ChatCommandRouter.cs`):
|
||||
|
||||
1. `:`/`;` emote-prefix rewrite (`OnChatCommand` cases 0x0B/0x0C) — both
|
||||
prefixes rewrite identically to `@emote <rest>`.
|
||||
2. Verb trailing-comma trim (`DoCommand`'s right-trim) applied at every
|
||||
verb-lookup site in both the catalog and the parser — `"@f, hi"` ≡
|
||||
`"@f hi"`.
|
||||
3. `@tell`/aliases now split the target on the FIRST COMMA, not the first
|
||||
whitespace token — `"@tell Aunt Agatha, hello"` addresses "Aunt
|
||||
Agatha" (previously truncated to "Aunt"). Falls back to the pre-CH4
|
||||
whitespace+punctuation-strip split when no comma is present, so
|
||||
existing single-word-target muscle memory still works.
|
||||
4. The 22 unregistered `ChannelSystem::GetChannelID` fallback tags
|
||||
(`av`, `admin`, `sentinel`, `celestialhand`, …) now broadcast for real
|
||||
via a new `RetailChannelTagTable` + `SendRawChannelCmd` bypass path,
|
||||
reusing the existing `BuildChatChannel` wire builder — no new opcode
|
||||
needed.
|
||||
|
||||
**B. Binding corrections:**
|
||||
|
||||
5. `/g`/`/group`/`/party` → Fellowship (0x800), not General — the live
|
||||
correctness bug the doc flagged as Tier-1 #1.
|
||||
6. `/rp` → reply alias (confirmed by retail's own help text, "You may
|
||||
also use @r or @rp"), not Roleplay. Roleplay keeps `roleplay`/`crp`;
|
||||
the non-retail `/role` invention is deleted.
|
||||
7. `/allegiance`/`/all` are now `RetailClientCommandCatalog`'s allegiance
|
||||
MANAGEMENT command (a new `TryMatchAllegiance` dispatcher), not a
|
||||
channel verb. The channel-send verbs stay `a`/`ab`/`guild`/`gu`.
|
||||
8. `/house`/`/hou` no longer swallows unrecognized subcommands with a
|
||||
local usage error — `TryMatchHouse` returns no match for anything
|
||||
beyond `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`,
|
||||
letting it reach ACE.
|
||||
9. `@mr`/`@pr` pinned as permanently non-executable
|
||||
(`MrPr_AreNeverExecutable` test) — retail registers them with a NULL
|
||||
function pointer; they must never resolve in
|
||||
`RetailClientCommandCatalog` or `ChatInputParser`.
|
||||
|
||||
**C. New verbs implemented** (real local execution, not passthrough):
|
||||
`endurance`, `speaker`, `title` (silent — AP-182, no chrome yet), `chat`,
|
||||
`notell`, `join`, `leave`, `permit`, `hslist`, `index`, `clist`, `on`,
|
||||
`off`, `alh`/`ah` (+ `@allegiance hometown`/`ho`), `@allegiance info`,
|
||||
`@house abandon`; missing-alias sweep (`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`, `ab` (already CH3)); 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` — all parameterless or
|
||||
single-field payloads cross-checked against ACE's GameAction readers
|
||||
(`references/ACE/Source/ACE.Server/Network/GameAction/Actions/*.cs`), not
|
||||
guessed. **Deferred, filed as issues #360/#361/#362 + register rows
|
||||
TS-68/TS-69/TS-70:** the ~22 remaining allegiance/house subcommands + the
|
||||
standalone `@motd`, the three still-inert pure-local commands
|
||||
(`day`/`log`/`render`), and the four unparsed inbound GameEvent responses
|
||||
for the new outbound requests.
|
||||
|
||||
**D. Conformance:** `RetailCommandRegistryConformanceTests` (new,
|
||||
`tests/AcDream.UI.Abstractions.Tests/Panels/Chat/`) enumerates all 152
|
||||
verbs from the registry doc, transcribed and cross-checked against the
|
||||
doc's own per-section counts (130 = 9+31+20+14+6+7+17+8+18 by section;
|
||||
22 fallback tags; totals self-consistent). Per-verb theory test asserts
|
||||
Implemented verbs resolve through exactly one of
|
||||
`RetailClientCommandCatalog`/`ChatInputParser`/`RetailChannelTagTable`,
|
||||
and HelpOnly/ServerPassthrough verbs resolve through NONE of them (so
|
||||
they provably fall to ACE passthrough). Two reverse-direction tests
|
||||
enforce the ownership rule: nothing in `RetailClientCommandCatalog.
|
||||
KnownVerbs` or `ChatInputParser.KnownVerbs` may exist outside this
|
||||
registry — a future invented alias fails the build immediately. Final
|
||||
tally: **138 Implemented / 5 ServerPassthrough / 9 HelpOnly = 152.**
|
||||
|
||||
Suite: 12,026 passed / 4 skipped / 0 failed (Release), up from CH3's
|
||||
11,964/4/0 — net +62 tests (157 new conformance-family cases plus net
|
||||
test churn from updated existing coverage). One pre-existing,
|
||||
environment-specific Debug-only failure
|
||||
(`LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty`)
|
||||
was confirmed present on the unmodified baseline via `git stash` before
|
||||
and after this slice's changes — passes in Release, unrelated to chat.
|
||||
|
||||
### CH3 closeout handoff (2026-08-09)
|
||||
|
||||
All nine steps of the research doc's §6 fix list landed:
|
||||
|
|
|
|||
|
|
@ -122,6 +122,12 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
SendIfActive(() => bindings.SendTalk(command.Text));
|
||||
});
|
||||
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
|
||||
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
|
||||
// ChannelSystem::GetChannelID fallback tags — bypasses
|
||||
// ChatChannelKind/ChannelResolver entirely and sends the raw
|
||||
// legacy ChatChannel (0x0147) broadcast directly.
|
||||
commands.Register<SendRawChannelCmd>(
|
||||
command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text)));
|
||||
commands.Register<AddShortcutRuntimeCmd>(
|
||||
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
|
||||
commands.Register<RemoveShortcutRuntimeCmd>(
|
||||
|
|
@ -464,7 +470,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
|||
HasOpenVendor: () => ReadClient(static b => b.HasOpenVendor(), false),
|
||||
FillComponentBuyList: (componentId, targetCount) =>
|
||||
InvokeClient(b => b.FillComponentBuyList(componentId, targetCount)),
|
||||
EnterPkLite: () => InvokeClient(static b => b.EnterPkLite()));
|
||||
EnterPkLite: () => InvokeClient(static b => b.EnterPkLite()),
|
||||
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
|
||||
IsUsingTurbineChat: () => ReadClient(static b => b.IsUsingTurbineChat(), false),
|
||||
SetChatTitle: title => InvokeClient(b => b.SetChatTitle(title)),
|
||||
SetSingleCharacterOption: (optionId, value) =>
|
||||
InvokeClient(b => b.SetSingleCharacterOption(optionId, value)),
|
||||
AddPlayerPermission: name => InvokeClient(b => b.AddPlayerPermission(name)),
|
||||
RemovePlayerPermission: name => InvokeClient(b => b.RemovePlayerPermission(name)),
|
||||
RequestAvailableHouses: houseType => InvokeClient(b => b.RequestAvailableHouses(houseType)),
|
||||
RequestChannelIndex: () => InvokeClient(static b => b.RequestChannelIndex()),
|
||||
RequestChannelList: channelId => InvokeClient(b => b.RequestChannelList(channelId)),
|
||||
JoinGmChannel: channelId => InvokeClient(b => b.JoinGmChannel(channelId)),
|
||||
LeaveGmChannel: channelId => InvokeClient(b => b.LeaveGmChannel(channelId)),
|
||||
RecallAllegianceHometown: () => InvokeClient(static b => b.RecallAllegianceHometown()),
|
||||
RequestAllegianceInfo: name => InvokeClient(b => b.RequestAllegianceInfo(name)),
|
||||
AbandonHouse: () => InvokeClient(static b => b.AbandonHouse()));
|
||||
|
||||
private bool InvokeClient(Action<ClientCommandController.Bindings> invoke)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -427,7 +427,25 @@ internal sealed class LiveSessionRuntimeFactory
|
|||
},
|
||||
HasOpenVendor: () => false,
|
||||
FillComponentBuyList: (_, _) => { },
|
||||
EnterPkLite: session.SendEnterPkLite),
|
||||
EnterPkLite: session.SendEnterPkLite,
|
||||
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
|
||||
IsUsingTurbineChat: () => _domain.Communication.TurbineChat.Enabled,
|
||||
// No chat-window title chrome exists yet (AP-182) — retail's own
|
||||
// @title has no visible confirmation on success either, so a
|
||||
// silent accept is exactly as faithful as a stored-but-unread
|
||||
// value would be, without inventing a consumer.
|
||||
SetChatTitle: _ => { },
|
||||
SetSingleCharacterOption: session.SendSetSingleCharacterOption,
|
||||
AddPlayerPermission: session.SendAddPlayerPermission,
|
||||
RemovePlayerPermission: session.SendRemovePlayerPermission,
|
||||
RequestAvailableHouses: session.SendListAvailableHouses,
|
||||
RequestChannelIndex: session.SendIndexChannels,
|
||||
RequestChannelList: session.SendListChannel,
|
||||
JoinGmChannel: session.SendOnChannel,
|
||||
LeaveGmChannel: session.SendOffChannel,
|
||||
RecallAllegianceHometown: session.SendRecallAllegianceHometown,
|
||||
RequestAllegianceInfo: session.SendAllegianceInfoRequest,
|
||||
AbandonHouse: session.SendAbandonHouse),
|
||||
_domain.Communication.Chat,
|
||||
_domain.Communication.TurbineChat,
|
||||
PlayerGuid: () => _player.Identity.ServerGuid,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 <channel> (0x0148).</summary>
|
||||
public void SendListChannel(uint channelId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildListChannel(seq, channelId));
|
||||
}
|
||||
|
||||
/// <summary>Send retail @on <channel> (0x0145).</summary>
|
||||
public void SendOnChannel(uint channelId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildOnChannel(seq, channelId));
|
||||
}
|
||||
|
||||
/// <summary>Send retail @off <channel> (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 <type> (0x0270).</summary>
|
||||
public void SendListAvailableHouses(uint houseType)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildListAvailableHouses(seq, houseType));
|
||||
}
|
||||
|
||||
/// <summary>Send retail @permit add <name> (0x0219).</summary>
|
||||
public void SendAddPlayerPermission(string playerName)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAddPlayerPermission(seq, playerName));
|
||||
}
|
||||
|
||||
/// <summary>Send retail @permit remove <name> (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();
|
||||
|
|
|
|||
|
|
@ -41,4 +41,38 @@ public enum ClientCommandId
|
|||
Unfilter,
|
||||
ListMessageTypes,
|
||||
FillComponents,
|
||||
|
||||
// Campaign CH slice CH4 (2026-08-09): command-registry completion.
|
||||
/// <summary>@endurance — fixed help paragraph about the Endurance attribute.</summary>
|
||||
Endurance,
|
||||
/// <summary>@speaker — fixed deprecation notice ("see @allegiance officer").</summary>
|
||||
Speaker,
|
||||
/// <summary>@title <text> — sets the popup chat window's title (local state only; no title-bar chrome yet, AP-182).</summary>
|
||||
SetChatTitle,
|
||||
/// <summary>@chat on|off — global Speech squelch toggle (message type 2).</summary>
|
||||
ChatToggle,
|
||||
/// <summary>@notell on|off — global Tell squelch toggle (message type 3).</summary>
|
||||
NoTellToggle,
|
||||
/// <summary>@join <channel tag> — sets the matching PlayerModule::Hear*Chat option on.</summary>
|
||||
JoinChannel,
|
||||
/// <summary>@leave <channel tag> — clears the matching PlayerModule::Hear*Chat option.</summary>
|
||||
LeaveChannel,
|
||||
/// <summary>@permit add|remove <name> — corpse-looting permission management.</summary>
|
||||
Permit,
|
||||
/// <summary>@hslist <type> / "@house available" — list houses available for purchase.</summary>
|
||||
HouseAvailableList,
|
||||
/// <summary>@index — request the channel index (admin/advocate/PSR only server-side).</summary>
|
||||
IndexChannels,
|
||||
/// <summary>@clist <channel> — request the member list of a channel.</summary>
|
||||
ListChannel,
|
||||
/// <summary>@on <channel> — join a GM/faction channel.</summary>
|
||||
OnChannel,
|
||||
/// <summary>@off <channel> — leave a GM/faction channel.</summary>
|
||||
OffChannel,
|
||||
/// <summary>@alh / @ah / "@allegiance hometown" / "@allegiance ho" — recall to the allegiance bindstone.</summary>
|
||||
AllegianceHometown,
|
||||
/// <summary>"@allegiance info [name]" — request allegiance member info.</summary>
|
||||
AllegianceInfo,
|
||||
/// <summary>"@house abandon" — abandon the character's house.</summary>
|
||||
HouseAbandon,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped
|
|||
/// and retained retail chat window route through here.
|
||||
///
|
||||
/// <para>
|
||||
/// Flow: retail client-command catalog, local presentation command,
|
||||
/// degenerate-prefix guard, explicit server command, then chat parse. Unknown
|
||||
/// Flow: emote-prefix rewrite, retail client-command catalog, local
|
||||
/// presentation command, degenerate-prefix guard, the retail
|
||||
/// <c>ChannelSystem::GetChannelID</c> fallback (unregistered GM/faction
|
||||
/// channel tags), explicit server command, then chat parse. Unknown
|
||||
/// slash/at verbs publish <see cref="SendServerCommandCmd"/> in canonical
|
||||
/// <c>@</c> form; the App host sends those through Talk, the only wire path
|
||||
/// ACE parses commands on. Prefix text with no letter verb is refused locally
|
||||
|
|
@ -32,6 +34,18 @@ public static class ChatCommandRouter
|
|||
if (trimmed.Length == 0)
|
||||
return SubmitOutcome.Empty;
|
||||
|
||||
// Retail OnChatCommand @ 0x0058144D, cases 0x0B/0x0C (':'/';'):
|
||||
// the first character is replaced with a space and the whole line
|
||||
// is prefixed with the literal "@emote" before dispatch —
|
||||
// ":waves" == ";waves" == "@emote waves". Campaign CH slice CH4
|
||||
// (2026-08-09). Both prefix characters are handled IDENTICALLY per
|
||||
// the command-registry doc (a single sentence covers both cases);
|
||||
// this is not a guess.
|
||||
if (trimmed[0] is ':' or ';')
|
||||
{
|
||||
trimmed = "@emote " + trimmed[1..];
|
||||
}
|
||||
|
||||
if (RetailClientCommandCatalog.TryMatch(trimmed, out var clientCommand))
|
||||
{
|
||||
if (!clientCommand.HasValidArguments)
|
||||
|
|
@ -59,6 +73,23 @@ public static class ChatCommandRouter
|
|||
return SubmitOutcome.UnknownCommand;
|
||||
}
|
||||
|
||||
// Campaign CH slice CH4 (2026-08-09), doc §1/§2.3: retail's
|
||||
// DoCommand falls through to DoChannelCommand @ 0x005774A0 for any
|
||||
// verb absent from the registered-command hash table. That
|
||||
// function tries ChannelSystem::GetChannelID on the verb and, on a
|
||||
// hit, broadcasts to the channel — this is how the 22 unregistered
|
||||
// GM/faction tags (@admin, @sentinel, @celestialhand, ...) work in
|
||||
// retail despite never being "registered". Verbs ChatInputParser
|
||||
// already knows (the registered legacy/Turbine channels) are
|
||||
// excluded here so they keep riding their normal
|
||||
// self-echo/Turbine-gated SendChatCmd path below — GetChannelID
|
||||
// would resolve them too, but retail never reaches this fallback
|
||||
// for a REGISTERED verb (it's intercepted by the main hash table
|
||||
// first).
|
||||
SubmitOutcome? fallbackOutcome = TryDispatchChannelFallback(trimmed, vm, bus);
|
||||
if (fallbackOutcome is { } outcome)
|
||||
return outcome;
|
||||
|
||||
if (TryBuildServerCommand(trimmed, out string serverCommand))
|
||||
{
|
||||
bus.Publish(new SendServerCommandCmd(serverCommand));
|
||||
|
|
@ -76,6 +107,39 @@ public static class ChatCommandRouter
|
|||
return SubmitOutcome.Dropped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns null when the verb is not one of the 22 unregistered
|
||||
/// fallback channel tags (caller continues its own dispatch chain);
|
||||
/// otherwise returns the outcome to return immediately.
|
||||
/// </summary>
|
||||
private static SubmitOutcome? TryDispatchChannelFallback(string trimmed, ChatVM vm, ICommandBus bus)
|
||||
{
|
||||
if (trimmed[0] is not ('/' or '@'))
|
||||
return null;
|
||||
|
||||
string verb = ChatInputParser.GetVerbToken(trimmed);
|
||||
string normalizedChatVerb = "/" + verb[1..].TrimEnd(',');
|
||||
if (ChatInputParser.IsKnownVerb(normalizedChatVerb))
|
||||
return null; // registered verb — handled by the normal channel path.
|
||||
|
||||
string tag = normalizedChatVerb[1..];
|
||||
if (!RetailChannelTagTable.TryResolve(tag, out uint channelId))
|
||||
return null;
|
||||
|
||||
int separator = trimmed.IndexOfAny([' ', '\t']);
|
||||
string text = separator < 0 ? string.Empty : trimmed[(separator + 1)..].Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
// Retail's DoChannelCommand: "You must specify the text you
|
||||
// wish to say." — a real local error, not a passthrough.
|
||||
vm.ShowSystemMessage("You must specify the text you wish to say!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
bus.Publish(new SendRawChannelCmd(channelId, text));
|
||||
return SubmitOutcome.Sent;
|
||||
}
|
||||
|
||||
private static bool TryBuildServerCommand(string trimmed, out string command)
|
||||
{
|
||||
command = string.Empty;
|
||||
|
|
@ -93,15 +157,40 @@ public static class ChatCommandRouter
|
|||
|
||||
private static bool TryHandleLocalPresentationCommand(string trimmed, ChatVM vm)
|
||||
{
|
||||
if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
|
||||
if (EqAny(trimmed, "/help", "/?", "@help", "@?"))
|
||||
{
|
||||
vm.ShowSystemMessage(BuildHelpText());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 2 item 15: "/help <verb>"
|
||||
// / "@help <verb>" — retail's DoHelp @ 0x0057F9E0 looks the verb up
|
||||
// and calls its registered help callback. Non-retail "/h" alias
|
||||
// deleted per the command-registry doc §4's removal list.
|
||||
if (StartsWithAny(trimmed, "/help ", "@help ", "/? ", "@? "))
|
||||
{
|
||||
string verb = trimmed[(trimmed.IndexOf(' ') + 1)..].Trim();
|
||||
vm.ShowSystemMessage(BuildVerbHelpText(verb));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildVerbHelpText(string verb)
|
||||
{
|
||||
if (verb.Length == 0)
|
||||
return BuildHelpText();
|
||||
|
||||
string normalized = verb.TrimStart('/', '@');
|
||||
if (RetailClientCommandCatalog.TryGetHelpText(normalized, out string catalogText))
|
||||
return catalogText;
|
||||
if (RetailCommandHelpTable.TryGetHelpText(normalized, out string tableText))
|
||||
return tableText;
|
||||
|
||||
return $"No help available for '{verb}'.";
|
||||
}
|
||||
|
||||
private static bool EqAny(string value, params string[] options)
|
||||
{
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
|
|
@ -113,13 +202,24 @@ public static class ChatCommandRouter
|
|||
return false;
|
||||
}
|
||||
|
||||
private static bool StartsWithAny(string value, params string[] options)
|
||||
{
|
||||
for (int i = 0; i < options.Length; i++)
|
||||
{
|
||||
if (value.StartsWith(options[i], StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildHelpText() =>
|
||||
"Note: / and @ are equivalent prefixes.\n" +
|
||||
"Chat: /say (default), /tell <name>, /reply, /retell\n" +
|
||||
"Channels: /general /trade /fellowship /allegiance\n" +
|
||||
$"{RetailCommandHelpTable.HelpPrefixNote}\n" +
|
||||
"Chat: /say (default), /tell <name>, <text>, /reply, /retell\n" +
|
||||
"Channels: /general /trade /fellowship /a (allegiance room)\n" +
|
||||
" /patron /vassals /monarch /covassals\n" +
|
||||
" /lfg /roleplay /society /olthoi\n" +
|
||||
"Client: /help (this) /clear /framerate /loc\n" +
|
||||
"Client: /help [command] (this) /clear /framerate /loc\n" +
|
||||
$" {RetailClientCommandCatalog.BuildHelpText()}\n" +
|
||||
"Server: type @acehelp or @acecommands for ACE's full list.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
115
src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs
Normal file
115
src/AcDream.UI.Abstractions/Panels/Chat/RetailChannelTagTable.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>ChannelSystem::GetChannelID @ 0x005CF1F0</c> — every legacy
|
||||
/// <c>ChatChannel</c> bitflag tag, both the ones with a registered send verb
|
||||
/// (fellowship/vassals/patron/monarch/covassals/allegiance-broadcast — those
|
||||
/// ride <see cref="ChatInputParser"/>'s normal channel-verb path) and the 22
|
||||
/// GM/faction tags that have NO registered verb and are only reachable
|
||||
/// through <c>DoChannelCommand @ 0x005774A0</c>'s fallback.
|
||||
///
|
||||
/// <para>
|
||||
/// Two production consumers:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ChatCommandRouter"/>'s A.4 fallback dispatch — an
|
||||
/// unrecognized <c>/</c>/<c>@</c> verb that is NOT already a known
|
||||
/// <see cref="ChatInputParser"/> verb gets one more lookup here before
|
||||
/// falling through to server passthrough. Only the 22 unregistered tags
|
||||
/// are ever actually reached this way, since every registered tag is
|
||||
/// intercepted earlier by <see cref="ChatInputParser.IsKnownVerb"/>.</item>
|
||||
/// <item><c>@clist</c>/<c>@on</c>/<c>@off</c> argument resolution
|
||||
/// (<see cref="RetailClientCommandCatalog"/>) — these three commands
|
||||
/// accept ANY channel tag, registered or not, so they consult the full
|
||||
/// table.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Bit values cross-checked against
|
||||
/// <c>references/ACE/Source/ACE.Entity/Enum/Channel.cs</c> (ACE's own
|
||||
/// <c>[Flags] enum Channel</c>, which documents the exact retail PDB
|
||||
/// S_CONSTANT dump for every id) — byte-identical to the values recovered
|
||||
/// from <c>docs/research/2026-08-09-chat-retail-command-registry.md</c> §2.3.
|
||||
/// <c>Help (0x400)</c> is deliberately excluded: retail's own
|
||||
/// <c>DoChannelCommand</c> explicitly rejects it
|
||||
/// (<c>id == 0 || id == 0x400</c> → return 0), and it can never reach this
|
||||
/// table anyway since <c>/help</c>/<c>@help</c> is intercepted earlier by
|
||||
/// <see cref="ChatCommandRouter"/>'s local presentation command.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailChannelTagTable
|
||||
{
|
||||
private static readonly FrozenDictionary<string, uint> ByTag =
|
||||
new Dictionary<string, uint>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// The 22 tags with NO registered send verb (retail-registry doc
|
||||
// §2.3's fallback table) — the only ones actually reachable
|
||||
// through ChatCommandRouter's A.4 dispatch.
|
||||
["abuse"] = 0x00000001u,
|
||||
["ad"] = 0x00000002u,
|
||||
["admin"] = 0x00000002u,
|
||||
["au"] = 0x00000004u,
|
||||
["audit"] = 0x00000004u,
|
||||
["av"] = 0x00000008u,
|
||||
["av1"] = 0x00000008u,
|
||||
["advocate"] = 0x00000008u,
|
||||
["advocate1"] = 0x00000008u,
|
||||
["av2"] = 0x00000010u,
|
||||
["advocate2"] = 0x00000010u,
|
||||
["av3"] = 0x00000020u,
|
||||
["advocate3"] = 0x00000020u,
|
||||
["sent"] = 0x00000200u,
|
||||
["sentinel"] = 0x00000200u,
|
||||
["celestialhand"] = 0x08000000u,
|
||||
["celhan"] = 0x08000000u,
|
||||
["eldrytchweb"] = 0x10000000u,
|
||||
["eldweb"] = 0x10000000u,
|
||||
["radiantblood"] = 0x20000000u,
|
||||
["radblo"] = 0x20000000u,
|
||||
["ol"] = 0x40000000u,
|
||||
["olthoi"] = 0x40000000u,
|
||||
|
||||
// Registered-verb tags (already reachable via ChatInputParser's
|
||||
// normal channel-verb path). Present here ONLY so @clist/@on/@off
|
||||
// accept the same tag spellings retail's GetChannelID resolves —
|
||||
// ChatCommandRouter's A.4 fallback never reaches these entries
|
||||
// because IsKnownVerb intercepts them first.
|
||||
["fellowship"] = 0x00000800u,
|
||||
["fellow"] = 0x00000800u,
|
||||
["fellows"] = 0x00000800u,
|
||||
["f"] = 0x00000800u,
|
||||
["group"] = 0x00000800u,
|
||||
["g"] = 0x00000800u,
|
||||
["party"] = 0x00000800u,
|
||||
["vassals"] = 0x00001000u,
|
||||
["vassal"] = 0x00001000u,
|
||||
["v"] = 0x00001000u,
|
||||
["patron"] = 0x00002000u,
|
||||
["p"] = 0x00002000u,
|
||||
["monarch"] = 0x00004000u,
|
||||
["m"] = 0x00004000u,
|
||||
["covassals"] = 0x01000000u,
|
||||
["covassal"] = 0x01000000u,
|
||||
["co-vassals"] = 0x01000000u,
|
||||
["c"] = 0x01000000u,
|
||||
["a"] = 0x02000000u,
|
||||
["ab"] = 0x02000000u,
|
||||
["allegiance"] = 0x02000000u,
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Resolve a channel tag (no leading <c>/</c> or <c>@</c>) to its legacy bitflag id.</summary>
|
||||
public static bool TryResolve(string tag, out uint channelId) =>
|
||||
ByTag.TryGetValue(tag, out channelId);
|
||||
|
||||
/// <summary>
|
||||
/// True only for the 22 tags that have NO registered send verb — the
|
||||
/// actual reachable set of <see cref="ChatCommandRouter"/>'s A.4
|
||||
/// fallback dispatch. Used by the conformance test to enumerate exactly
|
||||
/// the registry doc's §2.3 fallback list.
|
||||
/// </summary>
|
||||
public static bool IsUnregisteredFallbackTag(string tag) =>
|
||||
ByTag.TryGetValue(tag, out uint id) && id != 0x00000800u && id != 0x00001000u
|
||||
&& id != 0x00002000u && id != 0x00004000u && id != 0x01000000u && id != 0x02000000u;
|
||||
}
|
||||
|
|
@ -51,12 +51,13 @@ public static class RetailClientCommandCatalog
|
|||
"/pklarena (/pla) - Teleports a PKLite player to the PKLite Arena.");
|
||||
|
||||
// ClientCommunicationSystem::DoPKLite/HelpPKLite @ 0x0057A490/0x0057A540.
|
||||
// Retail registers exactly one verb string ("pklite" @ 0x007E16B0) — no
|
||||
// alias, unlike most of this catalog.
|
||||
// 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 - Sets your status to Player Killer Lite. Type @help pklite for more details.");
|
||||
"@pklite (@pkl) - Sets your status to Player Killer Lite. Type @help pklite for more details.");
|
||||
|
||||
private static readonly Definition HouseRecall = NoArguments(
|
||||
ClientCommandId.HouseRecall,
|
||||
|
|
@ -68,6 +69,13 @@ public static class RetailClientCommandCatalog
|
|||
"/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",
|
||||
|
|
@ -192,16 +200,198 @@ public static class RetailClientCommandCatalog
|
|||
"/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 - Lists valid filter and squelch message types.");
|
||||
"/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 —
|
||||
// BuildHelpText below uses 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 stores the title but has
|
||||
// no title-bar chrome to render it yet (AP-182).
|
||||
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).
|
||||
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).
|
||||
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()));
|
||||
|
||||
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help
|
||||
// string was extracted for the bare form; the verb is admin/advocate/
|
||||
// PSR gated server-side (GameActionChannelIndex.Handle).
|
||||
private static readonly Definition IndexChannels = NoArguments(
|
||||
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."
|
||||
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 => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
|
||||
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 => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
|
||||
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 => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
|
||||
InvalidArgumentsText: "Please specify the channel name.");
|
||||
|
||||
// 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.");
|
||||
|
||||
private static readonly FrozenDictionary<string, Definition> ByVerb =
|
||||
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
|
|
@ -216,6 +406,7 @@ public static class RetailClientCommandCatalog
|
|||
["pklarena"] = PkLiteArena,
|
||||
["pla"] = PkLiteArena,
|
||||
["pklite"] = PkLite,
|
||||
["pkl"] = PkLite,
|
||||
["hor"] = HouseRecall,
|
||||
["hr"] = HouseRecall,
|
||||
["hom"] = MansionRecall,
|
||||
|
|
@ -249,7 +440,25 @@ public static class RetailClientCommandCatalog
|
|||
["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>
|
||||
|
|
@ -271,35 +480,28 @@ public static class RetailClientCommandCatalog
|
|||
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))
|
||||
if (verb.Equals("house", StringComparison.OrdinalIgnoreCase)
|
||||
|| verb.Equals("hou", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
definition = arguments.ToLowerInvariant() switch
|
||||
{
|
||||
"recall" => HouseRecall,
|
||||
"mansion_recall" or "alleg_recall" => MansionRecall,
|
||||
_ => null,
|
||||
};
|
||||
if (definition is null)
|
||||
{
|
||||
match = new Match(
|
||||
ClientCommandId.HouseRecall,
|
||||
arguments,
|
||||
"/house recall | /house mansion_recall",
|
||||
HasValidArguments: false,
|
||||
InvalidArgumentsText: null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The subcommand selected the operation; its own handler has no
|
||||
// additional arguments.
|
||||
arguments = string.Empty;
|
||||
return TryMatchHouse(arguments, out match);
|
||||
}
|
||||
else if (!ByVerb.TryGetValue(verb, out definition))
|
||||
|
||||
if (verb.Equals("allegiance", StringComparison.OrdinalIgnoreCase)
|
||||
|| verb.Equals("all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TryMatchAllegiance(arguments, out match);
|
||||
}
|
||||
|
||||
if (!ByVerb.TryGetValue(verb, out definition))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
|
@ -313,6 +515,85 @@ public static class RetailClientCommandCatalog
|
|||
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) plus the pre-existing HasValidArguments==false swallow
|
||||
/// for a MISSPELLED recall variant. Every OTHER subcommand — open,
|
||||
/// close, storage, remove, boot, boot_all, remove_all, guest, available,
|
||||
/// hooks, on, off — is NOT yet ported (TS-68) and must reach ACE
|
||||
/// (which replies "Unknown command") rather than being swallowed
|
||||
/// locally with a wrong usage message — the Tier-1 #4 fix from the
|
||||
/// command-registry doc. Returning <c>false</c> here lets
|
||||
/// <see cref="ChatCommandRouter"/> fall through to server passthrough.
|
||||
/// </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). Every
|
||||
/// OTHER subcommand — boot, ban, officer, title, name, lock, house,
|
||||
/// motd, chat, broadcast — is NOT yet ported (TS-68) and falls through
|
||||
/// to server passthrough, same reasoning as <see cref="TryMatchHouse"/>.
|
||||
/// </summary>
|
||||
private static bool TryMatchAllegiance(string arguments, out Match match)
|
||||
{
|
||||
match = default;
|
||||
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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Help line generated from the same definition routing uses.</summary>
|
||||
public static string BuildHelpText() => string.Join("\n ",
|
||||
Lifestone.HelpText,
|
||||
|
|
@ -322,6 +603,7 @@ public static class RetailClientCommandCatalog
|
|||
PkLite.HelpText,
|
||||
HouseRecall.HelpText,
|
||||
MansionRecall.HelpText,
|
||||
HouseAbandon.HelpText,
|
||||
QueryAge.HelpText,
|
||||
QueryBirth.HelpText,
|
||||
FrameRate.HelpText,
|
||||
|
|
@ -345,7 +627,65 @@ public static class RetailClientCommandCatalog
|
|||
Filter.HelpText,
|
||||
Unfilter.HelpText,
|
||||
MessageTypes.HelpText,
|
||||
FillComponents.HelpText);
|
||||
FillComponents.HelpText,
|
||||
Endurance.HelpText,
|
||||
Speaker.HelpText,
|
||||
SetTitle.HelpText,
|
||||
ChatToggle.HelpText,
|
||||
NoTellToggle.HelpText,
|
||||
JoinChannel.HelpText,
|
||||
LeaveChannel.HelpText,
|
||||
Permit.HelpText,
|
||||
HouseAvailableList.HelpText,
|
||||
AllegianceHometown.HelpText,
|
||||
AllegianceInfo.HelpText);
|
||||
|
||||
/// <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>
|
||||
public static IReadOnlyCollection<string> KnownVerbs { get; } =
|
||||
ByVerb.Keys.Concat(["house", "hou", "allegiance", "all"]).ToArray();
|
||||
|
||||
/// <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) =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH4 (2026-08-09): <c>/help <verb></c> text for
|
||||
/// every retail-registry verb <see cref="RetailClientCommandCatalog"/>
|
||||
/// doesn't dispatch directly — chat aliases and channel verbs
|
||||
/// (<see cref="ChatInputParser"/>), the null-func help-only nodes (retail
|
||||
/// registers these with NO handler; typing them bare reaches the server,
|
||||
/// only <c>@help <verb></c> shows anything locally), and the
|
||||
/// allegiance/house command overviews (the per-subcommand detail lives
|
||||
/// here too, even though most subcommands are not yet locally executed —
|
||||
/// see TS-68).
|
||||
///
|
||||
/// <para>
|
||||
/// Every entry is verbatim retail text recovered from
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> by the
|
||||
/// recipe in the command-registry doc §5 (scan each <c>Help*</c>
|
||||
/// function's byte extent for <c>push imm32</c> into <c>.rdata</c>). Entries
|
||||
/// that could not be recovered this way (the 7 group-index headers'
|
||||
/// summary text, @day's confirmation lines, @render's option list) are
|
||||
/// NOT fabricated — they are simply absent from this table; the lookup
|
||||
/// falls through to a generic "no detailed help" line rather than guess.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailCommandHelpTable
|
||||
{
|
||||
// acclient_2013_pseudo_c.txt:1030771 (data_7dae40).
|
||||
public const string Tell =
|
||||
"@tell <name>, <text> - Sends a long-distance, private message to the specified character. Note that you must put a comma after the character's name.";
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1030742/1030745.
|
||||
public const string Reply =
|
||||
"@reply <text> - Sends the text to the last person who @tell'd you. You may also use @r or @rp.";
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1030753/1030757.
|
||||
public const string Retell =
|
||||
"@retell <text> - Sends the text to the last person you @tell'd. You may also use @rt.";
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1031564 (data_7e11e0), the "Note:" line
|
||||
// DoHelp @0x0057F9E0 prints alongside the bare group index.
|
||||
public const string HelpPrefixNote =
|
||||
"Note: You may substitute a forward slash (/) for the at symbol (@).";
|
||||
|
||||
// @mr/@pr are registered with a NULL function pointer in the 2013
|
||||
// build (verified at 0x00583041/0x005830C1 — arg3 is 0). Retail's own
|
||||
// HelpReply @0x00577A50 is shared between @reply/@r/@rp (which DO
|
||||
// execute) and @mr/@pr (which do NOT — they fall through to
|
||||
// DoChannelCommand, miss, and reach the server as literal text). The
|
||||
// shared help text is Reply's text above; acdream additionally notes
|
||||
// the non-execution here so /help mr doesn't imply it works.
|
||||
public const string MonarchReply =
|
||||
"@mr - Reply to the last person who @m'd you (monarch chat only). NOTE: this command is registered with no handler in the named retail build — it does not execute locally in retail OR acdream; typing it sends the literal text to the server.";
|
||||
|
||||
public const string PatronReply =
|
||||
"@pr - Reply to the last person who @p'd you (patron chat only). NOTE: this command is registered with no handler in the named retail build — it does not execute locally in retail OR acdream; typing it sends the literal text to the server.";
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1031093 (data_7de280).
|
||||
public const string Day =
|
||||
"@day - A toggle that lightens the outdoor landscape. Note that this command may take several seconds to take effect. NOT YET IMPLEMENTED in acdream — no sky/time-of-day override hook exists yet; the command reaches the server as literal text.";
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1031192-1031198 (data_7df7f8/data_7dfac4).
|
||||
public const string Log =
|
||||
"@log <name> - Echoes chat text to a logfile. All the information that appears in your chat window after you type this command will be copied into a text file. If this file already exists, it will add the additional text to the end of it. To turn off logging, simply retype @log. NOT YET IMPLEMENTED in acdream — the command reaches the server as literal text.";
|
||||
|
||||
public const string Render =
|
||||
"@render [options] - Forwards to the client's render-option surface (retail: SmartBox::HandleRenderOption). NOT YET IMPLEMENTED in acdream — there is no SmartBox equivalent; the command reaches the server as literal text.";
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1030670-1030676 (data_7da300/data_7da3e0).
|
||||
public const string Motd =
|
||||
"@allegiance motd - Displays the message of the day for your allegiance. @allegiance motd set <text> - Sets the MOTD. Can only be used by monarchs. @allegiance motd clear - Clears the MOTD. Can only be used by monarchs. NOT YET IMPLEMENTED in acdream (TS-68) — the command reaches the server as literal text.";
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1031234 (data_7e03d4) plus the full
|
||||
// per-subcommand block at 1031210-1031230 (data_7dfb58).
|
||||
public const string AllegianceOverview =
|
||||
"@allegiance - Commands to help manage your allegiance.\n"
|
||||
+ "@allegiance boot [-account] <name> - Removes a character from your allegiance.\n"
|
||||
+ "@allegiance ban <add/remove> <name> - Bans all characters on the given character's account from your allegiance (and boots them too!)\n"
|
||||
+ "@allegiance ban list - List the characters whose accounts are banned from your allegiance.\n"
|
||||
+ "@allegiance info <name> - Requests information on a member of your allegiance. [IMPLEMENTED]\n"
|
||||
+ "@allegiance chat <on/off> - Turn allegiance chat on and off.\n"
|
||||
+ "@allegiance chat kick <name>[, <reason>] - Kick a player temporarily from the allegiance chat room.\n"
|
||||
+ "@allegiance chat gag <name> - Gags a player so that they cannot see or speak in the allegiance chat room for 5 minutes.\n"
|
||||
+ "@allegiance chat ungag <name> - Ungags a gagged allegiance member so that they may once again see and speak in the allegiance chat room.\n"
|
||||
+ "@allegiance broadcast <message> - Broadcast a message to the entire allegiance. Limited to 10/day. Also: @ab [IMPLEMENTED as @ab]\n"
|
||||
+ "@allegiance officer <add/set> <level #> <name> - Assigns the position of officer, with the given level of permissions, to the named character.\n"
|
||||
+ "@allegiance officer <remove> <name> - Removed the named character as an allegiance officer.\n"
|
||||
+ "@allegiance officer clear - Clears all officer positions.\n"
|
||||
+ "@allegiance officer [list] - list your allegiance officer. Can be used by anyone in an allegiance.\n"
|
||||
+ "@allegiance title set <level #> <title> - Sets the title of the given officer level.\n"
|
||||
+ "@allegiance title clear - Clears all officer titles.\n"
|
||||
+ "@allegiance title [list] - Lists all the officer titles for your allegiance.\n"
|
||||
+ "@allegiance name <set/clear> - Displays, sets, or clears the name of your allegiance.\n"
|
||||
+ "@allegiance lock <on/off/toggle/check> - Locks, unlocks, or displays the locked state of your allegiance.\n"
|
||||
+ "@allegiance lock bypass <clear/name> - Sets, clears, or displays a single character as an approved vassal. That character may then swear into a locked allegiance.\n"
|
||||
+ "@allegiance hometown - Recalls you to your allegiance bindstone, if your allegiance has tied to one. [IMPLEMENTED, also @alh/@ah]\n"
|
||||
+ "@allegiance motd - Displays or sets the message of the day for your allegiance.\n"
|
||||
+ "Subcommands NOT marked [IMPLEMENTED] are not yet locally executed (TS-68) and reach the server as literal text.";
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1031041/1031045 (data_7dd908/data_7dd968)
|
||||
// plus the full per-subcommand block at 1031017-1031037 (data_7dd3d0).
|
||||
public const string HouseOverview =
|
||||
"@house - Commands that help you manage your house, including guest and storage management.\n"
|
||||
+ "@house abandon - Abandons your house. [IMPLEMENTED]\n"
|
||||
+ "@house boot <name> - Removes a player from your house.\n"
|
||||
+ "@house boot -all - Removes everyone from your house.\n"
|
||||
+ "@house guest add <name> - Adds players to your house guest list.\n"
|
||||
+ "@house guest remove <name> - Removes players from your house guest list.\n"
|
||||
+ "@house guest add_allegiance - Adds your allegiance to the guest list.\n"
|
||||
+ "@house guest remove_allegiance - Removes your allegiance from the guest list.\n"
|
||||
+ "@house guest remove_all - Removes all guests from your house guest list.\n"
|
||||
+ "@house guest list - Shows the current guest list.\n"
|
||||
+ "@house recall - Teleports you to your house. [IMPLEMENTED, also @hor/@hr]\n"
|
||||
+ "@house storage add <name> - Gives a player permission to use your house storage.\n"
|
||||
+ "@house storage remove <name> - Removes permission to use your house storage from a player.\n"
|
||||
+ "@house storage add_allegiance - Grants storage permission to your allegiance.\n"
|
||||
+ "@house storage remove_allegiance - Removes storage permission from your allegiance.\n"
|
||||
+ "@house storage remove_all - Removes all storage permissions from guests.\n"
|
||||
+ "@house open - Creates an open house.\n"
|
||||
+ "@house close - Closes your house.\n"
|
||||
+ "@house hooks on|off - Makes the hooks in your house visible or invisible.\n"
|
||||
+ "@house mansion_recall - Teleports you to your allegiance mansion or villa. [IMPLEMENTED, also @hom/@hoa]\n"
|
||||
+ "@house alleg_recall - Teleports you to your allegiance mansion or villa. [IMPLEMENTED, alias of mansion_recall]\n"
|
||||
+ "@house available - See @hslist [see @hslist, IMPLEMENTED]\n"
|
||||
+ "Subcommands NOT marked [IMPLEMENTED] are not yet locally executed (TS-68) and reach the server as literal text.";
|
||||
|
||||
// The 7 retail-registered "group index" nodes — retail registers them
|
||||
// with a NULL func (like @mr/@pr); typing one bare reaches the server.
|
||||
// Only @help <group> shows anything, and only this generic pointer —
|
||||
// the exact per-group summary/listing text (retail's HelpXxxGroup
|
||||
// functions) was not extracted this slice; see ISSUES.md.
|
||||
private const string GroupNodeNotExtracted =
|
||||
"This is a retail help-topic group; acdream has not yet extracted its exact retail listing text. Typing this verb alone (without @help) reaches the server as literal text, matching retail's null-handler registration.";
|
||||
|
||||
private static readonly FrozenDictionary<string, string> ByVerb =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["say"] = "@say <text> - Speaks the text aloud to nearby players.",
|
||||
["s"] = "@say <text> - Speaks the text aloud to nearby players.",
|
||||
["tell"] = Tell,
|
||||
["t"] = Tell,
|
||||
["send"] = Tell,
|
||||
["whisper"] = Tell,
|
||||
["w"] = Tell,
|
||||
["reply"] = Reply,
|
||||
["r"] = Reply,
|
||||
["rp"] = Reply,
|
||||
["retell"] = Retell,
|
||||
["rt"] = Retell,
|
||||
["mr"] = MonarchReply,
|
||||
["pr"] = PatronReply,
|
||||
["day"] = Day,
|
||||
["log"] = Log,
|
||||
["render"] = Render,
|
||||
["motd"] = Motd,
|
||||
["commands"] = GroupNodeNotExtracted,
|
||||
["allegiances"] = GroupNodeNotExtracted,
|
||||
["channels"] = GroupNodeNotExtracted,
|
||||
["chatting"] = GroupNodeNotExtracted,
|
||||
["death"] = GroupNodeNotExtracted,
|
||||
["status"] = GroupNodeNotExtracted,
|
||||
["text"] = GroupNodeNotExtracted,
|
||||
// Channel verbs — one line each, generated rather than
|
||||
// hand-extracted (retail's per-channel help strings were not
|
||||
// individually recovered this slice).
|
||||
["f"] = "Sends text to your Fellowship channel.",
|
||||
["fellow"] = "Sends text to your Fellowship channel.",
|
||||
["fellows"] = "Sends text to your Fellowship channel.",
|
||||
["fellowship"] = "Sends text to your Fellowship channel.",
|
||||
["g"] = "Sends text to your Fellowship channel.",
|
||||
["group"] = "Sends text to your Fellowship channel.",
|
||||
["party"] = "Sends text to your Fellowship channel.",
|
||||
["a"] = "Sends text to your Allegiance chat room.",
|
||||
["guild"] = "Sends text to your Allegiance chat room.",
|
||||
["gu"] = "Sends text to your Allegiance chat room.",
|
||||
["ab"] = "Broadcasts text to your entire allegiance (monarch/speaker permission). Also @allegiance broadcast.",
|
||||
["general"] = "Sends text to the General chat room.",
|
||||
["cg"] = "Sends text to the General chat room.",
|
||||
["trade"] = "Sends text to the Trade chat room.",
|
||||
["ct"] = "Sends text to the Trade chat room.",
|
||||
["lfg"] = "Sends text to the Looking-For-Group chat room.",
|
||||
["clfg"] = "Sends text to the Looking-For-Group chat room.",
|
||||
["roleplay"] = "Sends text to the Roleplay chat room.",
|
||||
["crp"] = "Sends text to the Roleplay chat room.",
|
||||
["society"] = "Sends text to your Society chat room.",
|
||||
["soc"] = "Sends text to your Society chat room.",
|
||||
["olthoi"] = "Sends text to the Olthoi Player Killer chat room.",
|
||||
["o"] = "Sends text to the Olthoi Player Killer chat room.",
|
||||
["m"] = "Sends text to your Monarch.",
|
||||
["monarch"] = "Sends text to your Monarch.",
|
||||
["p"] = "Sends text to your Patron.",
|
||||
["patron"] = "Sends text to your Patron.",
|
||||
["v"] = "Sends text to your Vassals.",
|
||||
["vassal"] = "Sends text to your Vassals.",
|
||||
["vassals"] = "Sends text to your Vassals.",
|
||||
["c"] = "Sends text to your Co-vassals.",
|
||||
["covassal"] = "Sends text to your Co-vassals.",
|
||||
["covassals"] = "Sends text to your Co-vassals.",
|
||||
["co-vassals"] = "Sends text to your Co-vassals.",
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static bool TryGetHelpText(string verb, out string helpText) =>
|
||||
ByVerb.TryGetValue(verb.TrimEnd(','), out helpText!);
|
||||
}
|
||||
17
src/AcDream.UI.Abstractions/SendRawChannelCmd.cs
Normal file
17
src/AcDream.UI.Abstractions/SendRawChannelCmd.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace AcDream.UI.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH4 (2026-08-09): broadcast to a legacy <c>ChatChannel
|
||||
/// (0x0147)</c> bitflag id resolved directly from a channel TAG, bypassing
|
||||
/// <see cref="ChatChannelKind"/> and <see cref="ChannelResolver"/> entirely.
|
||||
///
|
||||
/// <para>
|
||||
/// This is retail's <c>DoChannelCommand @ 0x005774A0</c> fallback path — the
|
||||
/// 22 <c>ChannelSystem::GetChannelID</c> tags that have no registered verb
|
||||
/// (GM/faction channels like <c>@admin</c>, <c>@sentinel</c>,
|
||||
/// <c>@celestialhand</c>) plus the argument channel-tag resolution
|
||||
/// <c>@clist</c>/<c>@on</c>/<c>@off</c> use. See
|
||||
/// <see cref="Panels.Chat.RetailChannelTagTable"/> for the tag→id table.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed record SendRawChannelCmd(uint ChannelId, string Text);
|
||||
|
|
@ -614,5 +614,18 @@ public sealed class LiveSessionCommandRouterTests
|
|||
ClearDesiredComponents: () => { },
|
||||
HasOpenVendor: () => false,
|
||||
FillComponentBuyList: (_, _) => { },
|
||||
EnterPkLite: () => { });
|
||||
EnterPkLite: () => { },
|
||||
IsUsingTurbineChat: () => false,
|
||||
SetChatTitle: _ => { },
|
||||
SetSingleCharacterOption: (_, _) => { },
|
||||
AddPlayerPermission: _ => { },
|
||||
RemovePlayerPermission: _ => { },
|
||||
RequestAvailableHouses: _ => { },
|
||||
RequestChannelIndex: () => { },
|
||||
RequestChannelList: _ => { },
|
||||
JoinGmChannel: _ => { },
|
||||
LeaveGmChannel: _ => { },
|
||||
RecallAllegianceHometown: () => { },
|
||||
RequestAllegianceInfo: _ => { },
|
||||
AbandonHouse: () => { });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -377,6 +377,19 @@ public sealed class ClientCommandControllerTests
|
|||
() => calls.Add("clearcomps"),
|
||||
() => vendorOpen,
|
||||
(category, price) => calls.Add($"fillcomps:{category}:{price}"),
|
||||
() => calls.Add("pklite")));
|
||||
() => calls.Add("pklite"),
|
||||
() => false,
|
||||
title => calls.Add("title:" + title),
|
||||
(optionId, value) => calls.Add($"charoption:{optionId}:{value}"),
|
||||
name => calls.Add("permitadd:" + name),
|
||||
name => calls.Add("permitremove:" + name),
|
||||
houseType => calls.Add("hslist:" + houseType),
|
||||
() => calls.Add("index"),
|
||||
channelId => calls.Add("clist:" + channelId),
|
||||
channelId => calls.Add("on:" + channelId),
|
||||
channelId => calls.Add("off:" + channelId),
|
||||
() => calls.Add("alh"),
|
||||
name => calls.Add("alleginfo:" + name),
|
||||
() => calls.Add("houseabandon")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,4 +139,84 @@ public class ChatCommandRouterTests
|
|||
Assert.Equal(SubmitOutcome.Empty, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
}
|
||||
|
||||
// ── Campaign CH slice CH4 (2026-08-09) ──────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(":waves")]
|
||||
[InlineData(";waves")]
|
||||
public void EmotePrefix_RewritesToAtEmote(string raw)
|
||||
{
|
||||
var (vm, _, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit(raw, vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ClientCommandId.Emote, command.Command);
|
||||
Assert.Equal("waves", command.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnregisteredChannelTag_PublishesRawChannelBroadcast()
|
||||
{
|
||||
var (vm, _, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/admin server is misbehaving", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||||
var command = Assert.IsType<SendRawChannelCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(0x00000002u, command.ChannelId);
|
||||
Assert.Equal("server is misbehaving", command.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnregisteredChannelTag_WithNoText_ShowsRetailRefusal_AndPublishesNothing()
|
||||
{
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/sentinel", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Contains(log.Snapshot(), entry => entry.Text == "You must specify the text you wish to say!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredChannelVerb_NeverReachesTheRawFallback()
|
||||
{
|
||||
// "/f" is a KNOWN ChatInputParser verb (Fellowship) — it must ride
|
||||
// the normal SendChatCmd/self-echo path, not the raw fallback.
|
||||
var (vm, _, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/f hi gang", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||||
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ChatChannelKind.Fellowship, command.Channel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelpVerb_ShowsCatalogHelpText()
|
||||
{
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/help lifestone", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("Returns you to the last lifestone"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelpVerb_UnknownVerb_ShowsFallbackMessage()
|
||||
{
|
||||
var (vm, log, bus) = Fixture();
|
||||
|
||||
var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say);
|
||||
|
||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||
Assert.Empty(bus.Published);
|
||||
Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("No help available"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,11 +15,17 @@ public sealed class ChatInputParserAtPrefixTests
|
|||
{
|
||||
[Theory]
|
||||
[InlineData("@a hi gang", ChatChannelKind.Allegiance, "hi gang")]
|
||||
[InlineData("@allegiance recall", ChatChannelKind.Allegiance, "recall")]
|
||||
[InlineData("@guild hi gang", ChatChannelKind.Allegiance, "hi gang")]
|
||||
[InlineData("@p heads up", ChatChannelKind.Patron, "heads up")]
|
||||
[InlineData("@patron heads up", ChatChannelKind.Patron, "heads up")]
|
||||
[InlineData("@f buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("@g general msg", ChatChannelKind.General, "general msg")]
|
||||
// Campaign CH slice CH4 (2026-08-09): "/g" moved from General to
|
||||
// Fellowship (Tier 1 fix #1 — retail binds g/group/party to
|
||||
// Fellowship, 0x800); "/allegiance" is DELETED from this table — it
|
||||
// is now RetailClientCommandCatalog's allegiance MANAGEMENT command,
|
||||
// not a channel verb (Tier 1 fix #3).
|
||||
[InlineData("@g general msg", ChatChannelKind.Fellowship, "general msg")]
|
||||
[InlineData("@general general msg", ChatChannelKind.General, "general msg")]
|
||||
public void AtPrefix_KnownChannelVerb_RoutesSameAsSlash(string raw, ChatChannelKind expected, string text)
|
||||
{
|
||||
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
|
||||
|
|
@ -62,6 +68,11 @@ public sealed class ChatInputParserAtPrefixTests
|
|||
[InlineData("@version")]
|
||||
[InlineData("@loc")] // ACE has @loc server-side too; passes through
|
||||
[InlineData("@nonsense filler")]
|
||||
// "@allegiance"/"@all" are RetailClientCommandCatalog verbs (the
|
||||
// management command), NOT ChatInputParser channel verbs — at this
|
||||
// layer (pure Parse, no catalog check) they pass through unknown.
|
||||
[InlineData("@allegiance boot Bob")]
|
||||
[InlineData("@house open")]
|
||||
public void AtPrefix_UnknownVerb_PassesThroughIntactAsDefaultChannel(string raw)
|
||||
{
|
||||
// Critical: the @-prefix is preserved in Text so ACE's
|
||||
|
|
|
|||
|
|
@ -116,19 +116,89 @@ public sealed class ChatInputParserTests
|
|||
Assert.Null(parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RpAlias_IsReply_NotRoleplay()
|
||||
{
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #2: retail's own
|
||||
// help text confirms "@reply <text> ... You may also use @r or
|
||||
// @rp." — a private reply, not a Roleplay broadcast.
|
||||
var parsed = ChatInputParser.Parse("/rp back at you", ChatChannelKind.Say, lastTellSender: "Bestie");
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
||||
Assert.Equal("Bestie", parsed.Value.TargetName);
|
||||
Assert.Equal("back at you", parsed.Value.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TellAliases_SendWhisperW_AllRouteAsTell()
|
||||
{
|
||||
// Campaign CH slice CH4 (2026-08-09): retail's DoTell registers
|
||||
// FOUR verb strings — tell/t/send/whisper/w.
|
||||
foreach (string verb in new[] { "/send", "/whisper", "/w" })
|
||||
{
|
||||
var parsed = ChatInputParser.Parse($"{verb} Bestie hi", ChatChannelKind.Say, lastTellSender: null);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
||||
Assert.Equal("Bestie", parsed.Value.TargetName);
|
||||
Assert.Equal("hi", parsed.Value.Text);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetellAlias_Rt_RoutesLikeRetell()
|
||||
{
|
||||
var parsed = ChatInputParser.Parse(
|
||||
"/rt once more",
|
||||
ChatChannelKind.Say,
|
||||
lastTellSender: null,
|
||||
lastOutgoingTellTarget: "Caith");
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
||||
Assert.Equal("Caith", parsed.Value.TargetName);
|
||||
Assert.Equal("once more", parsed.Value.Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/tell Aunt Agatha, hello", "Aunt Agatha", "hello")]
|
||||
[InlineData("/t Aunt Agatha, hello", "Aunt Agatha", "hello")]
|
||||
public void TellTarget_SplitsOnFirstComma_NotFirstWhitespace(string raw, string expectedTarget, string expectedText)
|
||||
{
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #6 (A.3): retail's
|
||||
// DoTell requires a comma after the name precisely because names
|
||||
// can be multiple words. Splitting on the first WHITESPACE (the
|
||||
// pre-CH4 behavior) truncated "Aunt Agatha" to "Aunt".
|
||||
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
||||
Assert.Equal(expectedTarget, parsed.Value.TargetName);
|
||||
Assert.Equal(expectedText, parsed.Value.Text);
|
||||
}
|
||||
|
||||
// -- Channel aliases (single-message) -------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("/g raid time", ChatChannelKind.General, "raid time")]
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #1: "/g" is
|
||||
// Fellowship in retail (g/group/party/fellow/fellows/fellowship all
|
||||
// bind 0x800), NOT General — sending fellowship chatter to General
|
||||
// was a live correctness bug.
|
||||
[InlineData("/g raid time", ChatChannelKind.Fellowship, "raid time")]
|
||||
[InlineData("/f buff up", ChatChannelKind.Fellowship, "buff up")]
|
||||
[InlineData("/a swearing in", ChatChannelKind.Allegiance, "swearing in")]
|
||||
[InlineData("/m monarch broadcast", ChatChannelKind.Monarch, "monarch broadcast")]
|
||||
[InlineData("/p patron only", ChatChannelKind.Patron, "patron only")]
|
||||
[InlineData("/v vassals only", ChatChannelKind.Vassals, "vassals only")]
|
||||
[InlineData("/cv covassals only", ChatChannelKind.CoVassals, "covassals only")]
|
||||
// "/cv" (an acdream invention) is DELETED; "/c" is the real retail
|
||||
// Co-vassals alias (registry doc §2.3).
|
||||
[InlineData("/c covassals only", ChatChannelKind.CoVassals, "covassals only")]
|
||||
[InlineData("/lfg need 3 more", ChatChannelKind.Lfg, "need 3 more")]
|
||||
[InlineData("/trade wts gem", ChatChannelKind.Trade, "wts gem")]
|
||||
[InlineData("/role *waves*", ChatChannelKind.Roleplay, "*waves*")]
|
||||
// "/role" (an acdream invention) is DELETED; "/roleplay" is retail's
|
||||
// real verb (see LongFormAliases_RouteToTheirChannel for the full
|
||||
// roleplay/reply-alias split).
|
||||
[InlineData("/roleplay *waves*", ChatChannelKind.Roleplay, "*waves*")]
|
||||
[InlineData("/society olthoi raid", ChatChannelKind.Society, "olthoi raid")]
|
||||
[InlineData("/olthoi for the queen", ChatChannelKind.Olthoi, "for the queen")]
|
||||
public void ChannelPrefixes_RouteToTheirChannel(string raw, ChatChannelKind expectedChannel, string expectedText)
|
||||
|
|
@ -270,8 +340,17 @@ public sealed class ChatInputParserTests
|
|||
[InlineData("/say", true)]
|
||||
[InlineData("/tell", true)]
|
||||
[InlineData("/retell", true)]
|
||||
[InlineData("/allegiance", true)]
|
||||
[InlineData("/lookingforgroup", true)]
|
||||
[InlineData("/rt", true)]
|
||||
[InlineData("/rp", true)] // reply alias now, not Roleplay (Tier 1 fix #2)
|
||||
[InlineData("/guild", true)]
|
||||
// Campaign CH slice CH4 (2026-08-09): "/allegiance"/"/lookingforgroup"
|
||||
// are DELETED from ChatInputParser — "allegiance" is now
|
||||
// RetailClientCommandCatalog's management command (Tier 1 fix #3);
|
||||
// "lookingforgroup" was never a retail verb (doc §4 removal list).
|
||||
[InlineData("/allegiance", false)]
|
||||
[InlineData("/lookingforgroup", false)]
|
||||
[InlineData("/role", false)] // acdream invention, deleted
|
||||
[InlineData("/cv", false)] // acdream invention, deleted
|
||||
[InlineData("/genio", false)]
|
||||
[InlineData("/ls", false)]
|
||||
[InlineData("/foo", false)]
|
||||
|
|
@ -281,6 +360,31 @@ public sealed class ChatInputParserTests
|
|||
Assert.Equal(expected, ChatInputParser.IsKnownVerb(verb));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsKnownVerb_TrimsTrailingComma()
|
||||
{
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #5: retail's
|
||||
// DoCommand right-trims ',' off the verb before lookup — the verb
|
||||
// TOKEN itself ("/f,", as GetVerbToken would extract from "/f, hi").
|
||||
Assert.True(ChatInputParser.IsKnownVerb("/f,"));
|
||||
Assert.True(ChatInputParser.IsKnownVerb("/f"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommaTrimmedVerb_ParsesTheSameAsWithoutComma()
|
||||
{
|
||||
// "/f, hi" == "/f hi" end to end through Parse.
|
||||
var withComma = ChatInputParser.Parse("/f, hi", ChatChannelKind.Say, lastTellSender: null);
|
||||
var withoutComma = ChatInputParser.Parse("/f hi", ChatChannelKind.Say, lastTellSender: null);
|
||||
|
||||
Assert.NotNull(withComma);
|
||||
Assert.NotNull(withoutComma);
|
||||
Assert.Equal(withoutComma!.Value.Channel, withComma!.Value.Channel);
|
||||
Assert.Equal(withoutComma.Value.Text, withComma.Value.Text);
|
||||
Assert.Equal(ChatChannelKind.Fellowship, withComma.Value.Channel);
|
||||
Assert.Equal("hi", withComma.Value.Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/g hello", "/g")]
|
||||
[InlineData("/tell Bob hi", "/tell")]
|
||||
|
|
@ -293,16 +397,19 @@ public sealed class ChatInputParserTests
|
|||
|
||||
[Theory]
|
||||
[InlineData("/general what's the deal", ChatChannelKind.General, "what's the deal")]
|
||||
[InlineData("/allegiance recall", ChatChannelKind.Allegiance, "recall")]
|
||||
[InlineData("/guild recall", ChatChannelKind.Allegiance, "recall")]
|
||||
[InlineData("/patron need help", ChatChannelKind.Patron, "need help")]
|
||||
[InlineData("/vassals listen up", ChatChannelKind.Vassals, "listen up")]
|
||||
[InlineData("/monarch heads up", ChatChannelKind.Monarch, "heads up")]
|
||||
[InlineData("/covassals tax season", ChatChannelKind.CoVassals, "tax season")]
|
||||
[InlineData("/fellowship buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/fellow buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/lookingforgroup hunt invite", ChatChannelKind.Lfg, "hunt invite")]
|
||||
[InlineData("/fellows buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/group buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/party buff time", ChatChannelKind.Fellowship, "buff time")]
|
||||
[InlineData("/clfg hunt invite", ChatChannelKind.Lfg, "hunt invite")]
|
||||
[InlineData("/roleplay walk-up", ChatChannelKind.Roleplay, "walk-up")]
|
||||
[InlineData("/rp walk-up", ChatChannelKind.Roleplay, "walk-up")]
|
||||
[InlineData("/crp walk-up", ChatChannelKind.Roleplay, "walk-up")]
|
||||
public void LongFormAliases_RouteToTheirChannel(string raw, ChatChannelKind expected, string text)
|
||||
{
|
||||
// Phase J: retail muscle memory uses long forms ("/patron"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ public sealed class ChatPanelInputTests
|
|||
|
||||
[Theory]
|
||||
[InlineData("/?")]
|
||||
[InlineData("/h")]
|
||||
// "/h" is DELETED (Campaign CH slice CH4, 2026-08-09) — it is not a
|
||||
// retail-registered verb (registry doc §4's removal list).
|
||||
[InlineData("/HELP")]
|
||||
public void Submit_HelpAliases_AlsoRenderLocalHelp(string raw)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -90,13 +90,156 @@ public sealed class RetailClientCommandCatalogTests
|
|||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnsupportedHouseSubcommand_RemainsClientOwnedAndShowsUsage()
|
||||
[Theory]
|
||||
[InlineData("/house open")]
|
||||
[InlineData("/house close")]
|
||||
[InlineData("/house guest add Bob")]
|
||||
[InlineData("/house storage add Bob")]
|
||||
[InlineData("/house nope")]
|
||||
[InlineData("/house available")]
|
||||
public void UnsupportedHouseSubcommand_FallsThroughToServerPassthrough(string input)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch("/house nope", out var match));
|
||||
// Campaign CH slice CH4 (2026-08-09), Tier 1 fix #4: unrecognized
|
||||
// house subcommands must reach ACE (TS-68), not be swallowed
|
||||
// locally with a wrong usage message.
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch(input, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/house abandon", ClientCommandId.HouseAbandon)]
|
||||
[InlineData("/house re", ClientCommandId.HouseRecall)]
|
||||
[InlineData("/house ma", ClientCommandId.MansionRecall)]
|
||||
[InlineData("/hou recall", ClientCommandId.HouseRecall)]
|
||||
public void HouseAliasesAndShortcuts_Resolve(string input, ClientCommandId expected)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/allegiance boot Bob")]
|
||||
[InlineData("/allegiance ban add Bob")]
|
||||
[InlineData("/allegiance motd")]
|
||||
[InlineData("/allegiance")]
|
||||
[InlineData("/all officer add 2 Bob")]
|
||||
public void UnsupportedAllegianceSubcommand_FallsThroughToServerPassthrough(string input)
|
||||
{
|
||||
// Same Tier-1-class fix, applied to the allegiance management
|
||||
// dispatcher (TS-68): unrecognized subcommands reach ACE.
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch(input, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/allegiance hometown", ClientCommandId.AllegianceHometown, "")]
|
||||
[InlineData("/allegiance ho", ClientCommandId.AllegianceHometown, "")]
|
||||
[InlineData("/alh", ClientCommandId.AllegianceHometown, "")]
|
||||
[InlineData("/ah", ClientCommandId.AllegianceHometown, "")]
|
||||
[InlineData("/allegiance info", ClientCommandId.AllegianceInfo, "")]
|
||||
[InlineData("/allegiance info Bob", ClientCommandId.AllegianceInfo, "Bob")]
|
||||
[InlineData("/all info Bob", ClientCommandId.AllegianceInfo, "Bob")]
|
||||
public void AllegianceImplementedSubcommands_Resolve(
|
||||
string input, ClientCommandId expected, string expectedArguments)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
Assert.Equal(expectedArguments, match.Arguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/pkl", ClientCommandId.EnterPkLite)]
|
||||
[InlineData("/message_types", ClientCommandId.ListMessageTypes)]
|
||||
[InlineData("/msgtypes", ClientCommandId.ListMessageTypes)]
|
||||
[InlineData("/msg_types", ClientCommandId.ListMessageTypes)]
|
||||
[InlineData("/endurance", ClientCommandId.Endurance)]
|
||||
[InlineData("/speaker", ClientCommandId.Speaker)]
|
||||
[InlineData("/index", ClientCommandId.IndexChannels)]
|
||||
public void MissingAliasesSweep_Resolve(string input, ClientCommandId expected)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expected, match.Command);
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/chat on")]
|
||||
[InlineData("/chat off")]
|
||||
[InlineData("/notell on")]
|
||||
[InlineData("/notell off")]
|
||||
public void ChatNoTellToggle_ValidArguments_Resolve(string input)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatToggle_InvalidArgument_IsRejected()
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch("/chat maybe", out var match));
|
||||
Assert.False(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/join allegiance")]
|
||||
[InlineData("/join general")]
|
||||
[InlineData("/leave society")]
|
||||
[InlineData("/leave soc")]
|
||||
public void JoinLeave_ValidTags_Resolve(string input)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.True(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoinLeave_InvalidTag_IsRejected()
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch("/join nonsense", out var match));
|
||||
Assert.False(match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/permit add Bob", true)]
|
||||
[InlineData("/permit remove Bob", true)]
|
||||
[InlineData("/permit add", false)]
|
||||
[InlineData("/permit maybe Bob", false)]
|
||||
public void Permit_ArgumentShape(string input, bool expectedValid)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expectedValid, match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/hslist Cottage", true)]
|
||||
[InlineData("/hslist mansion", true)]
|
||||
[InlineData("/hslist nonsense", false)]
|
||||
public void HouseAvailableList_ArgumentShape(string input, bool expectedValid)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expectedValid, match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/clist fellowship", true)]
|
||||
[InlineData("/on admin", true)]
|
||||
[InlineData("/off nonsense", false)]
|
||||
public void ChannelArgumentCommands_ResolveTagsAgainstRetailChannelTagTable(string input, bool expectedValid)
|
||||
{
|
||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||
Assert.Equal(expectedValid, match.HasValidArguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MrPr_AreNeverExecutable()
|
||||
{
|
||||
// Retail registers @mr/@pr with a NULL function pointer — they
|
||||
// must never resolve as client-owned commands (Tier B.9).
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch("/mr", out _));
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch("/pr", out _));
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch("/mr hello", out _));
|
||||
Assert.False(RetailClientCommandCatalog.TryMatch("/pr hello", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LifestoneArgument_IsRecognizedButInvalid()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CH slice CH4 (2026-08-09): pins acdream's complete chat-command
|
||||
/// surface against the retail registry enumeration in
|
||||
/// <c>docs/research/2026-08-09-chat-retail-command-registry.md</c> §2/§4 —
|
||||
/// 130 verbs registered by <c>InitializeCommands</c> +
|
||||
/// <c>StartupTurbineChatSystem</c>, plus the 22 unregistered
|
||||
/// <c>ChannelSystem::GetChannelID</c> fallback tags (152 total).
|
||||
///
|
||||
/// <para>
|
||||
/// Every verb has an explicit <see cref="Status"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="Status.Implemented"/> — executes locally (typed client
|
||||
/// command, chat alias, channel send, or local presentation).</item>
|
||||
/// <item><see cref="Status.HelpOnly"/> — retail registers it with a NULL
|
||||
/// function pointer (verified byte-level in the registry doc); it can
|
||||
/// never execute in retail OR acdream. Typing it bare reaches the server
|
||||
/// as literal text.</item>
|
||||
/// <item><see cref="Status.ServerPassthrough"/> — a real retail verb
|
||||
/// acdream has not yet ported (TS-68/TS-69/TS-70, or the deliberate
|
||||
/// @loadfile product decision); falls through to ACE as literal text.</item>
|
||||
/// </list>
|
||||
/// The FIRST test enforces that every verb resolves to the RIGHT status.
|
||||
/// The SECOND enforces the ownership rule in reverse: nothing in
|
||||
/// <see cref="RetailClientCommandCatalog"/> or <see cref="ChatInputParser"/>
|
||||
/// exists that ISN'T in this registry (no invented verbs slip back in).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RetailCommandRegistryConformanceTests
|
||||
{
|
||||
private enum Status { Implemented, HelpOnly, ServerPassthrough }
|
||||
|
||||
private sealed record Entry(Status Status, params string[] Verbs);
|
||||
|
||||
/// <summary>
|
||||
/// The complete 152-verb registry, one entry per retail handler (or
|
||||
/// per fallback-tag family), transcribed directly from the
|
||||
/// command-registry doc's §2 tables. Verb-string counts per section
|
||||
/// were cross-checked against the doc's own §4 totals (130 + 22 = 152;
|
||||
/// the Turbine §2.4 net-new count here is exactly 14, matching the
|
||||
/// doc's "15 added, 14 net-new (a replaced)" note) before this test
|
||||
/// was written.
|
||||
/// </summary>
|
||||
private static readonly Entry[] Registry =
|
||||
[
|
||||
// §2.1 Help / group nodes (9)
|
||||
new(Status.Implemented, "help", "?"),
|
||||
new(Status.HelpOnly, "commands"),
|
||||
new(Status.HelpOnly, "allegiances"),
|
||||
new(Status.HelpOnly, "channels"),
|
||||
new(Status.HelpOnly, "chatting"),
|
||||
new(Status.HelpOnly, "death"),
|
||||
new(Status.HelpOnly, "status"),
|
||||
new(Status.HelpOnly, "text"),
|
||||
|
||||
// §2.2 Local chat routing (31)
|
||||
new(Status.Implemented, "say", "s"),
|
||||
new(Status.Implemented, "tell", "t", "send", "whisper", "w"),
|
||||
new(Status.Implemented, "reply", "r", "rp"),
|
||||
new(Status.HelpOnly, "mr"),
|
||||
new(Status.HelpOnly, "pr"),
|
||||
new(Status.Implemented, "retell", "rt"),
|
||||
new(Status.Implemented, "chat"),
|
||||
new(Status.Implemented, "notell"),
|
||||
new(Status.Implemented, "join"),
|
||||
new(Status.Implemented, "leave"),
|
||||
new(Status.Implemented, "index"),
|
||||
new(Status.Implemented, "clist"),
|
||||
new(Status.Implemented, "on"),
|
||||
new(Status.Implemented, "off"),
|
||||
new(Status.Implemented, "title"),
|
||||
new(Status.ServerPassthrough, "log"), // TS-69
|
||||
new(Status.Implemented, "clear"),
|
||||
new(Status.Implemented, "filter"),
|
||||
new(Status.Implemented, "unfilter"),
|
||||
new(Status.Implemented, "messagetypes", "message_types", "msgtypes", "msg_types"),
|
||||
new(Status.ServerPassthrough, "loadfile"), // deliberate — doc §3 "explicitly do NOT implement"
|
||||
|
||||
// §2.3 Chat channels (20)
|
||||
new(Status.Implemented, "a", "ab"),
|
||||
new(Status.Implemented, "co-vassals", "covassals", "covassal", "c"),
|
||||
new(Status.Implemented, "monarch", "m"),
|
||||
new(Status.Implemented, "patron", "p"),
|
||||
new(Status.Implemented, "vassals", "vassal", "v"),
|
||||
new(Status.Implemented, "fellowship", "fellows", "fellow", "f", "group", "g", "party"),
|
||||
|
||||
// §2.3 fallback: 22 GetChannelID tags with NO registered verb (22)
|
||||
new(Status.Implemented, "av", "av1", "advocate", "advocate1"),
|
||||
new(Status.Implemented, "av2", "advocate2"),
|
||||
new(Status.Implemented, "av3", "advocate3"),
|
||||
new(Status.Implemented, "abuse"),
|
||||
new(Status.Implemented, "ad", "admin"),
|
||||
new(Status.Implemented, "au", "audit"),
|
||||
new(Status.Implemented, "sent", "sentinel"),
|
||||
new(Status.Implemented, "celestialhand", "celhan"),
|
||||
new(Status.Implemented, "eldrytchweb", "eldweb"),
|
||||
new(Status.Implemented, "radiantblood", "radblo"),
|
||||
new(Status.Implemented, "ol"),
|
||||
|
||||
// §2.4 Turbine chat, 14 net-new (a already counted in §2.3) (14)
|
||||
new(Status.Implemented, "guild", "gu"),
|
||||
new(Status.Implemented, "general", "cg"),
|
||||
new(Status.Implemented, "trade", "ct"),
|
||||
new(Status.Implemented, "lfg", "clfg"),
|
||||
new(Status.Implemented, "roleplay", "crp"),
|
||||
new(Status.Implemented, "society", "soc"),
|
||||
new(Status.Implemented, "olthoi", "o"),
|
||||
|
||||
// §2.5 Allegiance management (6; "ab" already counted in §2.3)
|
||||
new(Status.Implemented, "allegiance", "all"),
|
||||
new(Status.Implemented, "alh", "ah"),
|
||||
new(Status.ServerPassthrough, "motd"), // TS-68
|
||||
new(Status.Implemented, "speaker"),
|
||||
|
||||
// §2.5b Housing (7)
|
||||
new(Status.Implemented, "house", "hou"),
|
||||
new(Status.Implemented, "hor", "hr"),
|
||||
new(Status.Implemented, "hom", "hoa"),
|
||||
new(Status.Implemented, "hslist"),
|
||||
|
||||
// §2.6 Death / recall / PK (17)
|
||||
new(Status.Implemented, "lifestone", "lif", "ls"),
|
||||
new(Status.Implemented, "marketplace", "mar", "mp"),
|
||||
new(Status.Implemented, "pkarena", "pka"),
|
||||
new(Status.Implemented, "pklarena", "pla"),
|
||||
new(Status.Implemented, "pklite", "pkl"),
|
||||
new(Status.Implemented, "die"),
|
||||
new(Status.Implemented, "corpse", "cor"),
|
||||
new(Status.Implemented, "consent"),
|
||||
new(Status.Implemented, "permit"),
|
||||
|
||||
// §2.7 Status / display (8)
|
||||
new(Status.Implemented, "age"),
|
||||
new(Status.Implemented, "birth"),
|
||||
new(Status.ServerPassthrough, "day"), // TS-69 — no sky/time-of-day override hook
|
||||
new(Status.Implemented, "endurance"),
|
||||
new(Status.Implemented, "framerate"),
|
||||
new(Status.Implemented, "loc"),
|
||||
new(Status.Implemented, "version"),
|
||||
new(Status.ServerPassthrough, "render"), // TS-69 — no SmartBox equivalent
|
||||
|
||||
// §2.8 Interface layout, emotes, social, components (18)
|
||||
new(Status.Implemented, "saveui"),
|
||||
new(Status.Implemented, "loadui"),
|
||||
new(Status.Implemented, "saveautoui"),
|
||||
new(Status.Implemented, "loadautoui"),
|
||||
new(Status.Implemented, "lockui"),
|
||||
new(Status.Implemented, "emote", "e", "em", "me"),
|
||||
new(Status.Implemented, "emotes"),
|
||||
new(Status.Implemented, "afk"),
|
||||
new(Status.Implemented, "friends"),
|
||||
new(Status.Implemented, "friends_add"),
|
||||
new(Status.Implemented, "friends_remove"),
|
||||
new(Status.Implemented, "squelch"),
|
||||
new(Status.Implemented, "unsquelch"),
|
||||
new(Status.Implemented, "fillcomps"),
|
||||
];
|
||||
|
||||
private static readonly HashSet<string> CatalogVerbs =
|
||||
new(RetailClientCommandCatalog.KnownVerbs, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsExecutable(string verb)
|
||||
{
|
||||
if (verb is "help" or "?")
|
||||
return true; // local presentation — not a catalog/parser entry.
|
||||
if (CatalogVerbs.Contains(verb))
|
||||
return true;
|
||||
if (ChatInputParser.IsKnownVerb("/" + verb))
|
||||
return true;
|
||||
if (RetailChannelTagTable.IsUnregisteredFallbackTag(verb))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> AllVerbsWithStatus() =>
|
||||
Registry.SelectMany(entry => entry.Verbs.Select(verb => new object[] { verb, entry.Status }));
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllVerbsWithStatus))]
|
||||
public void EveryRegistryVerb_HasTheCorrectOwnershipStatus(string verb, object statusObj)
|
||||
{
|
||||
var status = (Status)statusObj;
|
||||
bool executable = IsExecutable(verb);
|
||||
if (status == Status.Implemented)
|
||||
{
|
||||
Assert.True(executable,
|
||||
$"'{verb}' is marked Implemented in the registry but no production surface " +
|
||||
"(RetailClientCommandCatalog, ChatInputParser, or RetailChannelTagTable) recognizes it.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(executable,
|
||||
$"'{verb}' is marked {status} (must fall through to server passthrough) but a " +
|
||||
"production surface claims to execute it locally — that's a real behavior change " +
|
||||
"the registry doesn't know about yet.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_EnumeratesExactly152Verbs()
|
||||
{
|
||||
int total = Registry.Sum(entry => entry.Verbs.Length);
|
||||
Assert.Equal(152, total);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_StatusCountsMatchTheAuditedTotals()
|
||||
{
|
||||
Assert.Equal(9, Registry.Where(e => e.Status == Status.HelpOnly).Sum(e => e.Verbs.Length));
|
||||
Assert.Equal(5, Registry.Where(e => e.Status == Status.ServerPassthrough).Sum(e => e.Verbs.Length));
|
||||
Assert.Equal(138, Registry.Where(e => e.Status == Status.Implemented).Sum(e => e.Verbs.Length));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoDuplicateVerbsAcrossEntries()
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Entry entry in Registry)
|
||||
{
|
||||
foreach (string verb in entry.Verbs)
|
||||
{
|
||||
Assert.True(seen.Add(verb), $"'{verb}' appears more than once in the registry.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ownership-rule enforcement, reverse direction ──────────────────
|
||||
//
|
||||
// A verb RetailClientCommandCatalog or ChatInputParser claims to
|
||||
// execute that ISN'T in the registry above is exactly the "acdream
|
||||
// invented a verb retail doesn't have" class of bug the doc's §4
|
||||
// "candidates for removal" list called out (gen, cv, lookingforgroup,
|
||||
// tr, role, h) — deleted at CH4. This test fails the build the next
|
||||
// time one slips back in.
|
||||
|
||||
[Fact]
|
||||
public void RetailClientCommandCatalog_HasNoVerbsOutsideTheRegistry()
|
||||
{
|
||||
var registryVerbs = new HashSet<string>(
|
||||
Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (string verb in RetailClientCommandCatalog.KnownVerbs)
|
||||
{
|
||||
Assert.True(registryVerbs.Contains(verb),
|
||||
$"RetailClientCommandCatalog recognizes '{verb}', which is not in the retail " +
|
||||
"command-registry doc — either it's a genuine retail verb missing from this " +
|
||||
"test's registry, or it's an invented alias that must be deleted.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChatInputParser_HasNoVerbsOutsideTheRegistry()
|
||||
{
|
||||
var registryVerbs = new HashSet<string>(
|
||||
Registry.SelectMany(e => e.Verbs), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (string verbWithSlash in ChatInputParser.KnownVerbs)
|
||||
{
|
||||
string verb = verbWithSlash.TrimStart('/');
|
||||
Assert.True(registryVerbs.Contains(verb),
|
||||
$"ChatInputParser recognizes '{verbWithSlash}', which is not in the retail " +
|
||||
"command-registry doc — either it's a genuine retail verb missing from this " +
|
||||
"test's registry, or it's an invented alias that must be deleted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue