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:
Erik 2026-08-09 21:59:35 +02:00
parent 090825e703
commit 724ef2d389
17 changed files with 853 additions and 85 deletions

View file

@ -256,9 +256,13 @@ public sealed class ClientCommandController
"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).
// chat-window title chrome exists yet (AP-182) — the binding is
// a pure no-op, matching retail's silent success (no
// 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:
_bindings.SetChatTitle(command.Arguments.Trim());
break;
@ -300,20 +304,32 @@ public sealed class ClientCommandController
case ClientCommandId.IndexChannels:
_bindings.RequestChannelIndex();
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:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint listChannelId))
_bindings.RequestChannelList(listChannelId);
else
_bindings.ShowWeenieError(0x0422u);
break;
// ClientCommunicationSystem::DoChannelOn @ 0x0057AA80.
// ClientCommunicationSystem::DoChannelOn @ 0x0057AA80. Same
// 0x422 shape as ListChannel above.
case ClientCommandId.OnChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint onChannelId))
_bindings.JoinGmChannel(onChannelId);
else
_bindings.ShowWeenieError(0x0422u);
break;
// ClientCommunicationSystem::DoChannelOff @ 0x0057AB50.
// ClientCommunicationSystem::DoChannelOff @ 0x0057AB50. Same
// 0x422 shape as ListChannel above.
case ClientCommandId.OffChannel:
if (RetailChannelTagTable.TryResolve(command.Arguments.Trim(), out uint offChannelId))
_bindings.LeaveGmChannel(offChannelId);
else
_bindings.ShowWeenieError(0x0422u);
break;
// GameActionRecallAllegianceHometown — @alh/@ah/"@allegiance hometown".
case ClientCommandId.AllegianceHometown:
@ -323,9 +339,34 @@ public sealed class ClientCommandController
case ClientCommandId.AllegianceInfo:
_bindings.RequestAllegianceInfo(command.Arguments.Trim());
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:
_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;
default:
@ -645,12 +686,15 @@ public sealed class ClientCommandController
}
// ClientCommunicationSystem::DoPermit @ 0x005785A0. Argument shape
// already validated by RetailClientCommandCatalog (exactly "add <name>"
// or "remove <name>").
// already validated by RetailClientCommandCatalog (a mode word plus at
// 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)
{
string[] parts = SplitArguments(arguments);
string name = parts[1];
string name = string.Join(' ', parts, 1, parts.Length - 1);
if (parts[0].Equals("add", StringComparison.OrdinalIgnoreCase))
_bindings.AddPlayerPermission(name);
else