fix(chat): #363 — retail 0x1A typing for command refusals via the interface-text seam

ChatVM gains an OnInterfaceText hook + ShowInterfaceText(text), the
App-layer composition wires it to RuntimeCommunicationState.AddText,
and ChatCommandRouter routes every retail-0x1A command refusal through
it instead of the chat log's 0x00 sink. UI.Abstractions still never
references Runtime directly; unwired hosts (headless, tests) fall back
to the chat log tagged ClientLocal so no text is ever silently lost.

Reclassified per register row AP-183 (DoChannelList/On/Off, DoAllegiance,
DoHouseAvailableList — the last also corrected to retail's own bad-house-
type string instead of a synthesized "Usage:" line) and newly wired two
sites that previously showed nothing at all (DoStupidChannelHack's bare
legacy-channel-verb refusal, DoReply's message-but-no-last-teller
refusal). The generic bad-args fallback now resolves WeenieErrorMessages
0x026 ("That is not a valid command.", retail's HandleFailureEvent(0x26))
instead of synthesizing "Usage: {Usage}". DoSpeaker/DoEndurance/DoTitle
are untouched — already correct at 0x00.

Also closes #367 (DoHelp's "Unknown command" fallback and the degenerate-
prefix refusal now reach the SpewBox too) and retires register row
AP-186, whose own filing proposed exactly this seam shape.

Full Release suite: 12,542 passed / 4 skipped / 0 failed (baseline
12,466/4/0 at ff2784ea).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 15:20:57 +02:00
parent ff2784eaa4
commit 09453ecae8
14 changed files with 702 additions and 106 deletions

View file

