fix(chat): CH4 review fixes — allegiance ownership guard, house-abandon confirmation
Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped TryMatchAllegiance (which only claimed "info"/"hometown") and fell through the unregistered-tag channel fallback, broadcasting the raw subcommand text to the Allegiance chat channel (0x02000000). Retail's own DoAllegiance never reaches DoChannelCommand for an unrecognized subcommand — it claims the whole verb and prints its own client-local refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership guard in TryDispatchChannelFallback as defense in depth. Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation. Retail runs a real two-stage dialog before Event_AbandonHouse(); ported both verbatim strings and chained two ShowConfirmation calls. Should-fixes: a bare unregistered tag with no text now passes through silently instead of showing a refusal that belongs to a different retail function; @join/@leave update RuntimeCharacterOptionsState locally (new SetOptionBit) before the wire push so the Turbine membership gate stops refusing a just-joined room; @permit accepts multi-word names; @clist/ @on/@off validate shape only and raise WeenieError 0x422 for an unknown tag; @mr/@pr help text is now the verbatim retail strings; corrected issue #360, register row TS-68, the campaign doc's B.7 note, and a stale RetailChannelTagTable comment; filed issue #363 + register row AP-183 for the deferred error-typing debt. Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's olthoi false-positive, added /g and /rp binding-level conformance pins, made @index ignore extra arguments, and noted the six removed invented verbs in ISSUES.md. Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's 12,190/4/0 — net +26 tests, no removals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
090825e703
commit
724ef2d389
17 changed files with 853 additions and 85 deletions
|
|
@ -26,7 +26,8 @@ What does NOT go here:
|
||||||
|
|
||||||
## #360 — @allegiance/@house management dispatchers only port their simple subcommands
|
## #360 — @allegiance/@house management dispatchers only port their simple subcommands
|
||||||
|
|
||||||
**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4. Retail's
|
**Status:** OPEN — filed 2026-08-09, Campaign CH slice CH4; corrected
|
||||||
|
2026-08-09 at the CH4 REJECT-review (Blocker 1). Retail's
|
||||||
`@allegiance`/`@all` and `@house`/`@hou` are 12- and 15-subcommand local
|
`@allegiance`/`@all` and `@house`/`@hou` are 12- and 15-subcommand local
|
||||||
command dispatchers (`ClientCommunicationSystem::DoAllegiance @
|
command dispatchers (`ClientCommunicationSystem::DoAllegiance @
|
||||||
0x0057D5A0` / `DoHouse @ 0x00580860`). CH4 ports the subset with simple
|
0x0057D5A0` / `DoHouse @ 0x00580860`). CH4 ports the subset with simple
|
||||||
|
|
@ -40,13 +41,23 @@ boot_all/remove_all/guest/available/hooks/on/off) plus the standalone
|
||||||
target-name/guid resolution, confirmation dialogs, or multi-field payloads
|
target-name/guid resolution, confirmation dialogs, or multi-field payloads
|
||||||
this session did not attempt to build without byte-level verification
|
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
|
against both the retail decomp and ACE's reader — see the doc's own
|
||||||
framing ("largest single item; deserves its own slice"). Today these
|
framing ("largest single item; deserves its own slice"). For `@house`,
|
||||||
subcommands correctly fall through to ACE as server-passthrough text
|
these subcommands correctly fall through to ACE as server-passthrough
|
||||||
(`RetailClientCommandCatalog.TryMatchHouse`/`TryMatchAllegiance`) rather
|
text (`RetailClientCommandCatalog.TryMatchHouse`) rather than being
|
||||||
than being swallowed locally, which was the Tier-1 correctness fix this
|
swallowed locally, which was the Tier-1 correctness fix CH4 landed — but
|
||||||
slice DID land — but they don't yet execute. Register row: TS-68.
|
they don't yet execute. **For `@allegiance`/`@all`, the original filing's
|
||||||
Registry doc: `docs/research/2026-08-09-chat-retail-command-registry.md`
|
"falls through to ACE" claim was wrong**: retail's own `DoAllegiance`
|
||||||
§2.5/§2.5b.
|
never reaches server passthrough for an unrecognized subcommand — it
|
||||||
|
prints "Please see @help Allegiance for more information on how to use
|
||||||
|
this command." locally and stays entirely client-side
|
||||||
|
(`ClientCommunicationSystem::DoAllegiance`, label at 0x0057DA4B). The
|
||||||
|
CH4 REJECT-review found acdream had instead been broadcasting the
|
||||||
|
unmatched subcommand text to the Allegiance chat channel — a real
|
||||||
|
chat-visible bug, now fixed (`TryMatchAllegiance` claims ownership
|
||||||
|
unconditionally and shows retail's own refusal text). The 22 subcommands
|
||||||
|
themselves still don't execute; only the fallback behavior changed.
|
||||||
|
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
|
## #361 — @day / @log / @render pure-local commands recognized in help only, not executed
|
||||||
|
|
||||||
|
|
@ -73,6 +84,42 @@ registered in `GameEventType` with no `GameEventWiring` handler — ACE's
|
||||||
reply is silently dropped. The request itself is correct and verifiable
|
reply is silently dropped. The request itself is correct and verifiable
|
||||||
on the wire; only the response rendering is missing. Register row: TS-70.
|
on the wire; only the response rendering is missing. Register row: TS-70.
|
||||||
|
|
||||||
|
## #363 — Chat refusal/usage call sites are typed ClientLocal 0x00 where retail types several 0x1A
|
||||||
|
|
||||||
|
**Status:** OPEN — filed 2026-08-09, CH4 REJECT-review, SHOULD-FIX 9.
|
||||||
|
`ChatVM.ShowSystemMessage`'s single-typed sink (`LogTextType 0x00`,
|
||||||
|
informational) is correct for most of `ClientCommandController`'s output,
|
||||||
|
but Campaign CH slice CH4 added roughly 10 new refusal/usage call sites
|
||||||
|
that retail types `0x1A` (bright red / ClientLocal), not `0x00`:
|
||||||
|
`DoStupidChannelHack` (the "You must specify the text you wish to say!"
|
||||||
|
family, registered channel verbs only), `DoChannelList`/`DoChannelOn`/
|
||||||
|
`DoChannelOff` ("Please specify the channel name."), `DoAllegiance` (the
|
||||||
|
"Please see @help Allegiance..." refusal this session's Blocker 1 fix
|
||||||
|
added), `DoHouseAvailableList`, and `DoReply` ("Someone must @tell you
|
||||||
|
first!"). Three CH4 sites are ALREADY correct because retail itself types
|
||||||
|
them informational `0x00`: `DoSpeaker`, `DoEndurance`, `DoTitle`.
|
||||||
|
Separately, retail's own bad-args fallback
|
||||||
|
(`ClientCommunicationSystem::DoCommand @0x0057E46D`) answers a registered
|
||||||
|
handler that returns 0 with `HandleFailureEvent(0x26)`, not a local
|
||||||
|
"Usage: <usage>" line — `ChatCommandRouter.Submit` shows a synthesized
|
||||||
|
`"Usage: {clientCommand.Usage}"` string instead whenever a catalog
|
||||||
|
command's `InvalidArgumentsText` is null. Register row: AP-183.
|
||||||
|
Deliberately NOT fixed this session — re-plumbing every call site to a
|
||||||
|
typed sink (and porting `HandleFailureEvent(0x26)`'s real text) is larger
|
||||||
|
than a REJECT-review fix batch; CH5-or-later.
|
||||||
|
|
||||||
|
## Note — six invented chat verbs removed for registry parity (2026-08-09)
|
||||||
|
|
||||||
|
Campaign CH slice CH4 deleted `/gen`, `/cv`, `/lookingforgroup`, `/tr`,
|
||||||
|
`/role`, `/h` from `ChatInputParser`/`ChatCommandRouter` — none are
|
||||||
|
retail-registered verbs; the retail command registry doc
|
||||||
|
(`docs/research/2026-08-09-chat-retail-command-registry.md` §4,
|
||||||
|
"candidates for removal") confirmed none exist in the real client. Not a
|
||||||
|
bug, no issue number — recorded here so they aren't reintroduced later as
|
||||||
|
"missing aliases." `RetailCommandRegistryConformanceTests`'s two
|
||||||
|
reverse-direction ownership tests now fail the build if any of the six
|
||||||
|
(or any other invented verb) resurfaces.
|
||||||
|
|
||||||
## #356 — Alt-tab during login crashed the client: focus loss faulted on an unpublished movement controller
|
## #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
|
**Status:** CLOSED 2026-08-08 — `972c7ab3`. Window focus loss runs
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -114,7 +114,7 @@ implementer per slice against a pinned contract (per
|
||||||
| CH1 colors | `172c6f9a` | 11,835 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `34d8a3c0` | pending |
|
| 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 |
|
| 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) |
|
| CH3 side channels | `614a1e05` | 11,964 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed `e07fba57` | pending (connected gate — see handoff below) |
|
||||||
| CH4 commands | see CH4 closeout below | 12,026 passed / 4 skipped / 0 failed | pending | pending |
|
| CH4 commands | `090825e7` | 12,190 passed / 4 skipped / 0 failed | REJECT; fixed `<sha>` | pending |
|
||||||
| CH5 closeout | — | — | — | — |
|
| CH5 closeout | — | — | — | — |
|
||||||
|
|
||||||
### CH4 closeout (2026-08-09)
|
### CH4 closeout (2026-08-09)
|
||||||
|
|
@ -152,6 +152,19 @@ Full parser-semantics + catalog-breadth pass against
|
||||||
7. `/allegiance`/`/all` are now `RetailClientCommandCatalog`'s allegiance
|
7. `/allegiance`/`/all` are now `RetailClientCommandCatalog`'s allegiance
|
||||||
MANAGEMENT command (a new `TryMatchAllegiance` dispatcher), not a
|
MANAGEMENT command (a new `TryMatchAllegiance` dispatcher), not a
|
||||||
channel verb. The channel-send verbs stay `a`/`ab`/`guild`/`gu`.
|
channel verb. The channel-send verbs stay `a`/`ab`/`guild`/`gu`.
|
||||||
|
**Corrected 2026-08-09 at the CH4 REJECT-review (Blocker 1): the
|
||||||
|
original implementation above only claimed ownership for the 2 ported
|
||||||
|
subcommands (`info`/`hometown`/`ho`) and let every OTHER subcommand
|
||||||
|
fall through the unregistered-tag channel-fallback path, which
|
||||||
|
broadcast the raw subcommand text to the Allegiance chat channel
|
||||||
|
(`0x02000000`) — a real chat-visible bug (`@allegiance boot Bob` sent
|
||||||
|
"boot Bob" to allegiance chat). Retail's own `DoAllegiance` claims the
|
||||||
|
ENTIRE verb unconditionally: an unrecognized subcommand prints "Please
|
||||||
|
see @help Allegiance for more information on how to use this command."
|
||||||
|
locally and never reaches `DoChannelCommand` or the server.
|
||||||
|
`TryMatchAllegiance` now matches this exactly — it always returns
|
||||||
|
ownership for `allegiance`/`all`, showing retail's refusal text for
|
||||||
|
any subcommand beyond the 2 ported ones.**
|
||||||
8. `/house`/`/hou` no longer swallows unrecognized subcommands with a
|
8. `/house`/`/hou` no longer swallows unrecognized subcommands with a
|
||||||
local usage error — `TryMatchHouse` returns no match for anything
|
local usage error — `TryMatchHouse` returns no match for anything
|
||||||
beyond `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`,
|
beyond `recall`/`re`/`mansion_recall`/`alleg_recall`/`ma`/`abandon`,
|
||||||
|
|
@ -196,14 +209,92 @@ KnownVerbs` or `ChatInputParser.KnownVerbs` may exist outside this
|
||||||
registry — a future invented alias fails the build immediately. Final
|
registry — a future invented alias fails the build immediately. Final
|
||||||
tally: **138 Implemented / 5 ServerPassthrough / 9 HelpOnly = 152.**
|
tally: **138 Implemented / 5 ServerPassthrough / 9 HelpOnly = 152.**
|
||||||
|
|
||||||
Suite: 12,026 passed / 4 skipped / 0 failed (Release), up from CH3's
|
Suite: 12,190 passed / 4 skipped / 0 failed (Release), up from CH3's
|
||||||
11,964/4/0 — net +62 tests (157 new conformance-family cases plus net
|
11,964/4/0 — net +226 tests. (Corrected 2026-08-09 at the CH4
|
||||||
test churn from updated existing coverage). One pre-existing,
|
REJECT-review, item 8: this paragraph originally read "12,026 ... net
|
||||||
|
+62 (157 new conformance-family cases plus net test churn)"; the actual
|
||||||
|
measured CH4-landing count was 12,190, matching CLAUDE.md's Current
|
||||||
|
Suite baseline — only the raw counts are corrected here, the +62/157
|
||||||
|
breakdown was not re-derived.) One pre-existing,
|
||||||
environment-specific Debug-only failure
|
environment-specific Debug-only failure
|
||||||
(`LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty`)
|
(`LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysicsEvenWhenEntityListIsAlreadyEmpty`)
|
||||||
was confirmed present on the unmodified baseline via `git stash` before
|
was confirmed present on the unmodified baseline via `git stash` before
|
||||||
and after this slice's changes — passes in Release, unrelated to chat.
|
and after this slice's changes — passes in Release, unrelated to chat.
|
||||||
|
|
||||||
|
### CH4 REJECT-review fixes (2026-08-09)
|
||||||
|
|
||||||
|
Two blockers, seven should-fixes, and six nits from the CH4 review landed:
|
||||||
|
|
||||||
|
**Blockers:** (1) `@allegiance <sub>` for an unrecognized subcommand was
|
||||||
|
broadcasting the raw subcommand text to the Allegiance chat channel
|
||||||
|
(`SendRawChannelCmd(0x02000000, ...)`) because `TryDispatchChannelFallback`
|
||||||
|
only guarded on `ChatInputParser.IsKnownVerb`, and `/allegiance` had been
|
||||||
|
deleted from that parser at CH4. Fixed at both ends: `TryMatchAllegiance`
|
||||||
|
now claims ownership of `allegiance`/`all` unconditionally (matching
|
||||||
|
retail's own `DoAllegiance`, which never falls through to
|
||||||
|
`DoChannelCommand`) and shows retail's own "Please see @help Allegiance
|
||||||
|
for more information on how to use this command." refusal client-side;
|
||||||
|
`TryDispatchChannelFallback` also gained a blanket
|
||||||
|
`RetailClientCommandCatalog.KnownVerbs` ownership guard as defense in
|
||||||
|
depth for the rest of the catalog. (2) `@house abandon` sent `0x021F`
|
||||||
|
immediately with zero confirmation; retail's `DoHouse` abandon branch runs
|
||||||
|
a real two-stage dialog ("Do you really want to abandon your house? ..."
|
||||||
|
then "Are you absolutely certain you wish to abandon your house? Click
|
||||||
|
yes only if you are sure!") before `Event_AbandonHouse()`.
|
||||||
|
`ClientCommandController`'s `HouseAbandon` case now chains two
|
||||||
|
`ShowConfirmation` calls with retail's verbatim text; `AbandonHouse` only
|
||||||
|
fires after both accepts.
|
||||||
|
|
||||||
|
**Should-fixes:** a bare unregistered tag with no text (`@admin`) now
|
||||||
|
passes through to the server silently, matching retail's `DoChannelCommand`
|
||||||
|
returning 0 on `argc<=0`, instead of showing "You must specify the text
|
||||||
|
you wish to say!" (that string belongs to the registered-verb-only
|
||||||
|
`DoStupidChannelHack`); `@join`/`@leave` now update
|
||||||
|
`RuntimeCharacterOptionsState` locally (a new `SetOptionBit` method)
|
||||||
|
before the wire push, so `TurbineChatMembershipGate` stops refusing a
|
||||||
|
just-joined room without waiting on a fresh `PlayerDescription`;
|
||||||
|
`@permit add/remove` now accepts a multi-word name (`>= 2` tokens,
|
||||||
|
joins the remainder, matching retail's `JoinArgsAsName`); `@clist`/`@on`/
|
||||||
|
`@off` now validate only argument SHAPE (exactly one token) at the
|
||||||
|
catalog layer and raise `WeenieError 0x422` ("That channel doesn't
|
||||||
|
exist.") for an unresolved tag, instead of silently doing nothing;
|
||||||
|
`@mr`/`@pr`'s help text is now the verbatim retail strings from
|
||||||
|
`data_7daa08`/`data_7daa80` (previously fabricated acdream summaries),
|
||||||
|
and the class doc no longer overclaims every table entry is verbatim
|
||||||
|
(the ~35 channel one-liners are acknowledged as acdream summaries);
|
||||||
|
issues #360 and register row TS-68 corrected — `@house`'s unported
|
||||||
|
subcommands still reach ACE, but `@allegiance`'s now correctly stay
|
||||||
|
client-side; the campaign doc's own B.7 note and `RetailChannelTagTable`'s
|
||||||
|
stale "IsKnownVerb intercepts them first" comment are corrected to
|
||||||
|
describe the catalog-ownership interception path; the ledger suite counts
|
||||||
|
above are corrected from a stale 12,026 to the actual 12,190. The
|
||||||
|
error-typing debt (~10 new refusal sites at `ClientLocal 0x00` where
|
||||||
|
retail types several `0x1A`, plus `DoCommand`'s real `HandleFailureEvent
|
||||||
|
(0x26)` bad-args response) was deliberately NOT re-plumbed — filed as
|
||||||
|
issue #363 and register row AP-183, CH5-or-later.
|
||||||
|
|
||||||
|
**Nits:** `TryMatchHouse`'s doc comment no longer describes a
|
||||||
|
local-swallow path that doesn't exist in the code; AP-182 and the
|
||||||
|
`SetChatTitle`/`@title` comments across three files no longer claim the
|
||||||
|
value is "stored" (the binding is `_ => { }`, a pure no-op) and AP-182
|
||||||
|
now lists `DoTitle`'s three omitted failure messages; `RetailChannelTagTable
|
||||||
|
.IsUnregisteredFallbackTag` now excludes by TAG STRING instead of channel
|
||||||
|
ID, fixing a false-positive on `"olthoi"` (which shares an id with the
|
||||||
|
genuinely-unregistered `"ol"` but has its own registered Turbine verb);
|
||||||
|
two binding-level conformance pins (`/g`→Fellowship `0x800`, `/rp`→reply)
|
||||||
|
were added to `RetailCommandRegistryConformanceTests` so a rebind
|
||||||
|
regression fails there, not just a narrower parser test; `@index foo` is
|
||||||
|
now accepted (retail's `DoChannelIndex` ignores argc); an ISSUES.md note
|
||||||
|
records the six invented verbs (`gen`/`cv`/`lookingforgroup`/`tr`/`role`/`h`)
|
||||||
|
removed at CH4 for registry parity.
|
||||||
|
|
||||||
|
Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's
|
||||||
|
12,190/4/0 — net +26 tests (new/expanded theory cases across
|
||||||
|
`ChatCommandRouterTests`, `RetailClientCommandCatalogTests`,
|
||||||
|
`RetailCommandRegistryConformanceTests`, `ClientCommandControllerTests`,
|
||||||
|
`RuntimeCharacterStateTests`, and `TurbineChatMembershipGateTests`; no
|
||||||
|
tests removed, several renamed/retargeted in place).
|
||||||
|
|
||||||
### CH3 closeout handoff (2026-08-09)
|
### CH3 closeout handoff (2026-08-09)
|
||||||
|
|
||||||
All nine steps of the research doc's §6 fix list landed:
|
All nine steps of the research doc's §6 fix list landed:
|
||||||
|
|
|
||||||
|
|
@ -435,7 +435,18 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
// silent accept is exactly as faithful as a stored-but-unread
|
// silent accept is exactly as faithful as a stored-but-unread
|
||||||
// value would be, without inventing a consumer.
|
// value would be, without inventing a consumer.
|
||||||
SetChatTitle: _ => { },
|
SetChatTitle: _ => { },
|
||||||
SetSingleCharacterOption: session.SendSetSingleCharacterOption,
|
// CH4 REJECT-review SHOULD-FIX 4 (2026-08-09): only @join/@leave
|
||||||
|
// reach this binding (see ClientCommandController.Execute).
|
||||||
|
// Retail's PlayerModule::SetHear*Chat family writes the bit into
|
||||||
|
// the local options copy FIRST, then notifies the server — match
|
||||||
|
// that ordering so TurbineChatMembershipGate (which reads
|
||||||
|
// _domain.Character.Options) stops refusing the newly-joined
|
||||||
|
// room before the next PlayerDescription happens to arrive.
|
||||||
|
SetSingleCharacterOption: (optionId, value) =>
|
||||||
|
{
|
||||||
|
_domain.Character.Options.SetOptionBit(optionId, value);
|
||||||
|
session.SendSetSingleCharacterOption(optionId, value);
|
||||||
|
},
|
||||||
AddPlayerPermission: session.SendAddPlayerPermission,
|
AddPlayerPermission: session.SendAddPlayerPermission,
|
||||||
RemovePlayerPermission: session.SendRemovePlayerPermission,
|
RemovePlayerPermission: session.SendRemovePlayerPermission,
|
||||||
RequestAvailableHouses: session.SendListAvailableHouses,
|
RequestAvailableHouses: session.SendListAvailableHouses,
|
||||||
|
|
|
||||||
|
|
@ -256,9 +256,13 @@ public sealed class ClientCommandController
|
||||||
"This command is no longer in use, please see @allegiance officer.");
|
"This command is no longer in use, please see @allegiance officer.");
|
||||||
break;
|
break;
|
||||||
// ClientCommunicationSystem::DoTitle @ 0x0057A640. No local
|
// ClientCommunicationSystem::DoTitle @ 0x0057A640. No local
|
||||||
// chat-window title chrome exists yet (AP-182) — the value is
|
// chat-window title chrome exists yet (AP-182) — the binding is
|
||||||
// stored for a future consumer, matching retail's silent
|
// a pure no-op, matching retail's silent success (no
|
||||||
// success (no confirmation text was found at the success site).
|
// confirmation text was found at the success site). Corrected
|
||||||
|
// 2026-08-09 at the CH4 REJECT-review nit 11: the earlier
|
||||||
|
// wording here claimed the value "is stored for a future
|
||||||
|
// consumer", which was false — see LiveSessionRuntimeFactory's
|
||||||
|
// SetChatTitle binding (`_ => { }`).
|
||||||
case ClientCommandId.SetChatTitle:
|
case ClientCommandId.SetChatTitle:
|
||||||
_bindings.SetChatTitle(command.Arguments.Trim());
|
_bindings.SetChatTitle(command.Arguments.Trim());
|
||||||
break;
|
break;
|
||||||
|
|
@ -300,20 +304,32 @@ public sealed class ClientCommandController
|
||||||
case ClientCommandId.IndexChannels:
|
case ClientCommandId.IndexChannels:
|
||||||
_bindings.RequestChannelIndex();
|
_bindings.RequestChannelIndex();
|
||||||
break;
|
break;
|
||||||
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0.
|
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. CH4
|
||||||
|
// REJECT-review SHOULD-FIX 6 (2026-08-09): the catalog only
|
||||||
|
// validated argument SHAPE (exactly one token); an unresolved
|
||||||
|
// tag reaches here and raises retail's own
|
||||||
|
// HandleFailureEvent(0x422) ("That channel doesn't exist.").
|
||||||
case ClientCommandId.ListChannel:
|
case ClientCommandId.ListChannel:
|
||||||
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint listChannelId))
|
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint listChannelId))
|
||||||
_bindings.RequestChannelList(listChannelId);
|
_bindings.RequestChannelList(listChannelId);
|
||||||
|
else
|
||||||
|
_bindings.ShowWeenieError(0x0422u);
|
||||||
break;
|
break;
|
||||||
// ClientCommunicationSystem::DoChannelOn @ 0x0057AA80.
|
// ClientCommunicationSystem::DoChannelOn @ 0x0057AA80. Same
|
||||||
|
// 0x422 shape as ListChannel above.
|
||||||
case ClientCommandId.OnChannel:
|
case ClientCommandId.OnChannel:
|
||||||
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint onChannelId))
|
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint onChannelId))
|
||||||
_bindings.JoinGmChannel(onChannelId);
|
_bindings.JoinGmChannel(onChannelId);
|
||||||
|
else
|
||||||
|
_bindings.ShowWeenieError(0x0422u);
|
||||||
break;
|
break;
|
||||||
// ClientCommunicationSystem::DoChannelOff @ 0x0057AB50.
|
// ClientCommunicationSystem::DoChannelOff @ 0x0057AB50. Same
|
||||||
|
// 0x422 shape as ListChannel above.
|
||||||
case ClientCommandId.OffChannel:
|
case ClientCommandId.OffChannel:
|
||||||
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint offChannelId))
|
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint offChannelId))
|
||||||
_bindings.LeaveGmChannel(offChannelId);
|
_bindings.LeaveGmChannel(offChannelId);
|
||||||
|
else
|
||||||
|
_bindings.ShowWeenieError(0x0422u);
|
||||||
break;
|
break;
|
||||||
// GameActionRecallAllegianceHometown — @alh/@ah/"@allegiance hometown".
|
// GameActionRecallAllegianceHometown — @alh/@ah/"@allegiance hometown".
|
||||||
case ClientCommandId.AllegianceHometown:
|
case ClientCommandId.AllegianceHometown:
|
||||||
|
|
@ -323,9 +339,34 @@ public sealed class ClientCommandController
|
||||||
case ClientCommandId.AllegianceInfo:
|
case ClientCommandId.AllegianceInfo:
|
||||||
_bindings.RequestAllegianceInfo(command.Arguments.Trim());
|
_bindings.RequestAllegianceInfo(command.Arguments.Trim());
|
||||||
break;
|
break;
|
||||||
// GameActionHouseAbandon — "@house abandon".
|
// GameActionHouseAbandon — "@house abandon". Retail's abandon
|
||||||
|
// branch (DoHouse @ 0x00580D58) opens a FIRST confirmation
|
||||||
|
// dialog (DialogFactory::MakeCallbackDialogInCurrentUI →
|
||||||
|
// HouseAbandonDialogCallback_First @0x00580E1A); only on
|
||||||
|
// accept does that callback open a SECOND dialog
|
||||||
|
// (HouseAbandonDialogCallback_Second @0x0057BE90), and only
|
||||||
|
// THAT callback's accept calls Event_AbandonHouse()
|
||||||
|
// (0x0057BF01 — the ONLY call site). Both strings recovered
|
||||||
|
// verbatim from acclient_2013_pseudo_c.txt (data_7e1460 /
|
||||||
|
// data_7e1370). CH4 REJECT-review Blocker 2 (2026-08-09):
|
||||||
|
// acdream previously sent 0x021F immediately with NO
|
||||||
|
// confirmation at all.
|
||||||
case ClientCommandId.HouseAbandon:
|
case ClientCommandId.HouseAbandon:
|
||||||
_bindings.AbandonHouse();
|
_bindings.ShowConfirmation(
|
||||||
|
"Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
||||||
|
firstAccepted =>
|
||||||
|
{
|
||||||
|
if (!firstAccepted)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_bindings.ShowConfirmation(
|
||||||
|
"Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!",
|
||||||
|
secondAccepted =>
|
||||||
|
{
|
||||||
|
if (secondAccepted)
|
||||||
|
_bindings.AbandonHouse();
|
||||||
|
});
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -645,12 +686,15 @@ public sealed class ClientCommandController
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Argument shape
|
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Argument shape
|
||||||
// already validated by RetailClientCommandCatalog (exactly "add <name>"
|
// already validated by RetailClientCommandCatalog (a mode word plus at
|
||||||
// or "remove <name>").
|
// least one more token). CH4 REJECT-review SHOULD-FIX 5 (2026-08-09):
|
||||||
|
// retail's DoPermit joins every token after the mode word into the
|
||||||
|
// name (JoinArgsAsName), so a multi-word character name — "@permit add
|
||||||
|
// Aunt Agatha" — must resolve to "Aunt Agatha", not just "Aunt".
|
||||||
private void ExecutePermit(string arguments)
|
private void ExecutePermit(string arguments)
|
||||||
{
|
{
|
||||||
string[] parts = SplitArguments(arguments);
|
string[] parts = SplitArguments(arguments);
|
||||||
string name = parts[1];
|
string name = string.Join(' ', parts, 1, parts.Length - 1);
|
||||||
if (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase))
|
if (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase))
|
||||||
_bindings.AddPlayerPermission(name);
|
_bindings.AddPlayerPermission(name);
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -651,6 +651,58 @@ public sealed class RuntimeCharacterOptionsState
|
||||||
Interlocked.Increment(ref _revision);
|
Interlocked.Increment(ref _revision);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set ONE character-option bit locally, by its linear
|
||||||
|
/// <c>CharacterOptionId</c> (the same id carried on the wire by
|
||||||
|
/// <c>SetSingleCharacterOption (0x0005)</c>). Retail's
|
||||||
|
/// <c>PlayerModule::SetHearGeneralChat @0x005D35C0</c> (and its five
|
||||||
|
/// <c>SetHear*Chat</c> siblings) write the bit into this LOCAL copy
|
||||||
|
/// FIRST, before the client ever notifies the server. CH4
|
||||||
|
/// REJECT-review SHOULD-FIX 4 (2026-08-09): acdream's <c>@join</c>/
|
||||||
|
/// <c>@leave</c> previously pushed only the wire message and left this
|
||||||
|
/// state untouched, so <see cref="AcDream.Runtime.Gameplay.TurbineChatMembershipGate"/>
|
||||||
|
/// kept refusing a room the player had just joined until the next
|
||||||
|
/// <c>PlayerDescription</c> happened to arrive. Only the six
|
||||||
|
/// <c>ListenTo*Chat</c> ids <c>CharacterOptionId</c> models are
|
||||||
|
/// recognized here; any other id is a silent no-op — this state only
|
||||||
|
/// tracks what the Turbine-chat membership gate needs, not a complete
|
||||||
|
/// <c>PlayerModule</c> mirror.
|
||||||
|
/// </summary>
|
||||||
|
public void SetOptionBit(uint characterOptionId, bool value)
|
||||||
|
{
|
||||||
|
(bool isOptions1, uint mask) = characterOptionId switch
|
||||||
|
{
|
||||||
|
(uint)CharacterOptionId.ListenToAllegianceChat =>
|
||||||
|
(true, (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat),
|
||||||
|
(uint)CharacterOptionId.ListenToGeneralChat =>
|
||||||
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearGeneralChat),
|
||||||
|
(uint)CharacterOptionId.ListenToTradeChat =>
|
||||||
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearTradeChat),
|
||||||
|
(uint)CharacterOptionId.ListenToLFGChat =>
|
||||||
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearLFGChat),
|
||||||
|
(uint)CharacterOptionId.ListenToRoleplayChat =>
|
||||||
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat),
|
||||||
|
(uint)CharacterOptionId.ListenToSocietyChat =>
|
||||||
|
(false, (uint)PlayerDescriptionParser.CharacterOptions2.HearSocietyChat),
|
||||||
|
_ => (false, 0u),
|
||||||
|
};
|
||||||
|
if (mask == 0u)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (isOptions1)
|
||||||
|
{
|
||||||
|
uint updated = value ? (Options1 | mask) : (Options1 & ~mask);
|
||||||
|
Volatile.Write(ref _options1, updated);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
uint updated = value ? (Options2 | mask) : (Options2 & ~mask);
|
||||||
|
Volatile.Write(ref _options2, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
Interlocked.Increment(ref _revision);
|
||||||
|
}
|
||||||
|
|
||||||
public void ResetSession()
|
public void ResetSession()
|
||||||
{
|
{
|
||||||
Volatile.Write(ref _options1, DefaultOptions1);
|
Volatile.Write(ref _options1, DefaultOptions1);
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,14 @@ public enum ClientCommandId
|
||||||
Endurance,
|
Endurance,
|
||||||
/// <summary>@speaker — fixed deprecation notice ("see @allegiance officer").</summary>
|
/// <summary>@speaker — fixed deprecation notice ("see @allegiance officer").</summary>
|
||||||
Speaker,
|
Speaker,
|
||||||
/// <summary>@title <text> — sets the popup chat window's title (local state only; no title-bar chrome yet, AP-182).</summary>
|
/// <summary>
|
||||||
|
/// @title <text> — retail sets the popup chat window's title;
|
||||||
|
/// acdream's binding is a pure no-op (the value is neither stored nor
|
||||||
|
/// consumed — no title-bar chrome exists yet, AP-182, corrected
|
||||||
|
/// 2026-08-09 at the CH4 REJECT-review nit 11, which found this
|
||||||
|
/// summary's earlier "local state only" wording implied storage that
|
||||||
|
/// does not happen).
|
||||||
|
/// </summary>
|
||||||
SetChatTitle,
|
SetChatTitle,
|
||||||
/// <summary>@chat on|off — global Speech squelch toggle (message type 2).</summary>
|
/// <summary>@chat on|off — global Speech squelch toggle (message type 2).</summary>
|
||||||
ChatToggle,
|
ChatToggle,
|
||||||
|
|
@ -75,4 +82,14 @@ public enum ClientCommandId
|
||||||
AllegianceInfo,
|
AllegianceInfo,
|
||||||
/// <summary>"@house abandon" — abandon the character's house.</summary>
|
/// <summary>"@house abandon" — abandon the character's house.</summary>
|
||||||
HouseAbandon,
|
HouseAbandon,
|
||||||
|
/// <summary>
|
||||||
|
/// CH4 REJECT-review Blocker 1 (2026-08-09): "@allegiance"/"@all" with
|
||||||
|
/// any subcommand beyond the 2 ported ones (info, hometown/ho). Never
|
||||||
|
/// dispatched — <see cref="AcDream.UI.Abstractions.Panels.Chat.
|
||||||
|
/// RetailClientCommandCatalog.Match.HasValidArguments"/> is always
|
||||||
|
/// false for this id, so <c>ChatCommandRouter</c> shows retail's own
|
||||||
|
/// "Please see @help Allegiance..." refusal and never publishes an
|
||||||
|
/// <c>ExecuteClientCommandCmd</c>.
|
||||||
|
/// </summary>
|
||||||
|
AllegianceUnrecognizedSubcommand,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
|
||||||
|
|
@ -118,11 +119,28 @@ public static class ChatCommandRouter
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
string verb = ChatInputParser.GetVerbToken(trimmed);
|
string verb = ChatInputParser.GetVerbToken(trimmed);
|
||||||
string normalizedChatVerb = "/" + verb[1..].TrimEnd(',');
|
string tag = verb[1..].TrimEnd(',');
|
||||||
|
|
||||||
|
// CH4 REJECT-review Blocker 1 (2026-08-09): apply the catalog
|
||||||
|
// ownership rule BEFORE any channel-tag resolution. Retail's
|
||||||
|
// registered-command hash table is checked FIRST (doc §1) and
|
||||||
|
// unconditionally wins over DoChannelCommand's fallback — a
|
||||||
|
// catalog-owned verb must never resolve as a channel broadcast
|
||||||
|
// here, even if its own TryMatch declines ownership for some
|
||||||
|
// OTHER reason than "not owned" (RetailClientCommandCatalog's
|
||||||
|
// "allegiance"/"all" already claim ownership unconditionally via
|
||||||
|
// TryMatchAllegiance, so this line is currently redundant for
|
||||||
|
// that specific verb — it's the blanket guard for the rest of the
|
||||||
|
// catalog, e.g. "house", "lifestone", …). Skipping this check is
|
||||||
|
// exactly how an unmatched "@allegiance boot Bob" used to reach
|
||||||
|
// this method and broadcast "boot Bob" to the Allegiance channel.
|
||||||
|
if (RetailClientCommandCatalog.KnownVerbs.Contains(tag, StringComparer.OrdinalIgnoreCase))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string normalizedChatVerb = "/" + tag;
|
||||||
if (ChatInputParser.IsKnownVerb(normalizedChatVerb))
|
if (ChatInputParser.IsKnownVerb(normalizedChatVerb))
|
||||||
return null; // registered verb — handled by the normal channel path.
|
return null; // registered verb — handled by the normal channel path.
|
||||||
|
|
||||||
string tag = normalizedChatVerb[1..];
|
|
||||||
if (!RetailChannelTagTable.TryResolve(tag, out uint channelId))
|
if (!RetailChannelTagTable.TryResolve(tag, out uint channelId))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|
@ -130,10 +148,17 @@ public static class ChatCommandRouter
|
||||||
string text = separator < 0 ? string.Empty : trimmed[(separator + 1)..].Trim();
|
string text = separator < 0 ? string.Empty : trimmed[(separator + 1)..].Trim();
|
||||||
if (text.Length == 0)
|
if (text.Length == 0)
|
||||||
{
|
{
|
||||||
// Retail's DoChannelCommand: "You must specify the text you
|
// CH4 REJECT-review SHOULD-FIX 3 (2026-08-09): retail's
|
||||||
// wish to say." — a real local error, not a passthrough.
|
// DoChannelCommand @0x005774A7 returns 0 SILENTLY when argc<=0
|
||||||
vm.ShowSystemMessage("You must specify the text you wish to say!");
|
// for an UNREGISTERED tag; DoCommand's own final fallback then
|
||||||
return SubmitOutcome.ClientHandled;
|
// sends the raw @-line to the server via Event_Talk — this is
|
||||||
|
// passthrough, not a local refusal. "You must specify the text
|
||||||
|
// you wish to say!" belongs to DoStupidChannelHack
|
||||||
|
// @0x0057B144, which only runs for the REGISTERED channel
|
||||||
|
// verbs (fellowship, vassals, patron, monarch, covassals,
|
||||||
|
// allegiance-broadcast), never these 22 GM/faction tags. Let
|
||||||
|
// TryBuildServerCommand's passthrough handle it instead.
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bus.Publish(new SendRawChannelCmd(channelId, text));
|
bus.Publish(new SendRawChannelCmd(channelId, text));
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,22 @@ public static class RetailChannelTagTable
|
||||||
["olthoi"] = 0x40000000u,
|
["olthoi"] = 0x40000000u,
|
||||||
|
|
||||||
// Registered-verb tags (already reachable via ChatInputParser's
|
// Registered-verb tags (already reachable via ChatInputParser's
|
||||||
// normal channel-verb path). Present here ONLY so @clist/@on/@off
|
// normal channel-verb path — OR, for "allegiance"/"all", via
|
||||||
// accept the same tag spellings retail's GetChannelID resolves —
|
// RetailClientCommandCatalog's unconditional ownership of that
|
||||||
// ChatCommandRouter's A.4 fallback never reaches these entries
|
// verb, see TryMatchAllegiance). Present here ONLY so
|
||||||
// because IsKnownVerb intercepts them first.
|
// @clist/@on/@off accept the same tag spellings retail's
|
||||||
|
// GetChannelID resolves — ChatCommandRouter's A.4 fallback never
|
||||||
|
// reaches these entries: "a"/"ab"/"fellowship"/"vassals"/
|
||||||
|
// "patron"/"monarch"/"co-vassals"/etc. are intercepted by
|
||||||
|
// ChatInputParser.IsKnownVerb; "allegiance" is intercepted
|
||||||
|
// EARLIER still, by RetailClientCommandCatalog.TryMatch claiming
|
||||||
|
// the verb before A.4 dispatch is ever attempted (CH4
|
||||||
|
// REJECT-review Blocker 1, 2026-08-09) — the previous wording
|
||||||
|
// here ("IsKnownVerb intercepts them first", unqualified) was
|
||||||
|
// false for "allegiance" specifically, and that gap is exactly
|
||||||
|
// how the broadcast-to-channel bug happened: an unmatched
|
||||||
|
// subcommand fell through past both catalog and IsKnownVerb,
|
||||||
|
// all the way to this table's own "allegiance" entry.
|
||||||
["fellowship"] = 0x00000800u,
|
["fellowship"] = 0x00000800u,
|
||||||
["fellow"] = 0x00000800u,
|
["fellow"] = 0x00000800u,
|
||||||
["fellows"] = 0x00000800u,
|
["fellows"] = 0x00000800u,
|
||||||
|
|
@ -104,12 +116,47 @@ public static class RetailChannelTagTable
|
||||||
ByTag.TryGetValue(tag, out channelId);
|
ByTag.TryGetValue(tag, out channelId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True only for the 22 tags that have NO registered send verb — the
|
/// Tag strings that have a genuine send verb elsewhere — either
|
||||||
|
/// <see cref="ChatInputParser"/>'s channel-verb table, or, for
|
||||||
|
/// "allegiance"/"all", <see cref="RetailClientCommandCatalog"/>'s
|
||||||
|
/// unconditional ownership of that verb — even though they also
|
||||||
|
/// resolve in <see cref="ByTag"/> above. "olthoi" is the deliberate
|
||||||
|
/// odd one out: retail's <c>GetChannelID</c> fallback resolves it
|
||||||
|
/// PRE-Turbine (registry doc §2.3, "ol (and olthoi pre-Turbine)"), but
|
||||||
|
/// once <c>StartupTurbineChatSystem</c> runs — acdream's assumed
|
||||||
|
/// steady state, see <c>TurbineChatState</c> — "olthoi"/"o" become
|
||||||
|
/// registered Turbine verbs (<c>DoTurbineChat_Olthoi</c>, §2.4) and
|
||||||
|
/// <see cref="ChatInputParser"/> owns them instead. "ol" itself is
|
||||||
|
/// never added by <c>StartupTurbineChatSystem</c>, so it remains a
|
||||||
|
/// genuine fallback-only tag in every state.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly FrozenSet<string> RegisteredVerbTags =
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"fellowship", "fellow", "fellows", "f", "group", "g", "party",
|
||||||
|
"vassals", "vassal", "v",
|
||||||
|
"patron", "p",
|
||||||
|
"monarch", "m",
|
||||||
|
"covassals", "covassal", "co-vassals", "c",
|
||||||
|
"a", "ab", "allegiance",
|
||||||
|
"olthoi",
|
||||||
|
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True only for tags that have NO registered send verb anywhere — the
|
||||||
/// actual reachable set of <see cref="ChatCommandRouter"/>'s A.4
|
/// actual reachable set of <see cref="ChatCommandRouter"/>'s A.4
|
||||||
/// fallback dispatch. Used by the conformance test to enumerate exactly
|
/// fallback dispatch. Used by the conformance test to enumerate exactly
|
||||||
/// the registry doc's §2.3 fallback list.
|
/// the registry doc's §2.3 fallback list.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// CH4 REJECT-review nit 12 (2026-08-09): previously excluded by
|
||||||
|
/// channel-ID membership, which wrongly reported "olthoi" as
|
||||||
|
/// unregistered — it shares id <c>0x40000000</c> with the genuinely-
|
||||||
|
/// unregistered "ol", but "olthoi" (unlike "ol") has its own
|
||||||
|
/// registered Turbine verb. Excluding by TAG STRING instead
|
||||||
|
/// (<see cref="RegisteredVerbTags"/>) fixes "olthoi" without changing
|
||||||
|
/// "ol"'s (correct, unregistered) answer.
|
||||||
|
/// </remarks>
|
||||||
public static bool IsUnregisteredFallbackTag(string tag) =>
|
public static bool IsUnregisteredFallbackTag(string tag) =>
|
||||||
ByTag.TryGetValue(tag, out uint id) && id != 0x00000800u && id != 0x00001000u
|
ByTag.ContainsKey(tag) && !RegisteredVerbTags.Contains(tag);
|
||||||
&& id != 0x00002000u && id != 0x00004000u && id != 0x01000000u && id != 0x02000000u;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -238,8 +238,11 @@ public static class RetailClientCommandCatalog
|
||||||
// ClientCommunicationSystem::DoTitle @ 0x0057A640. Exact retail help:
|
// ClientCommunicationSystem::DoTitle @ 0x0057A640. Exact retail help:
|
||||||
// acclient_2013_pseudo_c.txt:1031162 (data_7df2c4) — "@title <new
|
// acclient_2013_pseudo_c.txt:1031162 (data_7df2c4) — "@title <new
|
||||||
// title> - Sets the title of the popup chat window.\n". No confirmation
|
// 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
|
// text was found at the success site; acdream's binding is a pure
|
||||||
// no title-bar chrome to render it yet (AP-182).
|
// no-op (the value is neither stored nor consumed — no title-bar
|
||||||
|
// chrome exists to render it yet, AP-182; corrected 2026-08-09 at the
|
||||||
|
// CH4 REJECT-review nit 11, which found the earlier "acdream stores
|
||||||
|
// the title" wording false).
|
||||||
private static readonly Definition SetTitle = AnyArguments(
|
private static readonly Definition SetTitle = AnyArguments(
|
||||||
ClientCommandId.SetChatTitle,
|
ClientCommandId.SetChatTitle,
|
||||||
"/title <new title>",
|
"/title <new title>",
|
||||||
|
|
@ -305,7 +308,14 @@ public static class RetailClientCommandCatalog
|
||||||
ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim()));
|
ValidateArguments: static arguments => JoinLeaveTags.ContainsKey(arguments.Trim()));
|
||||||
|
|
||||||
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Exact retail help:
|
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Exact retail help:
|
||||||
// acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8).
|
// acclient_2013_pseudo_c.txt:1030850-1030852 (data_7dbac8). CH4
|
||||||
|
// REJECT-review SHOULD-FIX 5 (2026-08-09): DoPermit joins every token
|
||||||
|
// after "add"/"remove" into the name (JoinArgsAsName) so a multi-word
|
||||||
|
// character name works — "@permit add Aunt Agatha" grants Aunt Agatha,
|
||||||
|
// not just "Aunt". The old exactly-2-tokens gate rejected that input
|
||||||
|
// outright; the shape check now only requires a mode word plus AT
|
||||||
|
// LEAST one more token, and ExecutePermit
|
||||||
|
// (ClientCommandController.cs) joins the remainder.
|
||||||
private static readonly Definition Permit = new(
|
private static readonly Definition Permit = new(
|
||||||
ClientCommandId.Permit,
|
ClientCommandId.Permit,
|
||||||
Usage: "/permit <add|remove> <name>",
|
Usage: "/permit <add|remove> <name>",
|
||||||
|
|
@ -313,7 +323,7 @@ public static class RetailClientCommandCatalog
|
||||||
ValidateArguments: static arguments =>
|
ValidateArguments: static arguments =>
|
||||||
{
|
{
|
||||||
string[] parts = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
string[] parts = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||||
return parts.Length == 2
|
return parts.Length >= 2
|
||||||
&& (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase)
|
&& (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase)
|
||||||
|| parts[0].Equals("remove", StringComparison.OrdinalIgnoreCase));
|
|| parts[0].Equals("remove", StringComparison.OrdinalIgnoreCase));
|
||||||
});
|
});
|
||||||
|
|
@ -344,36 +354,51 @@ public static class RetailClientCommandCatalog
|
||||||
|
|
||||||
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help
|
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help
|
||||||
// string was extracted for the bare form; the verb is admin/advocate/
|
// string was extracted for the bare form; the verb is admin/advocate/
|
||||||
// PSR gated server-side (GameActionChannelIndex.Handle).
|
// PSR gated server-side (GameActionChannelIndex.Handle). CH4
|
||||||
private static readonly Definition IndexChannels = NoArguments(
|
// REJECT-review nit 14 (2026-08-09): DoChannelIndex ignores its argc —
|
||||||
|
// "@index foo" sends the SAME Event_ChannelIndex() as bare "@index" —
|
||||||
|
// so acdream must accept (and discard) any arguments too, not just none.
|
||||||
|
private static readonly Definition IndexChannels = AnyArguments(
|
||||||
ClientCommandId.IndexChannels,
|
ClientCommandId.IndexChannels,
|
||||||
"/index",
|
"/index",
|
||||||
"@index - Requests the channel index (restricted).");
|
"@index - Requests the channel index (restricted).");
|
||||||
|
|
||||||
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. Exact retail
|
// ClientCommunicationSystem::DoChannelList @ 0x0057A9B0. Exact retail
|
||||||
// no-arg text: acclient_2013_pseudo_c.txt:1031202 (data_7dfaf8)
|
// no-arg text: acclient_2013_pseudo_c.txt:1031202 (data_7dfaf8)
|
||||||
// "Please specify the channel name."
|
// "Please specify the channel name." CH4 REJECT-review SHOULD-FIX 6
|
||||||
|
// (2026-08-09): retail's own argc check is "!= 1" — a resolved-but-
|
||||||
|
// UNKNOWN tag still reaches the handler and raises
|
||||||
|
// HandleFailureEvent(0x422) ("That channel doesn't exist.",
|
||||||
|
// WeenieErrorMessages[0x422]); only a MISSING or MULTI-WORD argument
|
||||||
|
// prints this usage line locally. Using tag resolution itself as the
|
||||||
|
// argument-shape gate (the old behavior) silently swallowed an unknown
|
||||||
|
// tag instead of raising 0x422 — see ClientCommandController's
|
||||||
|
// dispatch (ListChannel/OnChannel/OffChannel cases) for the
|
||||||
|
// ShowWeenieError(0x422) call this shape-only gate now allows through.
|
||||||
private static readonly Definition ListChannel = new(
|
private static readonly Definition ListChannel = new(
|
||||||
ClientCommandId.ListChannel,
|
ClientCommandId.ListChannel,
|
||||||
Usage: "/clist <channel>",
|
Usage: "/clist <channel>",
|
||||||
HelpText: "@clist <channel> - Requests the member list of a channel (restricted).",
|
HelpText: "@clist <channel> - Requests the member list of a channel (restricted).",
|
||||||
ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
|
ValidateArguments: static arguments => IsSingleToken(arguments),
|
||||||
InvalidArgumentsText: "Please specify the channel name.");
|
InvalidArgumentsText: "Please specify the channel name.");
|
||||||
|
|
||||||
private static readonly Definition OnChannel = new(
|
private static readonly Definition OnChannel = new(
|
||||||
ClientCommandId.OnChannel,
|
ClientCommandId.OnChannel,
|
||||||
Usage: "/on <channel>",
|
Usage: "/on <channel>",
|
||||||
HelpText: "@on <channel> - Joins a channel (restricted).",
|
HelpText: "@on <channel> - Joins a channel (restricted).",
|
||||||
ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
|
ValidateArguments: static arguments => IsSingleToken(arguments),
|
||||||
InvalidArgumentsText: "Please specify the channel name.");
|
InvalidArgumentsText: "Please specify the channel name.");
|
||||||
|
|
||||||
private static readonly Definition OffChannel = new(
|
private static readonly Definition OffChannel = new(
|
||||||
ClientCommandId.OffChannel,
|
ClientCommandId.OffChannel,
|
||||||
Usage: "/off <channel>",
|
Usage: "/off <channel>",
|
||||||
HelpText: "@off <channel> - Leaves a channel (restricted).",
|
HelpText: "@off <channel> - Leaves a channel (restricted).",
|
||||||
ValidateArguments: static arguments => RetailChannelTagTable.TryResolve(arguments.Trim(), out _),
|
ValidateArguments: static arguments => IsSingleToken(arguments),
|
||||||
InvalidArgumentsText: "Please specify the channel name.");
|
InvalidArgumentsText: "Please specify the channel name.");
|
||||||
|
|
||||||
|
private static bool IsSingleToken(string arguments) =>
|
||||||
|
arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length == 1;
|
||||||
|
|
||||||
// GameActionRecallAllegianceHometown.Handle. Exact retail help:
|
// GameActionRecallAllegianceHometown.Handle. Exact retail help:
|
||||||
// acclient_2013_pseudo_c.txt:1031230 — "@allegiance hometown -
|
// acclient_2013_pseudo_c.txt:1031230 — "@allegiance hometown -
|
||||||
// Recalls you to your allegiance bindstone, if your allegiance has
|
// Recalls you to your allegiance bindstone, if your allegiance has
|
||||||
|
|
@ -392,6 +417,30 @@ public static class RetailClientCommandCatalog
|
||||||
"/allegiance info [name]",
|
"/allegiance info [name]",
|
||||||
"@allegiance info <name> - Requests information on a member of your allegiance.");
|
"@allegiance info <name> - Requests information on a member of your allegiance.");
|
||||||
|
|
||||||
|
// ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0. Exact retail
|
||||||
|
// text: acclient_2013_pseudo_c.txt:1031375 (data_7e0bd0) — "Please see
|
||||||
|
// @help Allegiance for more information on how to use this command.".
|
||||||
|
// Printed at label_57da4b (0x0057DA4B) whenever NO subcommand string
|
||||||
|
// matches ANY of the 12 retail dispatches (boot/info/chat/broadcast/
|
||||||
|
// ban/officer/title/hometown/ho/motd/name/lock/house) — retail keeps
|
||||||
|
// this ENTIRELY client-side; DoAllegiance never falls through to
|
||||||
|
// DoChannelCommand or the server for an unrecognized subcommand. CH4
|
||||||
|
// REJECT-review Blocker 1 (2026-08-09): acdream previously let an
|
||||||
|
// unmatched subcommand escape TryMatchAllegiance (return false, "not
|
||||||
|
// owned"), which fell all the way through to the unregistered-tag
|
||||||
|
// channel-fallback and broadcast the raw subcommand text ("boot Bob")
|
||||||
|
// to the legacy Allegiance channel (0x02000000) — a real chat-visible
|
||||||
|
// bug. TryMatchAllegiance below now claims ownership of "allegiance"/
|
||||||
|
// "all" UNCONDITIONALLY, exactly like retail's registered-command hash
|
||||||
|
// table does, and shows this refusal for every subcommand beyond the
|
||||||
|
// 2 ported ones (info/hometown/ho — TS-68 tracks the other 10).
|
||||||
|
private static readonly Definition AllegianceUnrecognizedSubcommand = new(
|
||||||
|
ClientCommandId.AllegianceUnrecognizedSubcommand,
|
||||||
|
Usage: "/allegiance <sub>",
|
||||||
|
HelpText: "Please see @help Allegiance for more information on how to use this command.",
|
||||||
|
ValidateArguments: static _ => false,
|
||||||
|
InvalidArgumentsText: "Please see @help Allegiance for more information on how to use this command.");
|
||||||
|
|
||||||
private static readonly FrozenDictionary<string, Definition> ByVerb =
|
private static readonly FrozenDictionary<string, Definition> ByVerb =
|
||||||
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
|
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
|
|
@ -520,14 +569,17 @@ public static class RetailClientCommandCatalog
|
||||||
/// Retail's real <c>DoHouse @ 0x00580860</c> handles 15 subcommands
|
/// Retail's real <c>DoHouse @ 0x00580860</c> handles 15 subcommands
|
||||||
/// (see the registry doc §2.5b) locally; acdream Campaign CH slice CH4
|
/// (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/
|
/// (2026-08-09) ports 4 of them (recall/re, mansion_recall/alleg_recall/
|
||||||
/// ma, abandon) plus the pre-existing HasValidArguments==false swallow
|
/// ma, abandon). Every OTHER subcommand — open, close, storage, remove,
|
||||||
/// for a MISSPELLED recall variant. Every OTHER subcommand — open,
|
/// boot, boot_all, remove_all, guest, available, hooks, on, off, and
|
||||||
/// close, storage, remove, boot, boot_all, remove_all, guest, available,
|
/// any misspelling of the 4 ported ones — returns <c>false</c>
|
||||||
/// hooks, on, off — is NOT yet ported (TS-68) and must reach ACE
|
/// uniformly (there is no separate local-swallow branch; CH4
|
||||||
/// (which replies "Unknown command") rather than being swallowed
|
/// REJECT-review nit 10, 2026-08-09, corrected this comment, which
|
||||||
/// locally with a wrong usage message — the Tier-1 #4 fix from the
|
/// previously described a swallow path that does not exist in the code
|
||||||
/// command-registry doc. Returning <c>false</c> here lets
|
/// below), letting <see cref="ChatCommandRouter"/> fall through to
|
||||||
/// <see cref="ChatCommandRouter"/> fall through to server passthrough.
|
/// server passthrough (ACE replies "Unknown command") rather than
|
||||||
|
/// being swallowed locally with a wrong usage message — the Tier-1 #4
|
||||||
|
/// fix from the command-registry doc. The 12 unported subcommands are
|
||||||
|
/// tracked by TS-68.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool TryMatchHouse(string arguments, out Match match)
|
private static bool TryMatchHouse(string arguments, out Match match)
|
||||||
{
|
{
|
||||||
|
|
@ -556,14 +608,26 @@ public static class RetailClientCommandCatalog
|
||||||
/// <c>@allegiance <sub></c> / <c>@all <sub></c> dispatcher.
|
/// <c>@allegiance <sub></c> / <c>@all <sub></c> dispatcher.
|
||||||
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles 12
|
/// Retail's real <c>DoAllegiance @ 0x0057D5A0</c> handles 12
|
||||||
/// subcommands (see the registry doc §2.5) locally; acdream Campaign CH
|
/// subcommands (see the registry doc §2.5) locally; acdream Campaign CH
|
||||||
/// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho). Every
|
/// slice CH4 (2026-08-09) ports 2 of them (info, hometown/ho).
|
||||||
/// 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>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>CH4 REJECT-review Blocker 1 correction (2026-08-09):</b> every
|
||||||
|
/// OTHER subcommand — boot, ban, officer, title, name, lock, house,
|
||||||
|
/// motd, chat, broadcast, or garbage — is NOT yet ported (TS-68), but
|
||||||
|
/// unlike <see cref="TryMatchHouse"/> this method NEVER returns
|
||||||
|
/// <c>false</c> for the "allegiance"/"all" verb: retail's own
|
||||||
|
/// <c>DoAllegiance</c> claims the ENTIRE verb unconditionally and
|
||||||
|
/// prints its own client-local refusal
|
||||||
|
/// (<see cref="AllegianceUnrecognizedSubcommand"/>) for an unrecognized
|
||||||
|
/// subcommand — it never falls through to <c>DoChannelCommand</c> or
|
||||||
|
/// the server. The original CH4 implementation returned <c>false</c>
|
||||||
|
/// here (matching <see cref="TryMatchHouse"/>'s reasoning), which let
|
||||||
|
/// an unmatched subcommand escape all the way to the unregistered-tag
|
||||||
|
/// channel-fallback and broadcast the raw text to the Allegiance
|
||||||
|
/// channel — a real bug, not merely an incomplete port.
|
||||||
|
/// </remarks>
|
||||||
private static bool TryMatchAllegiance(string arguments, out Match match)
|
private static bool TryMatchAllegiance(string arguments, out Match match)
|
||||||
{
|
{
|
||||||
match = default;
|
|
||||||
int separator = IndexOfWhitespace(arguments);
|
int separator = IndexOfWhitespace(arguments);
|
||||||
string subcommand = separator < 0 ? arguments : arguments[..separator];
|
string subcommand = separator < 0 ? arguments : arguments[..separator];
|
||||||
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
|
string rest = separator < 0 ? string.Empty : arguments[(separator + 1)..].Trim();
|
||||||
|
|
@ -591,7 +655,17 @@ public static class RetailClientCommandCatalog
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
// Every other subcommand (or none at all) — claim ownership
|
||||||
|
// anyway and show retail's own refusal text. See the remarks
|
||||||
|
// above; this is what stops "allegiance"/"all" from ever reaching
|
||||||
|
// ChatCommandRouter's channel-fallback or server-passthrough path.
|
||||||
|
match = new Match(
|
||||||
|
AllegianceUnrecognizedSubcommand.Command,
|
||||||
|
arguments,
|
||||||
|
AllegianceUnrecognizedSubcommand.Usage,
|
||||||
|
HasValidArguments: false,
|
||||||
|
AllegianceUnrecognizedSubcommand.InvalidArgumentsText);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Help line generated from the same definition routing uses.</summary>
|
/// <summary>Help line generated from the same definition routing uses.</summary>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
/// see TS-68).
|
/// see TS-68).
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Every entry is verbatim retail text recovered from
|
/// The named constants above <see cref="ByVerb"/> (<see cref="Tell"/>,
|
||||||
|
/// <see cref="Reply"/>, <see cref="Retell"/>, <see cref="MonarchReply"/>,
|
||||||
|
/// <see cref="PatronReply"/>, <see cref="Day"/>, <see cref="Log"/>,
|
||||||
|
/// <see cref="Render"/>, <see cref="Motd"/>, <see cref="AllegianceOverview"/>,
|
||||||
|
/// <see cref="HouseOverview"/>) are verbatim retail text recovered from
|
||||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c> by the
|
/// <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>
|
/// 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
|
/// function's byte extent for <c>push imm32</c> into <c>.rdata</c>). Entries
|
||||||
|
|
@ -23,6 +27,19 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
/// NOT fabricated — they are simply absent from this table; the lookup
|
/// NOT fabricated — they are simply absent from this table; the lookup
|
||||||
/// falls through to a generic "no detailed help" line rather than guess.
|
/// falls through to a generic "no detailed help" line rather than guess.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Corrected 2026-08-09 at the CH4 REJECT-review, SHOULD-FIX 7:</b> the
|
||||||
|
/// paragraph above previously claimed "every entry" was verbatim retail
|
||||||
|
/// text, which was FALSE and directly contradicted <see cref="ByVerb"/>'s
|
||||||
|
/// own inline comment a few dozen lines below it. The ~35 CHANNEL
|
||||||
|
/// one-liners in <see cref="ByVerb"/> ("Sends text to your Fellowship
|
||||||
|
/// channel.", etc.) are acdream-authored SUMMARIES, not individually
|
||||||
|
/// hand-extracted retail strings — retail's own per-channel help text was
|
||||||
|
/// not recovered this slice. Recovering them (or deleting the class-doc
|
||||||
|
/// overclaim) is future work; this comment now says so honestly instead
|
||||||
|
/// of leaving the contradiction standing.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class RetailCommandHelpTable
|
public static class RetailCommandHelpTable
|
||||||
{
|
{
|
||||||
|
|
@ -44,17 +61,27 @@ public static class RetailCommandHelpTable
|
||||||
"Note: You may substitute a forward slash (/) for the at symbol (@).";
|
"Note: You may substitute a forward slash (/) for the at symbol (@).";
|
||||||
|
|
||||||
// @mr/@pr are registered with a NULL function pointer in the 2013
|
// @mr/@pr are registered with a NULL function pointer in the 2013
|
||||||
// build (verified at 0x00583041/0x005830C1 — arg3 is 0). Retail's own
|
// build (verified at 0x00583041/0x005830C1 — arg3 is 0), so they never
|
||||||
// HelpReply @0x00577A50 is shared between @reply/@r/@rp (which DO
|
// execute locally in retail OR acdream — typing one sends the literal
|
||||||
// execute) and @mr/@pr (which do NOT — they fall through to
|
// text to the server. CH4 REJECT-review SHOULD-FIX 7 (2026-08-09):
|
||||||
// DoChannelCommand, miss, and reach the server as literal text). The
|
// the strings below were previously FABRICATED acdream summaries; the
|
||||||
// shared help text is Reply's text above; acdream additionally notes
|
// real retail-registered help function (HelpReply @0x00577A50) is
|
||||||
// the non-execution here so /help mr doesn't imply it works.
|
// shared across @reply/@r/@rp/@mr/@pr and IS the source of a per-verb
|
||||||
|
// detail line for each, extracted verbatim below —
|
||||||
|
// acclient_2013_pseudo_c.txt:1030734 (data_7daa08) for @mr,
|
||||||
|
// acclient_2013_pseudo_c.txt:1030738 (data_7daa80) for @pr. Retail's
|
||||||
|
// own strings have a double space before "you" — confirmed byte-level,
|
||||||
|
// not a typo. (HelpReply's full concatenation across all 5 shared
|
||||||
|
// verbs is more involved than a single-string extraction can safely
|
||||||
|
// confirm from the pseudo-C alone — a BN decomp string-temporary
|
||||||
|
// pattern reuses the output-parameter stack slot, which risks a
|
||||||
|
// misread; only the two per-verb detail lines requested by the review
|
||||||
|
// are pinned here, not a full re-derivation of HelpReply's output.)
|
||||||
public const string MonarchReply =
|
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.";
|
"@mr <text> - Sends the text to the last person who used @m to send you a message. This only works for monarchs.";
|
||||||
|
|
||||||
public const string PatronReply =
|
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.";
|
"@pr <text> - Sends the text to the last vassal who used @p to send you a message.";
|
||||||
|
|
||||||
// acclient_2013_pseudo_c.txt:1031093 (data_7de280).
|
// acclient_2013_pseudo_c.txt:1031093 (data_7de280).
|
||||||
public const string Day =
|
public const string Day =
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,124 @@ public sealed class ClientCommandControllerTests
|
||||||
Assert.Equal(["Component list cleared.", "You need an open vendor."], messages);
|
Assert.Equal(["Component list cleared.", "You need an open vendor."], messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CH4 REJECT-review Blocker 2 (2026-08-09) ────────────────────────
|
||||||
|
// "@house abandon" must run retail's real two-stage confirmation
|
||||||
|
// (DoHouse's abandon branch @0x00580D58 → HouseAbandonDialogCallback_
|
||||||
|
// First @0x00580E1A → HouseAbandonDialogCallback_Second @0x0057BE90,
|
||||||
|
// the ONLY Event_AbandonHouse() call site @0x0057BF01) before sending
|
||||||
|
// 0x021F — previously it sent immediately with no confirmation at all.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseAbandon_BothStagesAccepted_ShowsBothPromptsThenSendsExactlyOnce()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var controller = NewController(calls);
|
||||||
|
|
||||||
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
||||||
|
"confirm:Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!",
|
||||||
|
"houseabandon",
|
||||||
|
],
|
||||||
|
calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseAbandon_DeclineFirstStage_ShowsOnlyOnePromptAndNeverSends()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var controller = NewController(calls, confirmationResponses: new Queue<bool>([false]));
|
||||||
|
|
||||||
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
||||||
|
],
|
||||||
|
calls);
|
||||||
|
Assert.DoesNotContain("houseabandon", calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HouseAbandon_DeclineSecondStage_ShowsBothPromptsAndNeverSends()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var controller = NewController(calls, confirmationResponses: new Queue<bool>([true, false]));
|
||||||
|
|
||||||
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.HouseAbandon, ""));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"confirm:Do you really want to abandon your house? Any items in the house (on hooks or in storage) will stay with the house, and you will lose access to them.",
|
||||||
|
"confirm:Are you absolutely certain you wish to abandon your house? Click yes only if you are sure!",
|
||||||
|
],
|
||||||
|
calls);
|
||||||
|
Assert.DoesNotContain("houseabandon", calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CH4 REJECT-review SHOULD-FIX 5 (2026-08-09) ─────────────────────
|
||||||
|
// "@permit add/remove <multi-word name>" joins every token after the
|
||||||
|
// mode word into the name (retail's JoinArgsAsName).
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Permit_MultiWordName_JoinsTheRemainderIntoOneName()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var controller = NewController(calls);
|
||||||
|
|
||||||
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Permit, "add Aunt Agatha"));
|
||||||
|
controller.Execute(new ExecuteClientCommandCmd(ClientCommandId.Permit, "remove Lord Gnarly Beard"));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
["permitadd:Aunt Agatha", "permitremove:Lord Gnarly Beard"],
|
||||||
|
calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CH4 REJECT-review SHOULD-FIX 6 (2026-08-09) ─────────────────────
|
||||||
|
// @clist/@on/@off with an unresolvable (but single-token) tag raises
|
||||||
|
// retail's WeenieError 0x422 ("That channel doesn't exist.") instead
|
||||||
|
// of silently doing nothing.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChannelArgumentCommands_UnknownTag_ShowsWeenieError422WithoutSending()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var errors = new List<uint>();
|
||||||
|
var controller = NewController(calls, errors);
|
||||||
|
|
||||||
|
Execute(ClientCommandId.ListChannel, "nonsense");
|
||||||
|
Execute(ClientCommandId.OnChannel, "nonsense");
|
||||||
|
Execute(ClientCommandId.OffChannel, "nonsense");
|
||||||
|
|
||||||
|
Assert.Empty(calls);
|
||||||
|
Assert.Equal([0x0422u, 0x0422u, 0x0422u], errors);
|
||||||
|
|
||||||
|
void Execute(ClientCommandId id, string arguments) =>
|
||||||
|
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChannelArgumentCommands_KnownTag_SendsWithoutError()
|
||||||
|
{
|
||||||
|
var calls = new List<string>();
|
||||||
|
var errors = new List<uint>();
|
||||||
|
var controller = NewController(calls, errors);
|
||||||
|
|
||||||
|
Execute(ClientCommandId.ListChannel, "fellowship");
|
||||||
|
Execute(ClientCommandId.OnChannel, "admin");
|
||||||
|
Execute(ClientCommandId.OffChannel, "sentinel");
|
||||||
|
|
||||||
|
Assert.Empty(errors);
|
||||||
|
Assert.Equal(
|
||||||
|
["clist:2048", "on:2", "off:512"],
|
||||||
|
calls);
|
||||||
|
|
||||||
|
void Execute(ClientCommandId id, string arguments) =>
|
||||||
|
controller.Execute(new ExecuteClientCommandCmd(id, arguments));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UnknownCommandId_FailsAtApplicationBoundary()
|
public void UnknownCommandId_FailsAtApplicationBoundary()
|
||||||
{
|
{
|
||||||
|
|
@ -319,7 +437,13 @@ public sealed class ClientCommandControllerTests
|
||||||
FriendsState? friends = null,
|
FriendsState? friends = null,
|
||||||
SquelchState? squelch = null,
|
SquelchState? squelch = null,
|
||||||
string? lastTeller = null,
|
string? lastTeller = null,
|
||||||
bool vendorOpen = false)
|
bool vendorOpen = false,
|
||||||
|
// CH4 REJECT-review Blocker 2 (2026-08-09): lets a test drive a
|
||||||
|
// specific accept/decline sequence through consecutive
|
||||||
|
// ShowConfirmation calls (e.g. house-abandon's two-stage prompt).
|
||||||
|
// Defaults to "always accept" so every pre-existing single-stage
|
||||||
|
// test (Die, etc.) keeps its original behavior unchanged.
|
||||||
|
Queue<bool>? confirmationResponses = null)
|
||||||
{
|
{
|
||||||
calls ??= [];
|
calls ??= [];
|
||||||
errors ??= [];
|
errors ??= [];
|
||||||
|
|
@ -347,7 +471,10 @@ public sealed class ClientCommandControllerTests
|
||||||
(message, completed) =>
|
(message, completed) =>
|
||||||
{
|
{
|
||||||
calls.Add($"confirm:{message}");
|
calls.Add($"confirm:{message}");
|
||||||
completed(true);
|
bool accepted = confirmationResponses is { Count: > 0 }
|
||||||
|
? confirmationResponses.Dequeue()
|
||||||
|
: true;
|
||||||
|
completed(accepted);
|
||||||
},
|
},
|
||||||
() => calls.Add("suicide"),
|
() => calls.Add("suicide"),
|
||||||
all => calls.Add("clear:" + all),
|
all => calls.Add("clear:" + all),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Properties;
|
using AcDream.Core.Properties;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Core.Player;
|
using AcDream.Core.Player;
|
||||||
|
|
@ -231,6 +232,60 @@ public sealed class RuntimeCharacterStateTests
|
||||||
Assert.Equal(-1, state.MovementSkills.JumpSkill);
|
Assert.Equal(-1, state.MovementSkills.JumpSkill);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CharacterOptionId.ListenToGeneralChat, PlayerDescriptionParser.CharacterOptions2.HearGeneralChat)]
|
||||||
|
[InlineData(CharacterOptionId.ListenToTradeChat, PlayerDescriptionParser.CharacterOptions2.HearTradeChat)]
|
||||||
|
[InlineData(CharacterOptionId.ListenToLFGChat, PlayerDescriptionParser.CharacterOptions2.HearLFGChat)]
|
||||||
|
[InlineData(CharacterOptionId.ListenToRoleplayChat, PlayerDescriptionParser.CharacterOptions2.HearRoleplayChat)]
|
||||||
|
[InlineData(CharacterOptionId.ListenToSocietyChat, PlayerDescriptionParser.CharacterOptions2.HearSocietyChat)]
|
||||||
|
public void SetOptionBit_Options2Ids_ToggleOnlyTheirOwnBit(
|
||||||
|
CharacterOptionId optionId, PlayerDescriptionParser.CharacterOptions2 bit)
|
||||||
|
{
|
||||||
|
var options = new RuntimeCharacterOptionsState();
|
||||||
|
options.Replace(options.Options1, 0u); // every Hear*Chat bit off
|
||||||
|
|
||||||
|
options.SetOptionBit((uint)optionId, true);
|
||||||
|
Assert.Equal((uint)bit, options.Options2 & (uint)bit);
|
||||||
|
Assert.Equal(RuntimeCharacterOptionsState.DefaultOptions1, options.Options1);
|
||||||
|
|
||||||
|
options.SetOptionBit((uint)optionId, false);
|
||||||
|
Assert.Equal(0u, options.Options2 & (uint)bit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetOptionBit_AllegianceId_TogglesOptions1NotOptions2()
|
||||||
|
{
|
||||||
|
var options = new RuntimeCharacterOptionsState();
|
||||||
|
options.Replace(0u, options.Options2); // HearAllegianceChat off
|
||||||
|
|
||||||
|
options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, true);
|
||||||
|
Assert.Equal(
|
||||||
|
(uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat,
|
||||||
|
options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat);
|
||||||
|
|
||||||
|
options.SetOptionBit((uint)CharacterOptionId.ListenToAllegianceChat, false);
|
||||||
|
Assert.Equal(
|
||||||
|
0u,
|
||||||
|
options.Options1 & (uint)PlayerDescriptionParser.CharacterOptions1.HearAllegianceChat);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetOptionBit_UnrecognizedId_IsANoOp()
|
||||||
|
{
|
||||||
|
var options = new RuntimeCharacterOptionsState();
|
||||||
|
uint before1 = options.Options1;
|
||||||
|
uint before2 = options.Options2;
|
||||||
|
long beforeRevision = options.Revision;
|
||||||
|
|
||||||
|
options.SetOptionBit(0xFFFFu, true);
|
||||||
|
|
||||||
|
Assert.Equal(before1, options.Options1);
|
||||||
|
Assert.Equal(before2, options.Options2);
|
||||||
|
Assert.Equal(beforeRevision, options.Revision);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ───
|
// ── Campaign P Slice P1 (2026-07-30): burden/stamina/vitae-adjusted ───
|
||||||
// ── run/jump skill (pseudocode doc §9) ─────────────────────────────
|
// ── run/jump skill (pseudocode doc §9) ─────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,51 @@ public sealed class TurbineChatMembershipGateTests
|
||||||
Assert.Equal(expectedType, refusal.Value.Type);
|
Assert.Equal(expectedType, refusal.Value.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CH4 REJECT-review SHOULD-FIX 4 (2026-08-09) ────────────────────
|
||||||
|
// @join/@leave must update RuntimeCharacterOptionsState locally so
|
||||||
|
// this SAME-SESSION gate stops refusing without waiting on a fresh
|
||||||
|
// PlayerDescription (retail's PlayerModule::SetHear*Chat family
|
||||||
|
// writes the bit locally FIRST, then notifies).
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JoinChannel_SetOptionBit_FlipsGateToAllowed_WithoutFreshPlayerDescription()
|
||||||
|
{
|
||||||
|
TurbineChatState turbine = ReceivedRooms();
|
||||||
|
var options = new RuntimeCharacterOptionsState();
|
||||||
|
options.Replace(options.Options1, 0u); // every Hear*Chat bit off — starts refused
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TurbineChatGateStatus.NotListening,
|
||||||
|
TurbineChatMembershipGate.Evaluate(
|
||||||
|
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
|
||||||
|
|
||||||
|
options.SetOptionBit((uint)CharacterOptionId.ListenToGeneralChat, true);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TurbineChatGateStatus.Allowed,
|
||||||
|
TurbineChatMembershipGate.Evaluate(
|
||||||
|
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LeaveChannel_SetOptionBit_FlipsGateToNotListening()
|
||||||
|
{
|
||||||
|
TurbineChatState turbine = ReceivedRooms();
|
||||||
|
var options = new RuntimeCharacterOptionsState(); // General on by default
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TurbineChatGateStatus.Allowed,
|
||||||
|
TurbineChatMembershipGate.Evaluate(
|
||||||
|
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
|
||||||
|
|
||||||
|
options.SetOptionBit((uint)CharacterOptionId.ListenToGeneralChat, false);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
TurbineChatGateStatus.NotListening,
|
||||||
|
TurbineChatMembershipGate.Evaluate(
|
||||||
|
ChatChannelKindLite.General, turbine, options, isOlthoiPlayer: false).Status);
|
||||||
|
}
|
||||||
|
|
||||||
private static TurbineChatState ReceivedRooms(
|
private static TurbineChatState ReceivedRooms(
|
||||||
uint allegianceRoom = 0x10u,
|
uint allegianceRoom = 0x10u,
|
||||||
uint generalRoom = 0x11u,
|
uint generalRoom = 0x11u,
|
||||||
|
|
|
||||||
|
|
@ -171,15 +171,44 @@ public class ChatCommandRouterTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UnregisteredChannelTag_WithNoText_ShowsRetailRefusal_AndPublishesNothing()
|
public void UnregisteredChannelTag_WithNoText_PassesThroughToServer()
|
||||||
{
|
{
|
||||||
|
// CH4 REJECT-review SHOULD-FIX 3 (2026-08-09): retail's
|
||||||
|
// DoChannelCommand @0x005774A7 returns 0 SILENTLY on argc<=0 for an
|
||||||
|
// UNREGISTERED tag; DoCommand's own final fallback then sends the
|
||||||
|
// raw @-line to the server via Event_Talk. "You must specify the
|
||||||
|
// text you wish to say!" belongs to DoStupidChannelHack
|
||||||
|
// @0x0057B144, which only runs for REGISTERED channel verbs — it
|
||||||
|
// must never appear for one of the 22 unregistered fallback tags.
|
||||||
var (vm, log, bus) = Fixture();
|
var (vm, log, bus) = Fixture();
|
||||||
|
|
||||||
var outcome = ChatCommandRouter.Submit("/sentinel", vm, bus, ChatChannelKind.Say);
|
var outcome = ChatCommandRouter.Submit("/sentinel", vm, bus, ChatChannelKind.Say);
|
||||||
|
|
||||||
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||||||
|
var command = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
||||||
|
Assert.Equal("@sentinel", command.Text);
|
||||||
|
Assert.DoesNotContain(log.Snapshot(), entry => entry.Text.Contains("You must specify the text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CH4 REJECT-review Blocker 1 (2026-08-09) ────────────────────────
|
||||||
|
// "@allegiance <sub>" must never broadcast to the Allegiance channel
|
||||||
|
// or reach the server for an unrecognized subcommand — retail's own
|
||||||
|
// DoAllegiance claims the entire verb unconditionally and shows its
|
||||||
|
// own client-local refusal.
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/allegiance boot Bob")]
|
||||||
|
[InlineData("/all boot Bob")]
|
||||||
|
public void AllegianceUnrecognizedSubcommand_ShowsRetailRefusal_NeverBroadcastsOrSends(string input)
|
||||||
|
{
|
||||||
|
var (vm, log, bus) = Fixture();
|
||||||
|
|
||||||
|
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
|
||||||
|
|
||||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||||
Assert.Empty(bus.Published);
|
Assert.Empty(bus.Published); // no SendRawChannelCmd, no SendServerCommandCmd
|
||||||
Assert.Contains(log.Snapshot(), entry => entry.Text == "You must specify the text you wish to say!");
|
Assert.Contains(log.Snapshot(), entry =>
|
||||||
|
entry.Text == "Please see @help Allegiance for more information on how to use this command.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -208,6 +237,23 @@ public class ChatCommandRouterTests
|
||||||
Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("Returns you to the last lifestone"));
|
Assert.Contains(log.Snapshot(), entry => entry.Text.Contains("Returns you to the last lifestone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/help mr", "@mr <text> - Sends the text to the last person who used @m to send you a message. This only works for monarchs.")]
|
||||||
|
[InlineData("/help pr", "@pr <text> - Sends the text to the last vassal who used @p to send you a message.")]
|
||||||
|
public void HelpVerb_MrPr_ShowsVerbatimRetailText(string input, string expected)
|
||||||
|
{
|
||||||
|
// CH4 REJECT-review SHOULD-FIX 7 (2026-08-09): these were
|
||||||
|
// previously fabricated acdream summaries; now the verbatim retail
|
||||||
|
// strings from data_7daa08/data_7daa80 (the double space before
|
||||||
|
// "you" is confirmed byte-level, not a typo).
|
||||||
|
var (vm, log, bus) = Fixture();
|
||||||
|
|
||||||
|
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
|
||||||
|
|
||||||
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||||
|
Assert.Contains(log.Snapshot(), entry => entry.Text == expected);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HelpVerb_UnknownVerb_ShowsFallbackMessage()
|
public void HelpVerb_UnknownVerb_ShowsFallbackMessage()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -123,11 +123,23 @@ public sealed class RetailClientCommandCatalogTests
|
||||||
[InlineData("/allegiance motd")]
|
[InlineData("/allegiance motd")]
|
||||||
[InlineData("/allegiance")]
|
[InlineData("/allegiance")]
|
||||||
[InlineData("/all officer add 2 Bob")]
|
[InlineData("/all officer add 2 Bob")]
|
||||||
public void UnsupportedAllegianceSubcommand_FallsThroughToServerPassthrough(string input)
|
public void UnsupportedAllegianceSubcommand_ShowsRetailRefusal_ClientSide(string input)
|
||||||
{
|
{
|
||||||
// Same Tier-1-class fix, applied to the allegiance management
|
// CH4 REJECT-review Blocker 1 (2026-08-09): unlike @house (whose
|
||||||
// dispatcher (TS-68): unrecognized subcommands reach ACE.
|
// unrecognized subcommands correctly reach ACE, see the test
|
||||||
Assert.False(RetailClientCommandCatalog.TryMatch(input, out _));
|
// above), retail's own DoAllegiance NEVER falls through to
|
||||||
|
// DoChannelCommand/the server for an unrecognized subcommand — it
|
||||||
|
// claims the whole verb unconditionally and prints its own
|
||||||
|
// client-local refusal (label_57da4b, 0x0057DA4B). The earlier
|
||||||
|
// "falls through to server passthrough" behavior here was itself
|
||||||
|
// the bug: an unmatched subcommand used to escape all the way to
|
||||||
|
// the unregistered-tag channel fallback and broadcast to the
|
||||||
|
// Allegiance chat channel.
|
||||||
|
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||||
|
Assert.False(match.HasValidArguments);
|
||||||
|
Assert.Equal(
|
||||||
|
"Please see @help Allegiance for more information on how to use this command.",
|
||||||
|
match.InvalidArgumentsText);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -155,6 +167,9 @@ public sealed class RetailClientCommandCatalogTests
|
||||||
[InlineData("/endurance", ClientCommandId.Endurance)]
|
[InlineData("/endurance", ClientCommandId.Endurance)]
|
||||||
[InlineData("/speaker", ClientCommandId.Speaker)]
|
[InlineData("/speaker", ClientCommandId.Speaker)]
|
||||||
[InlineData("/index", ClientCommandId.IndexChannels)]
|
[InlineData("/index", ClientCommandId.IndexChannels)]
|
||||||
|
// CH4 REJECT-review nit 14 (2026-08-09): DoChannelIndex ignores argc —
|
||||||
|
// "@index foo" sends the same request as bare "@index".
|
||||||
|
[InlineData("/index foo", ClientCommandId.IndexChannels)]
|
||||||
public void MissingAliasesSweep_Resolve(string input, ClientCommandId expected)
|
public void MissingAliasesSweep_Resolve(string input, ClientCommandId expected)
|
||||||
{
|
{
|
||||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||||
|
|
@ -201,6 +216,11 @@ public sealed class RetailClientCommandCatalogTests
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/permit add Bob", true)]
|
[InlineData("/permit add Bob", true)]
|
||||||
[InlineData("/permit remove Bob", true)]
|
[InlineData("/permit remove Bob", true)]
|
||||||
|
// CH4 REJECT-review SHOULD-FIX 5 (2026-08-09): retail's DoPermit joins
|
||||||
|
// every token after the mode word into the name (JoinArgsAsName), so a
|
||||||
|
// multi-word character name is a VALID shape, not a rejected one.
|
||||||
|
[InlineData("/permit add Aunt Agatha", true)]
|
||||||
|
[InlineData("/permit remove Lord Gnarly Beard", true)]
|
||||||
[InlineData("/permit add", false)]
|
[InlineData("/permit add", false)]
|
||||||
[InlineData("/permit maybe Bob", false)]
|
[InlineData("/permit maybe Bob", false)]
|
||||||
public void Permit_ArgumentShape(string input, bool expectedValid)
|
public void Permit_ArgumentShape(string input, bool expectedValid)
|
||||||
|
|
@ -222,8 +242,16 @@ public sealed class RetailClientCommandCatalogTests
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/clist fellowship", true)]
|
[InlineData("/clist fellowship", true)]
|
||||||
[InlineData("/on admin", true)]
|
[InlineData("/on admin", true)]
|
||||||
[InlineData("/off nonsense", false)]
|
// CH4 REJECT-review SHOULD-FIX 6 (2026-08-09): the catalog only
|
||||||
public void ChannelArgumentCommands_ResolveTagsAgainstRetailChannelTagTable(string input, bool expectedValid)
|
// validates argument SHAPE (retail's argc != 1 check) — a resolved-but-
|
||||||
|
// UNKNOWN single-token tag is now a VALID shape that reaches
|
||||||
|
// ClientCommandController, which raises WeenieError 0x422 ("That
|
||||||
|
// channel doesn't exist.") instead of the catalog silently rejecting
|
||||||
|
// it with the wrong "Please specify the channel name." usage line.
|
||||||
|
[InlineData("/off nonsense", true)]
|
||||||
|
[InlineData("/clist", false)]
|
||||||
|
[InlineData("/on fellowship extra", false)]
|
||||||
|
public void ChannelArgumentCommands_RequireExactlyOneToken(string input, bool expectedValid)
|
||||||
{
|
{
|
||||||
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
|
||||||
Assert.Equal(expectedValid, match.HasValidArguments);
|
Assert.Equal(expectedValid, match.HasValidArguments);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using AcDream.UI.Abstractions;
|
||||||
using AcDream.UI.Abstractions.Panels.Chat;
|
using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
|
||||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||||
|
|
@ -266,4 +267,34 @@ public sealed class RetailCommandRegistryConformanceTests
|
||||||
"test's registry, or it's an invented alias that must be deleted.");
|
"test's registry, or it's an invented alias that must be deleted.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CH4 REJECT-review nit 13 (2026-08-09) ───────────────────────────
|
||||||
|
//
|
||||||
|
// The tests above only prove a verb STRING is recognized somewhere —
|
||||||
|
// not which channel it actually resolves to. A rebind regression (e.g.
|
||||||
|
// "/g" quietly reverting to General, the exact Tier-1 #1 bug this
|
||||||
|
// campaign fixed) would still pass every case above. These two pin the
|
||||||
|
// real dispatch outcome so a rebind regression fails THIS suite, not
|
||||||
|
// just a narrower parser-only test.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GVerb_BindsToFellowship_NotGeneral()
|
||||||
|
{
|
||||||
|
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
|
||||||
|
"/g hi gang", ChatChannelKind.Say, lastTellSender: null);
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(ChatChannelKind.Fellowship, parsed!.Value.Channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RpVerb_BindsToReply_NotRoleplay()
|
||||||
|
{
|
||||||
|
ChatInputParser.ParsedInput? parsed = ChatInputParser.Parse(
|
||||||
|
"/rp hello back", ChatChannelKind.Say, lastTellSender: "Aunt Agatha");
|
||||||
|
|
||||||
|
Assert.NotNull(parsed);
|
||||||
|
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
|
||||||
|
Assert.Equal("Aunt Agatha", parsed!.Value.TargetName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue