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

@ -300,6 +300,124 @@ public sealed class ClientCommandControllerTests
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]
public void UnknownCommandId_FailsAtApplicationBoundary()
{
@ -319,7 +437,13 @@ public sealed class ClientCommandControllerTests
FriendsState? friends = null,
SquelchState? squelch = 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 ??= [];
errors ??= [];
@ -347,7 +471,10 @@ public sealed class ClientCommandControllerTests
(message, completed) =>
{
calls.Add($"confirm:{message}");
completed(true);
bool accepted = confirmationResponses is { Count: > 0 }
? confirmationResponses.Dequeue()
: true;
completed(accepted);
},
() => calls.Add("suicide"),
all => calls.Add("clear:" + all),