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

@ -171,15 +171,44 @@ public class ChatCommandRouterTests
}
[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 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.Empty(bus.Published);
Assert.Contains(log.Snapshot(), entry => entry.Text == "You must specify the text you wish to say!");
Assert.Empty(bus.Published); // no SendRawChannelCmd, no SendServerCommandCmd
Assert.Contains(log.Snapshot(), entry =>
entry.Text == "Please see @help Allegiance for more information on how to use this command.");
}
[Fact]
@ -208,6 +237,23 @@ public class ChatCommandRouterTests
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]
public void HelpVerb_UnknownVerb_ShowsFallbackMessage()
{

View file

@ -123,11 +123,23 @@ public sealed class RetailClientCommandCatalogTests
[InlineData("/allegiance motd")]
[InlineData("/allegiance")]
[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
// dispatcher (TS-68): unrecognized subcommands reach ACE.
Assert.False(RetailClientCommandCatalog.TryMatch(input, out _));
// CH4 REJECT-review Blocker 1 (2026-08-09): unlike @house (whose
// unrecognized subcommands correctly reach ACE, see the test
// 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]
@ -155,6 +167,9 @@ public sealed class RetailClientCommandCatalogTests
[InlineData("/endurance", ClientCommandId.Endurance)]
[InlineData("/speaker", ClientCommandId.Speaker)]
[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)
{
Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match));
@ -201,6 +216,11 @@ public sealed class RetailClientCommandCatalogTests
[Theory]
[InlineData("/permit add 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 maybe Bob", false)]
public void Permit_ArgumentShape(string input, bool expectedValid)
@ -222,8 +242,16 @@ public sealed class RetailClientCommandCatalogTests
[Theory]
[InlineData("/clist fellowship", true)]
[InlineData("/on admin", true)]
[InlineData("/off nonsense", false)]
public void ChannelArgumentCommands_ResolveTagsAgainstRetailChannelTagTable(string input, bool expectedValid)
// CH4 REJECT-review SHOULD-FIX 6 (2026-08-09): the catalog only
// 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.Equal(expectedValid, match.HasValidArguments);

View file

@ -1,3 +1,4 @@
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.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.");
}
}
// ── 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);
}
}