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:
parent
ff2784eaa4
commit
09453ecae8
14 changed files with 702 additions and 106 deletions
|
|
@ -155,7 +155,9 @@ parity (`docs/plans/2026-08-09-chat-parity-campaign.md`) is CODE-COMPLETE
|
|||
channels, command registry) landed and dual-Opus-reviewed, pending the
|
||||
connected user gate (test script:
|
||||
`docs/research/2026-08-09-campaign-ch-test-script.md`); carried tail
|
||||
#358–#363.
|
||||
#359–#361 (#358 and #362 closed pre-existing; #363 closed 2026-08-10 —
|
||||
the interface-text seam, see `docs/plans/2026-08-09-chat-parity-campaign.md`'s
|
||||
ledger).
|
||||
|
||||
**Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every
|
||||
placement route now runs through the canonical residence + continuation-
|
||||
|
|
|
|||
116
docs/ISSUES.md
116
docs/ISSUES.md
|
|
@ -96,30 +96,53 @@ slice CH4).
|
|||
|
||||
## #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.
|
||||
**Status:** CLOSED 2026-08-10. `ChatVM` gained a typed interface-text seam
|
||||
(`OnInterfaceText` init hook + `ShowInterfaceText(text)`) that the App-layer
|
||||
composition (`InteractionRetainedUiComposition.CreateRetainedUi`) wires to
|
||||
`RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)` —
|
||||
the same SpewBox chokepoint every other interface-text producer uses.
|
||||
UI.Abstractions still never references Runtime directly (Code Structure
|
||||
Rules); the hook is the seam. Unwired callers (headless, the automation
|
||||
probe runner, plain test fixtures) fall back to the ordinary chat log
|
||||
tagged `ClientLocal`, so no text is ever silently dropped.
|
||||
|
||||
Every site AP-183 named now routes through the seam: `DoStupidChannelHack`
|
||||
("You must specify the text you wish to say!", newly wired — the six
|
||||
legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure
|
||||
`return null` shape with NO message at all), `DoChannelList`/`DoChannelOn`/
|
||||
`DoChannelOff` ("Please specify the channel name.", reclassified),
|
||||
`DoAllegiance` ("Please see @help Allegiance...", reclassified),
|
||||
`DoHouseAvailableList` (reclassified AND corrected to retail's own
|
||||
"Please see @help hslist for more information on how to use this command"
|
||||
string — verified at `acclient_2013_pseudo_c.txt:381481`/`1029383`,
|
||||
replacing the acdream-synthesized "Usage: /hslist <house type>" fallback),
|
||||
and `DoReply` ("Someone must @tell you first!", newly wired for the
|
||||
"message but no last teller" branch — bare `/r` with no message at all is
|
||||
a separate retail branch, deliberately out of scope, not named by AP-183).
|
||||
`DoSpeaker`/`DoEndurance`/`DoTitle` are untouched — already correct at
|
||||
`0x00` (their text is produced by `ClientCommandController`, not
|
||||
`ChatCommandRouter`).
|
||||
|
||||
The generic bad-args fallback is also fixed: `ChatCommandRouter.Submit`'s
|
||||
catalog dispatch now falls to `WeenieErrorMessages.Resolve(0x026u, null)`
|
||||
("That is not a valid command.", the exact port of retail's
|
||||
`HandleFailureEvent(0x26)`) instead of synthesizing `"Usage: {Usage}"` —
|
||||
verified 5 decompiled handlers (`DoDie`, `DoChannelList`/`On`/`Off`,
|
||||
`DoAllegiance`, `DoHouseAvailableList`) are ALL `0x1A`, confirming the
|
||||
uniform routing decision. This also closes #367 (the "Unknown command"
|
||||
DoHelp fallback and the degenerate-prefix "Unknown command: {verb}."
|
||||
refusal both now use the same seam) and retires register row AP-186 —
|
||||
see that row's retirement note.
|
||||
|
||||
Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs`
|
||||
(seam wired / null-fallback), `ChatInputParserTests.cs`
|
||||
(`IsBareRegisteredChannelVerb`/`IsReplyMissingLastTeller` pure-predicate
|
||||
coverage), `ChatCommandRouterTests.cs` (per-site routing pinned both ways
|
||||
for every reclassified/newly-wired site, plus a Turbine-only-channel
|
||||
negative case and a 0x00-site-stays-in-chat sanity check).
|
||||
|
||||
**Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH,
|
||||
CH4 REJECT-review).
|
||||
CH4 REJECT-review; closed in the goal-window follow-up).
|
||||
|
||||
## #365 — Headless host cannot move at head: session quarantines on the first advance tick; world never hydrates
|
||||
|
||||
|
|
@ -343,46 +366,19 @@ click — not decoded by CH6a.
|
|||
|
||||
## #367 — ChatCommandRouter's local-presentation fallbacks type-0x1A text still lands in the chat scroll, never the SpewBox
|
||||
|
||||
**Status:** OPEN — filed 2026-08-10, Campaign CH user-gate round 3, while
|
||||
tracing `ClientCommunicationSystem::DoHelp @0x0057f9e0`'s complete print
|
||||
sequence for findings (b)/(c). Retail's DoHelp fallback for an unresolved
|
||||
`/help <verb>` is `AddTextToScroll(u"Unknown command", 0x1A, 1, 0)` — type
|
||||
`0x1A` (`ClientLocal`) is HARDCODED to the SpewBox, never the chat window
|
||||
(`docs/research/2026-08-09-chat-retail-interface-text.md` §2.1/§2.2, the
|
||||
same routing rule Campaign CH user-gate round 2 item 2 already ported for
|
||||
the portal-space notice). `RuntimeCommunicationState.AddText` (Runtime
|
||||
layer) implements this rule correctly — `type == ClientLocal` routes to
|
||||
`SpewBox` only. But `ChatCommandRouter`/`ChatVM` live in
|
||||
`AcDream.UI.Abstractions`, a layer beneath Runtime that must stay
|
||||
presentation/Runtime-independent (Code Structure Rules), so they have no
|
||||
path to the SpewBox at all — every local-presentation fallback
|
||||
(`RetailCommandHelpTable.UnknownCommand` now; also the pre-existing
|
||||
"Unknown command: {verb}." command-shaped-input refusal in
|
||||
`ChatCommandRouter.Submit`'s main body) still writes through
|
||||
`ChatVM.ShowSystemMessage`, which only ever reaches `ChatLog`. Not a
|
||||
regression this round — the fallback text was already wrong AND
|
||||
already routed to the chat window before this round's fix; this round
|
||||
corrected the TEXT ("Unknown command", byte-exact) and traced the
|
||||
routing divergence clearly enough to file it. Register row AP-186.
|
||||
|
||||
**Fix shape:** either (a) give `ChatVM` (or a sibling in UI.Abstractions)
|
||||
an optional `Action<string>? OnClientLocalText` hook the App-layer host
|
||||
wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`
|
||||
the same way `ChatWindowController`/other retained-UI controllers already
|
||||
receive delegates from the composition layer, or (b) accept the
|
||||
divergence permanently as an acdream simplification (all local-presentation
|
||||
refusals show in the chat window instead of splitting across two surfaces)
|
||||
and retire AP-186 as an accepted Intentional Architecture row instead. Needs
|
||||
a product decision, not just an implementation — small either way once
|
||||
decided.
|
||||
|
||||
**Where:** `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`;
|
||||
`src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs`;
|
||||
`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs` (`AddText`, the
|
||||
correctly-implemented Runtime-layer oracle this should eventually reach).
|
||||
**Status:** CLOSED 2026-08-10, closed as a side effect of #363's
|
||||
interface-text seam (fix shape (a) from this issue's own filing).
|
||||
`ChatVM.OnInterfaceText` is exactly the hook this issue asked for; both
|
||||
named fallbacks (`RetailCommandHelpTable.UnknownCommand` in
|
||||
`ChatCommandRouter.EmitVerbHelp`, and the degenerate-prefix "Unknown
|
||||
command: {verb}." refusal in `ChatCommandRouter.Submit`'s main body) now
|
||||
call `vm.ShowInterfaceText(...)` instead of `vm.ShowSystemMessage(...)`,
|
||||
reaching the SpewBox through `RuntimeCommunicationState.AddText` via the
|
||||
App-layer composition wiring. See #363's closure note for the full
|
||||
mechanism and test list. Register row AP-186 retired in the same commit.
|
||||
|
||||
**Campaign:** `docs/plans/2026-08-09-chat-parity-campaign.md` (Campaign CH,
|
||||
user gate round 3).
|
||||
user gate round 3; closed in the goal-window follow-up).
|
||||
|
||||
## #364 — Three `/help` group topics still partial: HelpStupidChannelHack unresolved
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -288,6 +288,7 @@ implementer per slice against a pinned contract (per
|
|||
| User gate round 3 | `98de4f5a` | Debug (all projects): 12,329 passed / 4 skipped / 1 failed (pre-existing #351 Debug-only flake — reproduces identically on the pristine pre-round-3 commit, not a regression); Release (every project reachable while a live `AcDream.App.exe` client — PID 15064, must not be killed per project policy — holds its own Release binaries locked, blocking `AcDream.App`/`AcDream.App.Tests`/`AcDream.Core.Tests` specifically): `AcDream.UI.Abstractions.Tests` (the layer this round's `/help` fix lives in) 867/867, plus `Core.Net.Tests` 823/823, `Runtime.Tests` 1,323/1,323, `Content.Tests` 130/130, `Headless.Tests` 89/89, `Bake.Tests` 15/15, `Cli.Tests` 4/4 — all 0 failed | — | findings (a)-(c) fixed this commit — SpewBox flush-top + retail dat font, `/help`/`/help death` exact retail print sequence (see "User gate — round 3" below) |
|
||||
| CH6b floating windows 1–4 | `22020ef2`, reworked `1aa77099` | 12,420 passed / 4 skipped / 0 failed | REJECT (docs/research/2026-08-10-ch6ab-review-findings.md) → reworked `1aa77099` — SHOULD-FIXES 2/3/4/5 + NITs 1-5 applied | pending — no client launches this session (hard constraint); needs the next connected round for keybind/mirror/filter visual confirmation, plus the new 0x2100005B fixture's resolved-type assumptions |
|
||||
| CH6c opacity | `a819687c` | 12,459 passed / 4 skipped / 0 failed | APPROVE-WITH-FIXES; fixed this commit — BLOCKER (out-of-box `DefaultOpacity` 0.5→1.0), AP-190 reworded + two new decomp-verified clauses (retail's per-tick ease, retail's entry-field-specific focus predicate), NITs (`UiElement.cs` stale comment, `WindowUnregistered` detach, post-Dispose `Set*` guards, `DrawString`/outline-pass alpha tests) | pending — needs the next connected round for visual confirmation (window fade on focus change, Settings slider live-apply) |
|
||||
| Goal-window #363/#367 interface-text seam | `e822888a` | 12,542 passed / 4 skipped / 0 failed | implementer-only, no subagent review this session (hard constraint) | pending — needs the next connected round to confirm the SpewBox now flashes for the reclassified refusals (see "Goal-window follow-up" below) |
|
||||
|
||||
### CH4 closeout (2026-08-09)
|
||||
|
||||
|
|
@ -855,3 +856,87 @@ entirely against the FIX list, not the research.
|
|||
Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
|
||||
12,392/4/0 at `22020ef2`; net +28 tests, all new coverage from this
|
||||
rework, zero regressions).
|
||||
|
||||
## Goal-window follow-up — #363/#367 interface-text seam (2026-08-10)
|
||||
|
||||
Closed issues #363 and #367 (register rows AP-183 and AP-186, both
|
||||
RETIRED) under the goal-window's hard constraints: no subagents, no
|
||||
client launches, one commit.
|
||||
|
||||
`ChatVM` (`AcDream.UI.Abstractions`) gained an `OnInterfaceText`
|
||||
(`Action<string>?`) init property and a `ShowInterfaceText(text)` method —
|
||||
the seam #367's own filing proposed as fix shape (a). The App-layer
|
||||
composition (`InteractionRetainedUiComposition.CreateRetainedUi`) wires it
|
||||
to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`,
|
||||
the same SpewBox chokepoint every other interface-text producer (item
|
||||
examine, magic feedback, the portal wait cue) already uses. Unwired hosts
|
||||
(headless has no `ChatVM` at all; plain test fixtures) fall back to the
|
||||
ordinary chat log tagged `ClientLocal`, so no text is ever silently
|
||||
dropped — only the SURFACE degrades, never the message.
|
||||
|
||||
Every site AP-183 named now routes through the seam at its correct retail
|
||||
type, cross-checked against the named-retail decomp (5 handlers spot-
|
||||
verified: `DoDie`, `DoChannelList`/`On`/`Off`, `DoAllegiance`,
|
||||
`DoHouseAvailableList` — all `0x1A`):
|
||||
|
||||
- `DoStupidChannelHack` ("You must specify the text you wish to say!") —
|
||||
**newly wired**, not merely reclassified: the six legacy channel verbs
|
||||
(Fellowship/Allegiance/AllegianceBroadcast/Vassals/Patron/CoVassals)
|
||||
previously fell through `ChatInputParser.Parse`'s pure `return null`
|
||||
shape with NO message shown at all. New `ChatInputParser
|
||||
.IsBareRegisteredChannelVerb` predicate (pure, no side effects) plus a
|
||||
`ChatCommandRouter.Submit` check ahead of `Parse`.
|
||||
- `DoChannelList`/`DoChannelOn`/`DoChannelOff` ("Please specify the
|
||||
channel name.") — reclassified (was already wired via
|
||||
`InvalidArgumentsText`, just typed `0x00`).
|
||||
- `DoAllegiance` ("Please see @help Allegiance...") — reclassified, same
|
||||
shape.
|
||||
- `DoHouseAvailableList` — reclassified AND corrected: retail's own
|
||||
bad-house-type text is "Please see @help hslist for more information on
|
||||
how to use this command" (`acclient_2013_pseudo_c.txt:381481`/`1029383`,
|
||||
`AddTextToScroll(..., 0x1a, ...)`), not the acdream-synthesized "Usage:
|
||||
/hslist <house type>" line the catalog fell back to.
|
||||
- `DoReply` ("Someone must @tell you first!") — **newly wired** for the
|
||||
message-but-no-last-teller branch only (`gmCCommunicationSystem
|
||||
::GetLastTeller() == 0`). New `ChatInputParser.IsReplyMissingLastTeller`
|
||||
predicate. Bare `/r` with no message at all is retail's OWN separate
|
||||
copy of the "you must specify text" string (a different call site) and
|
||||
is deliberately still unported — not named by AP-183, out of scope.
|
||||
- `DoSpeaker`/`DoEndurance`/`DoTitle` — untouched, confirmed still correct
|
||||
at `0x00` (their text is produced by `ClientCommandController`, not
|
||||
`ChatCommandRouter`).
|
||||
|
||||
The generic bad-args fallback is also fixed: `ChatCommandRouter.Submit`'s
|
||||
catalog dispatch now resolves `WeenieErrorMessages.Resolve(0x026u, null)`
|
||||
("That is not a valid command.", the exact port of `DoCommand
|
||||
@0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing
|
||||
`"Usage: {Usage}"` — the row already carried the `0x026` template from
|
||||
CH2's `HandleFailureEvent` port, so no new retail text needed extracting.
|
||||
|
||||
This closes #367 too: `ChatCommandRouter`'s two other local-presentation
|
||||
fallbacks (`RetailCommandHelpTable.UnknownCommand` in `EmitVerbHelp`, and
|
||||
the degenerate-prefix "Unknown command: {verb}." refusal in `Submit`'s
|
||||
main body) now call `ShowInterfaceText` instead of `ShowSystemMessage`.
|
||||
The test-script doc (`docs/research/2026-08-09-campaign-ch-test-script.md`)
|
||||
is updated to drop the "known, tracked gap" notes for both — the next
|
||||
connected round should observe both fallbacks flash on the SpewBox instead
|
||||
of landing in the chat window.
|
||||
|
||||
Tests: per-site routing pinned both ways (seam wired → reaches the
|
||||
SpewBox capture; seam unwired → falls back to chat, tagged `ClientLocal`)
|
||||
for every reclassified/newly-wired site in
|
||||
`tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`,
|
||||
plus a Turbine-only-channel negative case and a 0x00-site-stays-in-chat
|
||||
sanity check; pure-predicate coverage for `IsBareRegisteredChannelVerb`/
|
||||
`IsReplyMissingLastTeller` in `ChatInputParserTests.cs`; the seam itself
|
||||
in `ChatVMRetellAndProvidersTests.cs`.
|
||||
|
||||
Full Release suite: 12,542 passed / 4 skipped / 0 failed (baseline 12,466/4/0
|
||||
at `ff2784ea`; net +76 tests, all new coverage from this follow-up, zero
|
||||
regressions). No subagent review this session (hard constraint); no
|
||||
connected user gate (hard constraint — no client launches). The next
|
||||
connected round should confirm: bare `/g`/`/a`/etc. now flash the SpewBox
|
||||
red instead of doing nothing; `/hslist badtype` and a bad-args catalog
|
||||
command (e.g. `/ls now`) now flash red SpewBox text instead of showing a
|
||||
green "Usage:" line in chat; `/help nonsenseverb` and a bare `/`/`@` now
|
||||
flash on the SpewBox instead of appearing in the chat scroll.
|
||||
|
|
|
|||
|
|
@ -73,10 +73,33 @@ Side-by-side vs retail if possible:
|
|||
- `/help death` → the SAME "Note:" line first, then a SECOND line starting
|
||||
"For more information, type @help <command>." immediately followed by
|
||||
the 8-line corpse/death command listing — not just the 8 lines alone.
|
||||
- `/help somenonsenseverb` → "Unknown command" (no "Note:" line before it;
|
||||
retail's fallback skips the wrapper). This still shows in the CHAT
|
||||
window rather than the SpewBox — a known, tracked gap (#367/AP-186), not
|
||||
a new bug to report.
|
||||
- `/help somenonsenseverb` → **goal-window fix (2026-08-10, #367 closed):**
|
||||
"Unknown command" now flashes on the SpewBox (top of viewport), NOT the
|
||||
chat window — the SAME surface as section 1's "jump in the air" refusal.
|
||||
Chat gets no line at all.
|
||||
|
||||
## 6. Goal-window fix — SpewBox refusal typing (2026-08-10, #363/#367)
|
||||
|
||||
- `/g` (or `/f`/`/a`/`/m`/`/p`/`/v`/`/c` etc.) with NO message → **"You
|
||||
must specify the text you wish to say!"** flashes on the SpewBox.
|
||||
Previously this silently did nothing.
|
||||
- `/r hi there` (or `/reply`) with no prior incoming Tell → **"Someone
|
||||
must @tell you first!"** flashes on the SpewBox. Previously silent.
|
||||
- A bad-args catalog command, e.g. `/ls now` or `/marketplace foo` → **"That
|
||||
is not a valid command."** flashes on the SpewBox. Previously showed a
|
||||
green "Usage: /lifestone" line in CHAT — both the color/surface AND the
|
||||
wording changed.
|
||||
- `/hslist badtype` → **"Please see @help hslist for more information on
|
||||
how to use this command"** flashes on the SpewBox. Previously showed a
|
||||
green "Usage: /hslist <house type>" line in CHAT.
|
||||
- `/clist`/`/on`/`/off` with no channel name, and `@allegiance boot Bob`
|
||||
from section 4 above — SAME text as before, but now flash on the
|
||||
SpewBox instead of showing in chat.
|
||||
- A bare `/` or `@x` (no letter verb) and `/help somenonsenseverb` (section
|
||||
5) both now flash "Unknown command[: x]" on the SpewBox too.
|
||||
- Sanity: `/endurance`, `@version`, `@loc` and other informational command
|
||||
output must STILL show in chat (green/default), not the SpewBox — only
|
||||
genuine refusals moved.
|
||||
|
||||
## Known-open, do not report as new
|
||||
|
||||
|
|
@ -84,9 +107,6 @@ Side-by-side vs retail if possible:
|
|||
- SpewBox line lifetime is still a placeholder (AP-177) pending a retail
|
||||
measurement session; position/font are now best-available
|
||||
approximations (AP-178), not confirmed retail pixel values.
|
||||
- `/help <verb>` fallback ("Unknown command") shows in the chat window,
|
||||
not the SpewBox — retail types it for the SpewBox exclusively, but
|
||||
`ChatCommandRouter` has no path there yet (#367/AP-186).
|
||||
- Allegiance management subcommands print the help refusal instead of
|
||||
executing (#360); `@day`/`@log`/`@render` deferred (#361); four request
|
||||
commands send but responses aren't rendered yet (#362).
|
||||
commands send but responses aren't rendered yet (#362, already closed).
|
||||
|
|
|
|||
|
|
@ -562,7 +562,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
var chat = new ChatVM(
|
||||
d.Communication.Chat,
|
||||
displayLimit: 200,
|
||||
commandTargets: d.Communication.CommandTargets);
|
||||
commandTargets: d.Communication.CommandTargets)
|
||||
{
|
||||
// Issue #363 / #367: routes ChatCommandRouter's 0x1A
|
||||
// (ClientLocal) command refusals to the same SpewBox
|
||||
// chokepoint every other interface-text producer uses,
|
||||
// instead of the chat scroll.
|
||||
OnInterfaceText = text =>
|
||||
d.Communication.AddText(text, RetailLogTextType.ClientLocal),
|
||||
};
|
||||
AcDream.UI.Abstractions.Panels.Settings.SettingsStore? layoutStore =
|
||||
d.Settings.LayoutStore;
|
||||
RetailUiPersistenceBindings? persistence = layoutStore is null
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
|
|
@ -51,8 +52,23 @@ public static class ChatCommandRouter
|
|||
{
|
||||
if (!clientCommand.HasValidArguments)
|
||||
{
|
||||
vm.ShowSystemMessage(clientCommand.InvalidArgumentsText
|
||||
?? $"Usage: {clientCommand.Usage}");
|
||||
// #363 / register row AP-183: retail's bad-args refusal is
|
||||
// ALWAYS 0x1A (ClientLocal / SpewBox-only) — verified
|
||||
// against five decompiled handlers (DoDie, DoChannelList/
|
||||
// On/Off, DoAllegiance, DoHouseAvailableList). A Definition
|
||||
// with its own InvalidArgumentsText is the handler's own
|
||||
// bespoke refusal string, printed before it returns
|
||||
// "handled" (1) so retail's generic fallback never fires
|
||||
// for it. A Definition with none falls to that generic
|
||||
// fallback: ClientCommunicationSystem::DoCommand
|
||||
// @0x0057E46D calls HandleFailureEvent(0x26) when a
|
||||
// registered handler returns 0 (bad args) —
|
||||
// "That is not a valid command." (WeenieErrorMessages
|
||||
// [0x026]) — never the acdream-invented "Usage: {Usage}"
|
||||
// line this branch used to synthesize.
|
||||
vm.ShowInterfaceText(clientCommand.InvalidArgumentsText
|
||||
?? WeenieErrorMessages.Resolve(0x026u, null).Text
|
||||
?? "That is not a valid command.");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
|
|
@ -66,10 +82,14 @@ public static class ChatCommandRouter
|
|||
|
||||
// Command-shaped but no letter verb ("/", "//shrug", "@ x"):
|
||||
// refuse locally rather than putting junk on the wire or in speech.
|
||||
// #363/#367: this is one of retail's DoHelp-family "Unknown
|
||||
// command" fallbacks (0x1A ClientLocal, SpewBox-only) — routed
|
||||
// through the interface-text seam now that one exists, instead of
|
||||
// the chat scroll.
|
||||
if (trimmed[0] is '/' or '@'
|
||||
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
|
||||
{
|
||||
vm.ShowSystemMessage(
|
||||
vm.ShowInterfaceText(
|
||||
$"Unknown command: {ChatInputParser.GetVerbToken(trimmed)}. Type /help for the list of supported commands.");
|
||||
return SubmitOutcome.UnknownCommand;
|
||||
}
|
||||
|
|
@ -97,6 +117,23 @@ public static class ChatCommandRouter
|
|||
return SubmitOutcome.Sent;
|
||||
}
|
||||
|
||||
// #363 / register row AP-183: retail's registered legacy-channel
|
||||
// verbs (DoStupidChannelHack @0x0057B144) and /reply
|
||||
// (DoReply @0x00577910) refuse locally at 0x1A instead of silently
|
||||
// dropping the line the way ChatInputParser.Parse's pure "return
|
||||
// null" shape does for these cases.
|
||||
if (ChatInputParser.IsBareRegisteredChannelVerb(trimmed))
|
||||
{
|
||||
vm.ShowInterfaceText("You must specify the text you wish to say!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
if (ChatInputParser.IsReplyMissingLastTeller(trimmed, vm.LastIncomingTellSender))
|
||||
{
|
||||
vm.ShowInterfaceText("Someone must @tell you first!");
|
||||
return SubmitOutcome.ClientHandled;
|
||||
}
|
||||
|
||||
var parsed = ChatInputParser.Parse(
|
||||
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
|
||||
if (parsed is { } chat)
|
||||
|
|
@ -247,12 +284,10 @@ public static class ChatCommandRouter
|
|||
return;
|
||||
}
|
||||
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only; ChatVM has
|
||||
// no SpewBox routing capability yet, so this still renders via the
|
||||
// chat scroll — a pre-existing gap, not new this round. See the
|
||||
// class remarks on RetailCommandHelpTable.UnknownCommand and
|
||||
// ISSUES.md #367.
|
||||
vm.ShowSystemMessage(RetailCommandHelpTable.UnknownCommand);
|
||||
// Retail types this 0x1A (ClientLocal) -> SpewBox-only. #363/#367:
|
||||
// now routed through ChatVM.ShowInterfaceText instead of the chat
|
||||
// scroll — see the class remarks on RetailCommandHelpTable.UnknownCommand.
|
||||
vm.ShowInterfaceText(RetailCommandHelpTable.UnknownCommand);
|
||||
}
|
||||
|
||||
private static bool EqAny(string value, params string[] options)
|
||||
|
|
|
|||
|
|
@ -124,6 +124,64 @@ public static class ChatInputParser
|
|||
("/o", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The six retail channel groups whose registered-verb handler is
|
||||
/// <c>ClientCommunicationSystem::DoStupidChannelHack @0x0057B144</c>
|
||||
/// (command-registry doc §2.3) — the legacy 0x0147 channels. The
|
||||
/// Turbine-only channels (General/Trade/Lfg/Roleplay/Society/Olthoi)
|
||||
/// are never this handler's clients. <see cref="ChatChannelKind.Allegiance"/>
|
||||
/// (retail's <c>@a</c>) still belongs here even though acdream always
|
||||
/// sends it over Turbine once connected (<see cref="ChatChannelKind"/>'s
|
||||
/// own doc comment) — DoStupidChannelHack dispatches on the REGISTERED
|
||||
/// VERB, before Turbine send-time routing decides where the text goes;
|
||||
/// retail's <c>"a"</c>/<c>"ab"</c> are one registered verb group.
|
||||
/// </summary>
|
||||
private static readonly HashSet<ChatChannelKind> LegacyChannelHackKinds =
|
||||
[
|
||||
ChatChannelKind.Fellowship,
|
||||
ChatChannelKind.Allegiance,
|
||||
ChatChannelKind.AllegianceBroadcast,
|
||||
ChatChannelKind.Vassals,
|
||||
ChatChannelKind.Patron,
|
||||
ChatChannelKind.Monarch,
|
||||
ChatChannelKind.CoVassals,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="trimmed"/> is one of the
|
||||
/// <see cref="LegacyChannelHackKinds"/> verbs with NO message — retail's
|
||||
/// <c>DoStupidChannelHack</c> "You must specify the text you wish to
|
||||
/// say!" refusal (<c>0x1A</c> ClientLocal,
|
||||
/// acclient_2013_pseudo_c.txt:1030730, data_7da9b0). This parser stays
|
||||
/// pure (no side effects — <see cref="Parse"/> just returns
|
||||
/// <see langword="null"/> for this shape); <see cref="ChatCommandRouter"/>
|
||||
/// owns the actual refusal text and routes it to the SpewBox.
|
||||
/// </summary>
|
||||
public static bool IsBareRegisteredChannelVerb(string trimmed)
|
||||
{
|
||||
foreach (var (verb, channel) in ChannelVerbs)
|
||||
{
|
||||
if (LegacyChannelHackKinds.Contains(channel) && IsBareVerb(trimmed, [verb]))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="trimmed"/> is <c>/reply</c>/<c>/r</c>/
|
||||
/// <c>/rp</c> WITH a message but no prior incoming Tell — retail's
|
||||
/// <c>ClientCommunicationSystem::DoReply @0x00577910</c> "Someone must
|
||||
/// @tell you first!" refusal (<c>0x1A</c> ClientLocal,
|
||||
/// acclient_2013_pseudo_c.txt:387538, data_7da974), the
|
||||
/// <c>gmCCommunicationSystem::GetLastTeller() == 0</c> branch. Bare
|
||||
/// <c>/r</c> with no message at all is a DIFFERENT retail branch (its
|
||||
/// own copy of the "you must specify text" string) and is deliberately
|
||||
/// out of scope here — not named by register row AP-183 / issue #363.
|
||||
/// </summary>
|
||||
public static bool IsReplyMissingLastTeller(string trimmed, string? lastTellSender) =>
|
||||
string.IsNullOrEmpty(lastTellSender) && TryParseMessageOnly(trimmed, ReplyAliases, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="raw"/> into a <see cref="ParsedInput"/>
|
||||
/// triple, or <c>null</c> if the input is empty / whitespace /
|
||||
|
|
|
|||
|
|
@ -73,6 +73,21 @@ public sealed class ChatVM : IDisposable
|
|||
/// </summary>
|
||||
public Func<Vector3>? PositionProvider { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional hook routing retail-<c>0x1A</c> (<see
|
||||
/// cref="RetailLogTextType.ClientLocal"/>) interface text — command
|
||||
/// refusals and bad-argument usage lines — to the SpewBox instead of
|
||||
/// the chat transcript. <c>AcDream.UI.Abstractions</c> must stay
|
||||
/// Runtime-independent (Code Structure Rules), so it cannot call
|
||||
/// <c>RuntimeCommunicationState.AddText</c> directly; the App-layer
|
||||
/// composition host wires this the same way it wires
|
||||
/// <see cref="FpsProvider"/>/<see cref="PositionProvider"/>. Closes
|
||||
/// ISSUES.md #367 / register row AP-186 — <see cref="ChatCommandRouter"/>
|
||||
/// no longer has to render every <c>0x1A</c> refusal through the chat
|
||||
/// scroll.
|
||||
/// </summary>
|
||||
public Action<string>? OnInterfaceText { get; init; }
|
||||
|
||||
/// <summary>Monotonic revision of the underlying transcript content.</summary>
|
||||
public long Revision => _log.Revision;
|
||||
|
||||
|
|
@ -139,6 +154,33 @@ public sealed class ChatVM : IDisposable
|
|||
/// </remarks>
|
||||
public void ShowSystemMessage(string text) => _log.OnSystemMessage(text, chatType: 0x00u);
|
||||
|
||||
/// <summary>
|
||||
/// Route a retail-<c>0x1A</c> (<see cref="RetailLogTextType.ClientLocal"/>)
|
||||
/// command refusal / usage line to the SpewBox — retail's
|
||||
/// <c>ClientSystem::AddTextToScroll(text, 0x1A, 1, windowId) @0x00563C50</c>
|
||||
/// destination for this text type is the SpewBox exclusively, never a
|
||||
/// chat window (<c>docs/research/2026-08-09-chat-retail-interface-text.md</c>
|
||||
/// §2.1/§2.2).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Prefers <see cref="OnInterfaceText"/> when the App-layer host wired
|
||||
/// it (the production graphical client). When unwired — headless, the
|
||||
/// automation probe runner, or a test fixture that only exercises the
|
||||
/// pure UI.Abstractions layer — the text still needs to reach the
|
||||
/// player somewhere, so it falls back to the ordinary chat transcript
|
||||
/// tagged with the real <see cref="RetailLogTextType.ClientLocal"/>
|
||||
/// color rather than being silently dropped. That fallback lands in
|
||||
/// the wrong PANEL (chat instead of SpewBox) but keeps the right TYPE
|
||||
/// and never loses the line — the safe default issue #363 requires.
|
||||
/// </remarks>
|
||||
public void ShowInterfaceText(string text)
|
||||
{
|
||||
if (OnInterfaceText is { } hook)
|
||||
hook(text);
|
||||
else
|
||||
_log.OnSystemMessage(text, chatType: (uint)RetailLogTextType.ClientLocal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drain the chat log. Used by the /clear client-side command.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -346,11 +346,18 @@ public static class RetailClientCommandCatalog
|
|||
|
||||
// ClientCommunicationSystem::DoHouseAvailableList @ 0x00570510. Exact
|
||||
// retail help: acclient_2013_pseudo_c.txt:1031049 (data_7dd9d0).
|
||||
// #363 / register row AP-183: the bad-args refusal is retail's own
|
||||
// specific string too, verified at acclient_2013_pseudo_c.txt:381481
|
||||
// (AddTextToScroll(..., 0x1a, ...)), full text at
|
||||
// acclient_2013_pseudo_c.txt:1029383 (data_7d0a98) — NOT the
|
||||
// acdream-synthesized "Usage: /hslist <house type>" line this
|
||||
// Definition used to fall back to.
|
||||
private static readonly Definition HouseAvailableList = new(
|
||||
ClientCommandId.HouseAvailableList,
|
||||
Usage: "/hslist <house type>",
|
||||
HelpText: "@hslist <house type> - Lists the number and, if appropriate, positions of houses currently available for purchase. Types include: Apartment, Cottage, Villa, Mansion",
|
||||
ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim()));
|
||||
ValidateArguments: static arguments => HouseTypes.ContainsKey(arguments.Trim()),
|
||||
InvalidArgumentsText: "Please see @help hslist for more information on how to use this command");
|
||||
|
||||
// ClientCommunicationSystem::DoChannelIndex @ 0x0056E640. No help
|
||||
// string was extracted for the bare form; the verb is admin/advocate/
|
||||
|
|
|
|||
|
|
@ -91,14 +91,17 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
|||
/// <see cref="UnknownCommand"/>, typed <c>0x1A</c> (<c>ClientLocal</c>) —
|
||||
/// retail routes that type to the SpewBox exclusively, never the chat
|
||||
/// window (<c>docs/research/2026-08-09-chat-retail-interface-text.md</c>
|
||||
/// §2.1/§2.2). <c>ChatCommandRouter</c> operates on <c>ChatVM</c>
|
||||
/// (<c>AcDream.UI.Abstractions</c>), which has no SpewBox routing
|
||||
/// capability — wiring that would mean threading a
|
||||
/// <c>RuntimeCommunicationState</c>-shaped dependency down into a layer
|
||||
/// that must stay presentation/Runtime-independent, out of this round's
|
||||
/// scope. The unknown-verb fallback therefore still renders via the chat
|
||||
/// scroll, a pre-existing (not newly introduced) gap now tracked at
|
||||
/// ISSUES.md #367 instead of silently continuing unregistered.
|
||||
/// §2.1/§2.2).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Issue #363 (2026-08-10):</b> <c>ChatCommandRouter</c> now routes this
|
||||
/// fallback (and every other <c>0x1A</c> command-refusal call site) through
|
||||
/// <c>ChatVM.ShowInterfaceText</c> — an optional hook the App-layer host
|
||||
/// wires to <c>RuntimeCommunicationState.AddText</c>, the same SpewBox
|
||||
/// chokepoint every other producer of interface text uses. UI.Abstractions
|
||||
/// still never references Runtime directly (Code Structure Rules); the hook
|
||||
/// is the seam. Closes ISSUES.md #367 and retires register row AP-186.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RetailCommandHelpTable
|
||||
|
|
@ -147,9 +150,9 @@ public static class RetailCommandHelpTable
|
|||
// acclient_2013_pseudo_c.txt:395052 (u"Unknown command", UTF-16LE) --
|
||||
// DoHelp's fallback when the verb hash lookup fails, or resolves to an
|
||||
// entry with no registered help callback. Retail types this 0x1A
|
||||
// (ClientLocal) -- SpewBox-only; see the class remarks' routing note
|
||||
// and ISSUES.md #367 for why ChatCommandRouter still shows it in the
|
||||
// chat scroll.
|
||||
// (ClientLocal) -- SpewBox-only; see the class remarks' routing note --
|
||||
// ChatCommandRouter now routes it through ChatVM.ShowInterfaceText
|
||||
// (issue #363), closing #367.
|
||||
public const string UnknownCommand = "Unknown command";
|
||||
|
||||
// @mr/@pr are registered with a NULL function pointer in the 2013
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue