From 5a45a7ac7f307aa9c720e5482e8e54521d2f9df6 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Jul 2026 11:43:19 +0200 Subject: [PATCH] feat(chat): route retail lifestone commands Separate retail client actions, ACE server commands, and ordinary chat at the shared router. Port lifestone/lif/ls from the named retail registry through a typed App controller to game action 0x0063, keep unknown verbs on ACE Talk, and cover both UI backends plus exact outbound bytes. Co-Authored-By: Codex --- docs/ISSUES.md | 37 ++++++ docs/architecture/acdream-architecture.md | 6 + .../retail-divergence-register.md | 6 +- ...etail-client-command-routing-pseudocode.md | 110 ++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 9 ++ src/AcDream.App/UI/ClientCommandController.cs | 36 ++++++ src/AcDream.Core.Net/WorldSession.cs | 10 ++ .../ClientCommandId.cs | 11 ++ .../ExecuteClientCommandCmd.cs | 8 ++ .../Panels/Chat/ChatCommandRouter.cs | 120 +++++++++++++----- .../Panels/Chat/ChatInputParser.cs | 10 +- .../Panels/Chat/RetailClientCommandCatalog.cs | 90 +++++++++++++ .../SendServerCommandCmd.cs | 8 ++ .../UI/ClientCommandControllerTests.cs | 28 ++++ .../UI/Layout/ChatWindowControllerTests.cs | 15 +++ .../WorldSessionChatTests.cs | 13 ++ .../Panels/Chat/ChatCommandRouterTests.cs | 88 +++++++++---- .../Panels/Chat/ChatPanelInputTests.cs | 39 ++++-- .../Chat/RetailClientCommandCatalogTests.cs | 38 ++++++ 19 files changed, 604 insertions(+), 78 deletions(-) create mode 100644 docs/research/2026-07-13-retail-client-command-routing-pseudocode.md create mode 100644 src/AcDream.App/UI/ClientCommandController.cs create mode 100644 src/AcDream.UI.Abstractions/ClientCommandId.cs create mode 100644 src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs create mode 100644 src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs create mode 100644 src/AcDream.UI.Abstractions/SendServerCommandCmd.cs create mode 100644 tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7313b8ba..3d9eb6cb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,6 +46,43 @@ Copy this block when adding a new issue: --- +## #213 — Retail client commands were sent to ACE as chat text + +**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending live gate +**Severity:** MEDIUM +**Component:** retained UI / chat commands / net + +**Description:** Retail chat-bar commands such as `/ls` were treated as unknown +ACE commands. The client rewrote `/ls` to `@ls` and sent it through Talk, so ACE +reported an unknown command instead of recalling the character. + +**Root cause:** The shared chat router had only two outcomes: local +presentation commands and `SendChatCmd`. It therefore conflated retail commands +that send typed game actions with ACE administrator commands that are consumed +as Talk text. + +**Resolution:** Added an immutable named-retail client-command catalog and +three explicit routes: typed retail client action, ACE server command, and +ordinary chat. `/lifestone`, `/lif`, and `/ls` now publish +`ExecuteClientCommandCmd`; the App-layer `ClientCommandController` invokes +`WorldSession.SendTeleportToLifestone`, which sends retail game action `0x0063`. +Unknown `/` commands publish `SendServerCommandCmd` in canonical `@` form, so +commands such as `/ci` continue to reach ACE. Both chat backends use the same +router. + +**Research:** +`docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; +`ClientCommunicationSystem::OnChatCommand @ 0x00581320`; +`ClientCommunicationSystem::DoLifestone @ 0x0056FC70`; +`CM_Character::Event_TeleToLifestone @ 0x006A1B90`. + +**Acceptance:** In the connected Release client, `/ls`, `/lif`, and +`/lifestone` begin recall to the bound lifestone without appearing as speech or +an ACE unknown-command error. `/ls now` shows local usage and sends nothing. +An ACE command such as `/ci 629 1` still works. + +--- + ## #212 — Toolbar shortcut numbers turn gray in physical combat **Status:** IN-PROGRESS — implementation complete 2026-07-13, pending user gate diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index befe11d3..2a167f0c 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -100,6 +100,12 @@ history and the corrected contract live in and the `IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the retained gameplay tree, LayoutDesc importer, window runtime, and panel controllers. Neither presentation stack owns independent game-state truth. +Chat submission follows the same rule: both presentation stacks enter the +shared `ChatCommandRouter`, which emits distinct backend-neutral intents for a +retail client command (`ExecuteClientCommandCmd`), an ACE-owned command +(`SendServerCommandCmd`), or ordinary chat (`SendChatCmd`). App-layer +handlers and controllers translate those intents to `WorldSession`; panels +never inspect or construct wire messages. Plugins register retained gameplay markup through the BCL-only `AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui assemblies. Core `SelectionState` is the sole selected-object owner for world, diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6ab3a371..905ef832 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -107,7 +107,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 96 active rows +## 3. Documented approximation (AP) — 97 active rows Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -215,8 +215,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | | AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The M2 attack contract and authored basic panel are live; the remaining seams require the jump/movement command owner and a distinct Recklessness treatment rather than UI-local guesses | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | +| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | -## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) +## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -261,6 +262,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` | | ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) | | TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port | +| TS-47 | The typed retail client-command catalog currently ports only `lifestone`/`lif`/`ls`; all other retail-owned command verbs fall through to ACE as server commands until ported one family at a time | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs` | The catalog/router/controller architecture is the first vertical slice; each remaining family needs its own named-decomp pseudocode, typed action, and conformance tests rather than a guessed bulk alias list | Retail commands such as `/marketplace`, `/house`, or `/friends` may produce ACE unknown-command output or server-specific behavior instead of the retail client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md` | --- diff --git a/docs/research/2026-07-13-retail-client-command-routing-pseudocode.md b/docs/research/2026-07-13-retail-client-command-routing-pseudocode.md new file mode 100644 index 00000000..336856eb --- /dev/null +++ b/docs/research/2026-07-13-retail-client-command-routing-pseudocode.md @@ -0,0 +1,110 @@ +# Retail client-command routing and lifestone recall + +Date: 2026-07-13 + +## Scope + +This note records the retail path used for chat-bar commands and the first +ported client command, `@lifestone` (`@lif`, `@ls`). It deliberately separates +commands the retail client executes from ACE administrator commands that the +server consumes as Talk text. + +## Named-retail sources + +- `ChatInterface::ProcessCommand @ 0x004F5100` +- `ClientCommunicationSystem::OnChatCommand @ 0x00581320` +- `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` +- `ClientCommunicationSystem::HelpLifestone @ 0x00578500` +- `CM_Character::Event_TeleToLifestone @ 0x006A1B90` +- Command-table construction at `0x005838F7` through `0x00583A2B` +- Alias data at `0x007E17E4` (`ls`), `0x007E17E8` (`lif`), and + `0x007E17EC` (`lifestone`) + +Source file: +`docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +## Cross-references + +- ACE consumes server/admin commands such as `@ci` from the Talk game action. + That is a different path from retail client commands. +- acdream already has the exact lifestone action builder in + `AcDream.Core.Net.Messages.InteractRequests.BuildTeleToLifestone`. +- holtburger's chat parser is useful for chat aliases, but the named retail + command registry is authoritative for client-command ownership and aliases. + +## Retail pseudocode + +### Chat submission + +```text +ChatInterface.ProcessCommand(): + text = read chat input + communicationSystem.OnChatCommand(text, currentCommandSource) + append text to input history +``` + +```text +ClientCommunicationSystem.OnChatCommand(text, source): + allow plugin to inspect/replace the input + remember the current source and trimmed command line + + if first character is '/': + replace it with '@' + return DoCommand() + + if first character is '@': + return DoCommand() + + if first character denotes an emote shortcut: + rewrite to '@emote ...' + return DoCommand() + + otherwise: + route as ordinary speech according to the current talk focus +``` + +### Lifestone command registration + +```text +register "lifestone" -> DoLifestone, HelpLifestone +register "lif" -> DoLifestone, HelpLifestone +register "ls" -> DoLifestone, HelpLifestone +``` + +The registry uses case-insensitive command keys. + +### Lifestone command execution + +```text +ClientCommunicationSystem.DoLifestone(argumentCount, arguments): + if argumentCount == 0: + CM_Character.Event_TeleToLifestone() + else: + add the command's usage/error text to the chat scroll + return handled +``` + +```text +CM_Character.Event_TeleToLifestone(): + sequence = Proto_UI.GetNextUICounter() + packet = OrderHdr(gameActionEnvelope, sequence, payloadLength = 12) + packet.action = 0x0063 + sent = Proto_UI.SendToWeenie(packet) + if not sent: + Proto_UI.UICounterFailedSend(sequence) + return sent +``` + +## Port contract + +1. Both `/` and `@` prefixes are accepted for retail client commands. +2. `lifestone`, `lif`, and `ls` match case-insensitively. +3. The command accepts no arguments. Invalid arguments display usage locally + and send neither chat nor a game action. +4. A valid command publishes a typed client action. The App layer executes it + through `WorldSession`; UI code never touches the network session. +5. Unknown command verbs remain server commands: `/foo` is canonicalized to + `@foo` and sent through Talk so ACE remains authoritative for its command + set. +6. The wire body is the existing 12-byte game-action envelope with opcode + `0x0063`; no command text is sent for lifestone recall. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e03f0abc..91d06d8c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2716,6 +2716,15 @@ public sealed class GameWindow : IDisposable _commandBus = new AcDream.UI.Abstractions.LiveCommandBus(); var turbineChat = TurbineChat; uint playerGuid = _playerServerGuid; + var clientCommandController = new AcDream.App.UI.ClientCommandController( + liveSession.SendTeleportToLifestone); + _commandBus.Register( + clientCommandController.Execute); + _commandBus.Register(cmd => + { + if (!string.IsNullOrEmpty(cmd.Text)) + liveSession.SendTalk(cmd.Text); + }); _commandBus.Register(cmd => { if (string.IsNullOrEmpty(cmd.Text)) return; diff --git a/src/AcDream.App/UI/ClientCommandController.cs b/src/AcDream.App/UI/ClientCommandController.cs new file mode 100644 index 00000000..b4c64a23 --- /dev/null +++ b/src/AcDream.App/UI/ClientCommandController.cs @@ -0,0 +1,36 @@ +using AcDream.UI.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Application-layer executor for typed retail client commands. Chat panels +/// remain backend- and network-agnostic; this controller translates their +/// intent to the live-session action supplied by the composition root. +/// +public sealed class ClientCommandController +{ + private readonly Action _teleportToLifestone; + + public ClientCommandController(Action teleportToLifestone) + { + _teleportToLifestone = teleportToLifestone + ?? throw new ArgumentNullException(nameof(teleportToLifestone)); + } + + public void Execute(ExecuteClientCommandCmd command) + { + ArgumentNullException.ThrowIfNull(command); + + switch (command.Command) + { + // Retail: ClientCommunicationSystem::DoLifestone @ 0x0056FC70 + // -> CM_Character::Event_TeleToLifestone @ 0x006A1B90. + case ClientCommandId.LifestoneRecall: + _teleportToLifestone(); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(command), command.Command, "Unknown retail client command."); + } + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index d05dbee2..bcd4d17c 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1262,6 +1262,16 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } + /// + /// Send retail lifestone recall (0x0063). Retail source: + /// CM_Character::Event_TeleToLifestone @ 0x006A1B90. + /// + public void SendTeleportToLifestone() + { + uint seq = NextGameActionSequence(); + SendGameAction(InteractRequests.BuildTeleToLifestone(seq)); + } + /// Send retail ChangeCombatMode (0x0053). public void SendChangeCombatMode(CombatMode mode) { diff --git a/src/AcDream.UI.Abstractions/ClientCommandId.cs b/src/AcDream.UI.Abstractions/ClientCommandId.cs new file mode 100644 index 00000000..55235acf --- /dev/null +++ b/src/AcDream.UI.Abstractions/ClientCommandId.cs @@ -0,0 +1,11 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Backend-neutral identity for a command that retail executes in the client +/// instead of sending as chat text. +/// +public enum ClientCommandId +{ + /// Recall to the character's bound lifestone. + LifestoneRecall, +} diff --git a/src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs b/src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs new file mode 100644 index 00000000..b5e95079 --- /dev/null +++ b/src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs @@ -0,0 +1,8 @@ +namespace AcDream.UI.Abstractions; + +/// +/// User intent to execute a retail client command. The application host owns +/// the corresponding game-state or network action; UI backends only publish +/// this record. +/// +public sealed record ExecuteClientCommandCmd(ClientCommandId Command, string Arguments); diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs index 81034765..14ce9c58 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs @@ -3,26 +3,23 @@ using System; namespace AcDream.UI.Abstractions.Panels.Chat; /// What a submit did, so the caller can clear its input + give feedback. -/// UnknownCommand is now produced only for command-SHAPED but verbless -/// input ("/", "//x", "@ x"); real unknown verbs route to the server (retail -/// treats / and @ as equivalent command prefixes, and ACE answers unknown -/// commands itself). +/// UnknownCommand is produced only for command-shaped but verbless +/// input ("/", "//x", "@ x"); real unknown verbs route to the server. public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } /// -/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 -/// analogue). Both the ImGui devtools and the retail -/// chat window route through here so command handling stays in one place. +/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @ +/// 0x004F5100 analogue). Both the ImGui devtools +/// and retained retail chat window route through here. /// -/// Flow: client-command intercept → degenerate-prefix guard → -/// Publish(SendChatCmd). -/// Unknown slash/at verbs pass through the parser to the server (the parser -/// rewrites /xyz@xyz and forces the Say action, the only -/// wire path ACE parses commands on); ACE replies "Unknown command: xyz" -/// for verbs it doesn't know, so the server stays the single authority on -/// what's a valid command. Prefix text with no letter verb is refused -/// locally — /-prefixed input must NEVER broadcast as speech (Phase J -/// Tier 4, from the 2026-04-25 "/ls echoed as speech" trace). +/// +/// Flow: retail client-command catalog, local presentation command, +/// degenerate-prefix guard, explicit server command, then chat parse. Unknown +/// slash/at verbs publish in canonical +/// @ form; the App host sends those through Talk, the only wire path +/// ACE parses commands on. Prefix text with no letter verb is refused locally +/// so command-shaped input can never leak into speech. +/// /// public static class ChatCommandRouter { @@ -31,14 +28,28 @@ public static class ChatCommandRouter { ArgumentNullException.ThrowIfNull(vm); ArgumentNullException.ThrowIfNull(bus); - var trimmed = (raw ?? string.Empty).Trim(); - if (trimmed.Length == 0) return SubmitOutcome.Empty; + string trimmed = (raw ?? string.Empty).Trim(); + if (trimmed.Length == 0) + return SubmitOutcome.Empty; - if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; + if (RetailClientCommandCatalog.TryMatch(trimmed, out var clientCommand)) + { + if (!clientCommand.HasValidArguments) + { + vm.ShowSystemMessage($"Usage: {clientCommand.Usage}"); + return SubmitOutcome.ClientHandled; + } - // Command-shaped but no letter verb ("/", "//shrug", "@ x"): refuse - // locally. Rewriting to @ would put junk on the wire, and letting it - // fall through would broadcast /-text as speech (Tier-4 violation). + bus.Publish(new ExecuteClientCommandCmd( + clientCommand.Command, clientCommand.Arguments)); + return SubmitOutcome.ClientHandled; + } + + if (TryHandleLocalPresentationCommand(trimmed, vm)) + return SubmitOutcome.ClientHandled; + + // Command-shaped but no letter verb ("/", "//shrug", "@ x"): + // refuse locally rather than putting junk on the wire or in speech. if (trimmed[0] is '/' or '@' && (trimmed.Length == 1 || !char.IsLetter(trimmed[1]))) { @@ -47,33 +58,75 @@ public static class ChatCommandRouter return SubmitOutcome.UnknownCommand; } - var parsed = ChatInputParser.Parse( - trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); - if (parsed is { } p) + if (TryBuildServerCommand(trimmed, out string serverCommand)) { - bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + bus.Publish(new SendServerCommandCmd(serverCommand)); return SubmitOutcome.Sent; } + + var parsed = ChatInputParser.Parse( + trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); + if (parsed is { } chat) + { + bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text)); + return SubmitOutcome.Sent; + } + return SubmitOutcome.Dropped; } - private static bool TryHandleClientCommand(string trimmed, ChatVM vm) + private static bool TryBuildServerCommand(string trimmed, out string command) + { + command = string.Empty; + if (trimmed[0] is not ('/' or '@')) + return false; + + string verb = ChatInputParser.GetVerbToken(trimmed); + string normalizedChatVerb = "/" + verb[1..]; + if (ChatInputParser.IsKnownVerb(normalizedChatVerb)) + return false; + + command = "@" + trimmed[1..]; + return true; + } + + private static bool TryHandleLocalPresentationCommand(string trimmed, ChatVM vm) { if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) - { vm.ShowSystemMessage(BuildHelpText()); return true; } + { + vm.ShowSystemMessage(BuildHelpText()); + return true; + } + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) - { vm.Clear(); return true; } + { + vm.Clear(); + return true; + } + if (EqAny(trimmed, "/framerate", "@framerate")) - { vm.ShowFps(); return true; } + { + vm.ShowFps(); + return true; + } + if (EqAny(trimmed, "/loc", "@loc")) - { vm.ShowLocation(); return true; } + { + vm.ShowLocation(); + return true; + } + return false; } - private static bool EqAny(string s, params string[] options) + private static bool EqAny(string value, params string[] options) { for (int i = 0; i < options.Length; i++) - if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + { + if (value.Equals(options[i], StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; } @@ -84,5 +137,6 @@ public static class ChatCommandRouter " /patron /vassals /monarch /covassals\n" + " /lfg /roleplay /society /olthoi\n" + "Client: /help (this) /clear /framerate /loc\n" + + $" {RetailClientCommandCatalog.BuildHelpText()}\n" + "Server: type @acehelp or @acecommands for ACE's full list."; } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index 73141255..eeea0d6c 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -16,12 +16,10 @@ namespace AcDream.UI.Abstractions.Panels.Chat; /// /say with no message → null /// /t with no target / no message → null /// /r with no lastTellSendernull -/// unknown /xyz verb → rewritten to @xyz and passed -/// through on the default channel. Retail treats / and @ as -/// equivalent command prefixes; ACE's GameActionTalk only -/// intercepts the @ form on the wire. Deliberate divergence -/// from holtburger's literal fall-through (which would SAY the -/// command text out loud). +/// unknown /xyz verb → rewritten to @xyz when this pure +/// parser is called directly. The production intercepts it first and publishes a typed +/// . /// multi-word /t target: first whitespace token is target, /// rest is message (matches Rust split_once semantics) /// diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs new file mode 100644 index 00000000..6763ce8f --- /dev/null +++ b/src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs @@ -0,0 +1,90 @@ +using System.Collections.Frozen; + +namespace AcDream.UI.Abstractions.Panels.Chat; + +/// +/// Immutable catalog of commands the named retail client executes locally. +/// This is intentionally separate from chat aliases and ACE server commands. +/// +/// +/// First vertical slice: retail registers lifestone, lif, and +/// ls against ClientCommunicationSystem::DoLifestone @ 0x0056FC70. +/// See docs/research/2026-07-13-retail-client-command-routing-pseudocode.md. +/// +/// +public static class RetailClientCommandCatalog +{ + private sealed record Definition( + ClientCommandId Command, + string Usage, + string HelpText, + Func ValidateArguments); + + /// A resolved retail command plus its raw, trimmed argument text. + public readonly record struct Match( + ClientCommandId Command, + string Arguments, + string Usage, + bool HasValidArguments); + + private static readonly Definition Lifestone = new( + ClientCommandId.LifestoneRecall, + Usage: "/lifestone", + HelpText: "/lifestone (/lif, /ls) - Returns you to the last lifestone you used without killing you.", + ValidateArguments: static arguments => arguments.Length == 0); + + private static readonly FrozenDictionary ByVerb = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["lifestone"] = Lifestone, + ["lif"] = Lifestone, + ["ls"] = Lifestone, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Resolve a complete chat-bar line. Returns false when its verb is not + /// client-owned; callers can then try chat aliases or the server-command + /// path. Both retail command prefixes are accepted. + /// + public static bool TryMatch(string input, out Match match) + { + match = default; + if (string.IsNullOrWhiteSpace(input)) + return false; + + string trimmed = input.Trim(); + if (trimmed.Length < 2 || trimmed[0] is not ('/' or '@')) + return false; + + int separator = IndexOfWhitespace(trimmed); + string verb = separator < 0 + ? trimmed[1..] + : trimmed.Substring(1, separator - 1); + if (!ByVerb.TryGetValue(verb, out Definition? definition)) + return false; + + string arguments = separator < 0 + ? string.Empty + : trimmed[(separator + 1)..].Trim(); + match = new Match( + definition.Command, + arguments, + definition.Usage, + definition.ValidateArguments(arguments)); + return true; + } + + /// Help line generated from the same definition routing uses. + public static string BuildHelpText() => Lifestone.HelpText; + + private static int IndexOfWhitespace(string value) + { + for (int i = 1; i < value.Length; i++) + { + if (char.IsWhiteSpace(value[i])) + return i; + } + + return -1; + } +} diff --git a/src/AcDream.UI.Abstractions/SendServerCommandCmd.cs b/src/AcDream.UI.Abstractions/SendServerCommandCmd.cs new file mode 100644 index 00000000..b53aa807 --- /dev/null +++ b/src/AcDream.UI.Abstractions/SendServerCommandCmd.cs @@ -0,0 +1,8 @@ +namespace AcDream.UI.Abstractions; + +/// +/// Command text owned by the connected server rather than the retail client. +/// is canonicalized to an @ prefix because ACE +/// consumes administrator commands from the Talk game action. +/// +public sealed record SendServerCommandCmd(string Text); diff --git a/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs new file mode 100644 index 00000000..59b83acc --- /dev/null +++ b/tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs @@ -0,0 +1,28 @@ +using AcDream.App.UI; +using AcDream.UI.Abstractions; + +namespace AcDream.App.Tests.UI; + +public sealed class ClientCommandControllerTests +{ + [Fact] + public void LifestoneRecall_ExecutesSuppliedSessionActionOnce() + { + int recalls = 0; + var controller = new ClientCommandController(() => recalls++); + + controller.Execute(new ExecuteClientCommandCmd( + ClientCommandId.LifestoneRecall, Arguments: string.Empty)); + + Assert.Equal(1, recalls); + } + + [Fact] + public void UnknownCommandId_FailsAtApplicationBoundary() + { + var controller = new ClientCommandController(() => { }); + var command = new ExecuteClientCommandCmd((ClientCommandId)999, string.Empty); + + Assert.Throws(() => controller.Execute(command)); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index b85fed64..bd6c1dfe 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -198,6 +198,21 @@ public class ChatWindowControllerTests Assert.Equal("hello world", cmd.Text); } + [Fact] + public void Bind_LifestoneSubmit_PublishesTypedClientCommand() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); + + Assert.NotNull(ctrl); + ctrl!.Input.OnSubmit!.Invoke("/ls"); + + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.LifestoneRecall, command.Command); + } + // ── Test 5: Channel change updates the channel used by subsequent submits ─ [Fact] diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs index 67faf6c9..3c2601f3 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs @@ -76,4 +76,17 @@ public sealed class WorldSessionChatTests using var session = NewSession(); Assert.Throws(() => session.SendTalk(null!)); } + + [Fact] + public void SendTeleportToLifestone_EmitsRetailGameAction() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendTeleportToLifestone(); + + Assert.NotNull(captured); + Assert.Equal(InteractRequests.BuildTeleToLifestone(1), captured); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs index 5707e1e7..b80ca626 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs @@ -9,11 +9,10 @@ public class ChatCommandRouterTests { private sealed class CaptureBus : ICommandBus { - public SendChatCmd? Last; + public List Published { get; } = new(); + public void Publish(T command) where T : notnull - { - if (command is SendChatCmd c) Last = c; - } + => Published.Add(command); } private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() @@ -28,10 +27,11 @@ public class ChatCommandRouterTests { var (vm, _, bus) = Fixture(); var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); - Assert.NotNull(bus.Last); - Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); - Assert.Equal("hello there", bus.Last.Text); + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ChatChannelKind.Say, command.Channel); + Assert.Equal("hello there", command.Text); } [Fact] @@ -39,7 +39,9 @@ public class ChatCommandRouterTests { var (vm, _, bus) = Fixture(); ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); - Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); + + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ChatChannelKind.Fellowship, command.Channel); } [Fact] @@ -47,48 +49,80 @@ public class ChatCommandRouterTests { var (vm, log, bus) = Fixture(); log.OnSystemMessage("x", chatType: 0); + var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.ClientHandled, outcome); - Assert.Null(bus.Last); + Assert.Empty(bus.Published); Assert.Empty(log.Snapshot()); } - [Fact] - public void UnknownSlashVerb_RoutesToServerInAtForm() + [Theory] + [InlineData("/lifestone")] + [InlineData("/lif")] + [InlineData("/ls")] + [InlineData("@LS")] + public void LifestoneAliases_PublishTypedClientCommand(string input) + { + var (vm, _, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit(input, vm, bus, ChatChannelKind.Fellowship); + + Assert.Equal(SubmitOutcome.ClientHandled, outcome); + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.LifestoneRecall, command.Command); + } + + [Fact] + public void LifestoneWithArguments_ShowsUsageAndPublishesNothing() { - // Retail / ≡ @: unknown verbs are the server's to judge. The - // parser rewrites /-form to @-form (ACE's GameActionTalk only - // intercepts @ on the wire); ACE answers "Unknown command: x" - // for verbs it doesn't know — no local guess. var (vm, log, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); + + 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"); + } + + [Fact] + public void UnknownSlashVerb_RoutesThroughExplicitServerCommand() + { + var (vm, log, bus) = Fixture(); + + var outcome = ChatCommandRouter.Submit( + "/notacommand", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Sent, outcome); - Assert.NotNull(bus.Last); - Assert.Equal("@notacommand", bus.Last!.Text); - Assert.DoesNotContain(log.Snapshot(), e => e.Text.Contains("Unknown command")); + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal("@notacommand", command.Text); + Assert.DoesNotContain(log.Snapshot(), entry => entry.Text.Contains("Unknown command")); } [Theory] [InlineData("/ci 629 5")] [InlineData("@ci 629 5")] - public void ServerCommandWithArgs_PublishesCanonicalAtFormAsSay_EvenOnChannelDefault(string command) + public void ServerCommandWithArgs_PublishesCanonicalAtForm_EvenOnChannelDefault( + string input) { - // Commands resolve before channel routing (retail behavior): - // even with Fellowship as the active input channel, a command - // goes out as Say/Talk — the only wire action ACE parses @ on. var (vm, _, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit(command, vm, bus, ChatChannelKind.Fellowship); + + var outcome = ChatCommandRouter.Submit( + input, vm, bus, ChatChannelKind.Fellowship); + Assert.Equal(SubmitOutcome.Sent, outcome); - Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); - Assert.Equal("@ci 629 5", bus.Last.Text); + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal("@ci 629 5", command.Text); } [Fact] public void EmptyInput_DoesNothing() { var (vm, _, bus) = Fixture(); + var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); + Assert.Equal(SubmitOutcome.Empty, outcome); - Assert.Null(bus.Last); + Assert.Empty(bus.Published); } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 1977688c..35d7b5fb 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -5,7 +5,7 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat; /// /// Phase I.4: when the user submits text via the chat input field, the -/// panel must publish a to the command bus. +/// panel must publish the appropriate typed intent to the command bus. /// We exercise the full Render path with the /// pre-loading a "submitted" string and a recording bus capturing the /// resulting command. @@ -114,10 +114,9 @@ public sealed class ChatPanelInputTests [Theory] [InlineData("/foo", "@foo")] - [InlineData("/ls", "@ls")] [InlineData("/mp /tools/script.py", "@mp /tools/script.py")] [InlineData("/genio public", "@genio public")] - public void Submit_UnknownSlashCommand_RoutesToServerAsAtCommand(string raw, string expectedText) + public void Submit_UnknownSlashCommand_RoutesToExplicitServerCommand(string raw, string expectedText) { // Phase J Tier 4 held: /-prefixed text is still NEVER broadcast // as plain speech. Retail treats / and @ as equivalent command @@ -136,12 +135,33 @@ public sealed class ChatPanelInputTests panel.Render(new PanelContext(0.016f, bus), renderer); - var sendCmd = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(ChatChannelKind.Say, sendCmd.Channel); + var sendCmd = Assert.IsType(Assert.Single(bus.Published)); Assert.Equal(expectedText, sendCmd.Text); Assert.Empty(log.Snapshot()); // no local "Unknown command" guess } + [Theory] + [InlineData("/lifestone")] + [InlineData("/lif")] + [InlineData("/ls")] + [InlineData("@LS")] + public void Submit_LifestoneAlias_PublishesTypedClientCommand(string raw) + { + var vm = new ChatVM(new ChatLog()); + var panel = new ChatPanel(vm); + var bus = new RecordingBus(); + var renderer = new FakePanelRenderer + { + InputTextSubmitNextSubmitted = raw, + InputTextSubmitNextBufferAfter = "", + }; + + panel.Render(new PanelContext(0.016f, bus), renderer); + + var command = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.LifestoneRecall, command.Command); + } + [Theory] [InlineData("/")] [InlineData("//shrug")] @@ -170,12 +190,12 @@ public sealed class ChatPanelInputTests } [Fact] - public void Submit_AtAcehelp_PassesThroughToSayWithAtIntact() + public void Submit_AtAcehelp_PublishesExplicitServerCommand() { // Unknown @-verb falls through to the default channel with the // literal "@acehelp" text intact so ACE's CommandManager - // intercepts it server-side. We DO publish a SendChatCmd here — - // the publish is what carries the message to the server. + // intercepts it server-side. The explicit server-command record keeps + // it distinct from ordinary Say text. var log = new ChatLog(); var vm = new ChatVM(log); var panel = new ChatPanel(vm); @@ -188,8 +208,7 @@ public sealed class ChatPanelInputTests panel.Render(new PanelContext(0.016f, bus), renderer); - var sendCmd = Assert.IsType(Assert.Single(bus.Published)); - Assert.Equal(ChatChannelKind.Say, sendCmd.Channel); + var sendCmd = Assert.IsType(Assert.Single(bus.Published)); Assert.Equal("@acehelp", sendCmd.Text); } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs new file mode 100644 index 00000000..7fe96e03 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/RetailClientCommandCatalogTests.cs @@ -0,0 +1,38 @@ +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +public sealed class RetailClientCommandCatalogTests +{ + [Theory] + [InlineData("/lifestone")] + [InlineData("@lifestone")] + [InlineData("/lif")] + [InlineData("@LIF")] + [InlineData("/ls")] + [InlineData("@LS")] + public void RetailAliases_ResolveCaseInsensitively(string input) + { + Assert.True(RetailClientCommandCatalog.TryMatch(input, out var match)); + Assert.Equal(ClientCommandId.LifestoneRecall, match.Command); + Assert.True(match.HasValidArguments); + Assert.Empty(match.Arguments); + } + + [Fact] + public void LifestoneArgument_IsRecognizedButInvalid() + { + Assert.True(RetailClientCommandCatalog.TryMatch("/ls now", out var match)); + Assert.Equal("now", match.Arguments); + Assert.False(match.HasValidArguments); + Assert.Equal("/lifestone", match.Usage); + } + + [Theory] + [InlineData("/ci 629")] + [InlineData("@acehelp")] + [InlineData("ordinary speech")] + public void NonClientCommands_DoNotMatch(string input) + => Assert.False(RetailClientCommandCatalog.TryMatch(input, out _)); +}