@ -22,6 +22,23 @@ public class ChatCommandRouterTests
return (vm, log, new CaptureBus());
}
/// <summary>
/// #363: a fixture with <see cref="ChatVM.OnInterfaceText"/> wired to a
/// capturing list, so tests can assert a 0x1A refusal reached the
/// SpewBox seam directly instead of only observing the chat-log
/// null-fallback.
/// </summary>
private static (ChatVM vm, ChatLog log, CaptureBus bus, List<string> interfaceTexts) FixtureWithInterfaceSink()
{
var log = new ChatLog();
var interfaceTexts = new List<string>();
var vm = new ChatVM(log, displayLimit: 50)
{
OnInterfaceText = interfaceTexts.Add,
};
return (vm, log, new CaptureBus(), interfaceTexts);
}
[Fact]
public void PlainText_PublishesOnDefaultChannel()
{
@ -76,15 +93,37 @@ public class ChatCommandRouterTests
}
[Fact]
public void LifestoneWithArguments_ShowsUsageAndPublishesNothing()
public void LifestoneWithArguments_ShowsRetailBadArgsRefusal_ViaInterfaceTextSeam()
{
var (vm, log, bus) = Fixture();
// #363 / register row AP-183: a Definition with no bespoke
// InvalidArgumentsText falls to retail's own generic bad-args
// fallback (HandleFailureEvent(0x26), "That is not a valid
// command.") at 0x1A ClientLocal — never the acdream-invented
// "Usage: /lifestone" line this used to synthesize.
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/ls now", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Contains(log.Snapshot(), entry => entry.Text == "Usage: /lifestone");
Assert.Equal("That is not a valid command.", Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Fact]
public void LifestoneWithArguments_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal()
{
// Headless / no-window null-fallback safety: when the App-layer
// host hasn't wired ChatVM.OnInterfaceText, the refusal must still
// reach the player instead of being silently dropped.
var (vm, log, bus) = Fixture();
var outcome = ChatCommandRouter.Submit("/ls now", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
var entry = Assert.Single(log.Snapshot());
Assert.Equal("That is not a valid command.", entry.Text);
Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType);
}
[Fact]
@ -201,14 +240,19 @@ public class ChatCommandRouterTests
[InlineData("/all boot Bob")]
public void AllegianceUnrecognizedSubcommand_ShowsRetailRefusal_NeverBroadcastsOrSends(string input)
{
var (vm, log, bus) = Fixture();
// #363 / register row AP-183: retail types this refusal 0x1A
// (ClientLocal / SpewBox-only) — routed through the interface-text
// seam, not the chat log.
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
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.");
Assert.Equal(
"Please see @help Allegiance for more information on how to use this command.",
Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Fact]
@ -263,23 +307,36 @@ public class ChatCommandRouterTests
}
[Fact]
public void HelpVerb_UnknownVerb_ShowsRetailUnknownCommandText()
public void HelpVerb_UnknownVerb_ShowsRetailUnknownCommandText_ViaInterfaceTextSeam()
{
// Campaign CH user-gate round 3 (2026-08-10): retail's own DoHelp
// fallback text is "Unknown command" (swept verbatim), not an
// acdream-invented "No help available" message. Retail types this
// 0x1A (ClientLocal / SpewBox-only); ChatVM has no SpewBox routing
// capability yet (ISSUES.md #367), so it still lands in the chat
// scroll here as ONE entry (no HelpPrefixNote wrapper — DoHelp's
// fallback bypasses the two-entry shape entirely).
var (vm, log, bus) = Fixture();
// 0x1A (ClientLocal / SpewBox-only). Issue #363/#367: now routed
// through the interface-text seam as ONE entry (no HelpPrefixNote
// wrapper — DoHelp's fallback bypasses the two-entry shape
// entirely), not the chat scroll.
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Equal(RetailCommandHelpTable.UnknownCommand, Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Fact]
public void HelpVerb_UnknownVerb_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal()
{
var (vm, log, bus) = Fixture();
var outcome = ChatCommandRouter.Submit("/help nonsenseverb", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
var entry = Assert.Single(log.Snapshot());
Assert.Equal(RetailCommandHelpTable.UnknownCommand, entry.Text);
Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType);
}
[Fact]
@ -299,4 +356,169 @@ public class ChatCommandRouterTests
Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text);
Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text);
}
// ── Issue #363 / register row AP-183 (2026-08-10) ───────────────────
// Retail's 0x1A (ClientLocal / SpewBox-only) command-refusal call
// sites now route through ChatVM.OnInterfaceText instead of the chat
// scroll. Each site below is pinned two ways: with the seam wired
// (reaches the SpewBox sink, log stays empty) and with it unwired
// (safe null-fallback into chat, tagged ClientLocal).
[Theory]
[InlineData("/clist")]
[InlineData("/clist a b")]
[InlineData("/on")]
[InlineData("/off nonsense extra")]
public void ChannelListOnOff_BadArgumentShape_ShowsRetailRefusal_ViaInterfaceTextSeam(string input)
{
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Equal("Please specify the channel name.", Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Fact]
public void HouseAvailableList_BadHouseType_ShowsRetailRefusal_ViaInterfaceTextSeam()
{
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/hslist nonsense", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Equal(
"Please see @help hslist for more information on how to use this command",
Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Theory]
[InlineData("/g")]
[InlineData("/f")]
[InlineData("/fellowship")]
[InlineData("/a")]
[InlineData("/ab")]
[InlineData("/m")]
[InlineData("/p")]
[InlineData("/v")]
[InlineData("/c")]
public void BareRegisteredChannelVerb_ShowsDoStupidChannelHackRefusal_ViaInterfaceTextSeam(string input)
{
// Retail ClientCommunicationSystem::DoStupidChannelHack
// @0x0057B144 — a registered legacy-channel verb with no message
// refuses locally instead of silently dropping the line.
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Equal("You must specify the text you wish to say!", Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Theory]
[InlineData("/lfg")]
[InlineData("/trade")]
[InlineData("/general")]
[InlineData("/roleplay")]
[InlineData("/society")]
[InlineData("/olthoi")]
public void BareTurbineOnlyChannelVerb_NeverShowsDoStupidChannelHackRefusal(string input)
{
// The seven Turbine-only channels are never DoStupidChannelHack's
// clients (command-registry doc §2.3) — a bare verb here is
// silently dropped, matching the pre-#363 behavior for these
// specific channels (unchanged by this fix).
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.Dropped, outcome);
Assert.Empty(bus.Published);
Assert.Empty(interfaceTexts);
Assert.Empty(log.Snapshot());
}
[Fact]
public void Reply_WithMessage_NoLastTeller_ShowsDoReplyRefusal_ViaInterfaceTextSeam()
{
// Retail ClientCommunicationSystem::DoReply @0x00577910 —
// gmCCommunicationSystem::GetLastTeller() == 0 branch.
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/r hi there", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Empty(bus.Published);
Assert.Equal("Someone must @tell you first!", Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Fact]
public void Reply_WithMessage_NoLastTeller_NoInterfaceSinkWired_FallsBackToChatLog_TaggedClientLocal()
{
var (vm, log, bus) = Fixture();
var outcome = ChatCommandRouter.Submit("/reply hi there", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
var entry = Assert.Single(log.Snapshot());
Assert.Equal("Someone must @tell you first!", entry.Text);
Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType);
}
[Fact]
public void Reply_WithMessage_WithLastTeller_StillSendsNormally()
{
// Sanity: the new missing-last-teller predicate must not shadow
// the ordinary reply path once a Tell has arrived.
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042, logTextType: 0x03u);
var outcome = ChatCommandRouter.Submit("/r hi there", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.Sent, outcome);
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal("Bestie", command.TargetName);
Assert.Empty(interfaceTexts);
}
[Fact]
public void DegeneratePrefix_UnknownCommand_ShowsRefusal_ViaInterfaceTextSeam()
{
// "/" alone (no letter verb) — the pre-existing "Unknown command:
// {verb}." refusal, now also routed through the interface-text
// seam (issue #367).
var (vm, log, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
Assert.Empty(bus.Published);
Assert.Contains("Unknown command:", Assert.Single(interfaceTexts));
Assert.Empty(log.Snapshot());
}
[Fact]
public void EnduranceCommand_ValidArguments_NeverTouchesTheInterfaceTextSeam()
{
// AP-183 sanity check: DoEndurance/DoSpeaker/DoTitle are ALREADY
// correct at 0x00 (informational) — their actual output text is
// produced by ClientCommandController (App layer), not
// ChatCommandRouter, so this pins that a valid catalog dispatch
// never emits through the new 0x1A seam at all.
var (vm, _, bus, interfaceTexts) = FixtureWithInterfaceSink();
var outcome = ChatCommandRouter.Submit("/endurance", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
var command = Assert.IsType<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.Endurance, command.Command);
Assert.Empty(interfaceTexts);
}
}

View file

@ -423,4 +423,95 @@ public sealed class ChatInputParserTests
Assert.Null(parsed.Value.TargetName);
Assert.Equal(text, parsed.Value.Text);
}
// ── Issue #363: IsBareRegisteredChannelVerb / IsReplyMissingLastTeller ──
[Theory]
[InlineData("/g")]
[InlineData("/f")]
[InlineData("/fellow")]
[InlineData("/fellows")]
[InlineData("/fellowship")]
[InlineData("/group")]
[InlineData("/party")]
[InlineData("/a")]
[InlineData("/guild")]
[InlineData("/gu")]
[InlineData("/ab")]
[InlineData("/m")]
[InlineData("/monarch")]
[InlineData("/p")]
[InlineData("/patron")]
[InlineData("/v")]
[InlineData("/vassal")]
[InlineData("/vassals")]
[InlineData("/c")]
[InlineData("/covassal")]
[InlineData("/covassals")]
[InlineData("/co-vassals")]
public void IsBareRegisteredChannelVerb_TrueForEveryLegacyChannelAlias(string verb)
{
Assert.True(ChatInputParser.IsBareRegisteredChannelVerb(verb));
}
[Theory]
[InlineData("/general")]
[InlineData("/cg")]
[InlineData("/lfg")]
[InlineData("/clfg")]
[InlineData("/trade")]
[InlineData("/ct")]
[InlineData("/roleplay")]
[InlineData("/crp")]
[InlineData("/society")]
[InlineData("/soc")]
[InlineData("/olthoi")]
[InlineData("/o")]
public void IsBareRegisteredChannelVerb_FalseForTurbineOnlyChannels(string verb)
{
// The seven Turbine-only channels are never DoStupidChannelHack's
// clients (command-registry doc §2.3).
Assert.False(ChatInputParser.IsBareRegisteredChannelVerb(verb));
}
[Theory]
[InlineData("/g hi gang")]
[InlineData("/a hey")]
[InlineData("hello")]
[InlineData("/say hi")]
[InlineData("/r hi")]
public void IsBareRegisteredChannelVerb_FalseWithMessageOrNotAChannelVerb(string input)
{
Assert.False(ChatInputParser.IsBareRegisteredChannelVerb(input));
}
[Theory]
[InlineData("/r hi there")]
[InlineData("/reply hi there")]
[InlineData("/rp hi there")]
public void IsReplyMissingLastTeller_TrueWithMessageAndNoLastTeller(string input)
{
Assert.True(ChatInputParser.IsReplyMissingLastTeller(input, lastTellSender: null));
Assert.True(ChatInputParser.IsReplyMissingLastTeller(input, lastTellSender: ""));
}
[Fact]
public void IsReplyMissingLastTeller_FalseWhenLastTellerPresent()
{
Assert.False(ChatInputParser.IsReplyMissingLastTeller("/r hi there", lastTellSender: "Bestie"));
}
[Theory]
[InlineData("/r")]
[InlineData("/r ")]
[InlineData("/g hi gang")]
[InlineData("hello")]
public void IsReplyMissingLastTeller_FalseWithoutAMessageOrNotAReplyVerb(string input)
{
// Bare "/r" (no message at all) is a DIFFERENT retail branch
// (DoReply's own copy of the "you must specify text" string) and
// is deliberately out of scope for this predicate — see its doc
// comment.
Assert.False(ChatInputParser.IsReplyMissingLastTeller(input, lastTellSender: null));
}
}

View file

@ -138,4 +138,33 @@ public sealed class ChatVMRetellAndProvidersTests
Assert.Contains("provider unavailable", log.Snapshot()[0].Text);
}
// ── Issue #363: ShowInterfaceText / OnInterfaceText seam ────────────
[Fact]
public void ShowInterfaceText_WithHook_InvokesHook_NeverTouchesTheChatLog()
{
var log = new ChatLog();
var received = new List<string>();
var vm = new ChatVM(log) { OnInterfaceText = received.Add };
vm.ShowInterfaceText("Someone must @tell you first!");
Assert.Equal("Someone must @tell you first!", Assert.Single(received));
Assert.Empty(log.Snapshot());
}
[Fact]
public void ShowInterfaceText_NoHook_FallsBackToChatLog_TaggedClientLocal()
{
var log = new ChatLog();
var vm = new ChatVM(log);
vm.ShowInterfaceText("Someone must @tell you first!");
var entry = Assert.Single(log.Snapshot());
Assert.Equal("Someone must @tell you first!", entry.Text);
Assert.Equal((uint)RetailLogTextType.ClientLocal, entry.LogTextType);
Assert.Equal(ChatKind.System, entry.Kind);
}
}