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 <codex@openai.com>
This commit is contained in:
parent
5090aa7217
commit
5a45a7ac7f
19 changed files with 604 additions and 78 deletions
|
|
@ -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
|
## #212 — Toolbar shortcut numbers turn gray in physical combat
|
||||||
|
|
||||||
**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending user gate
|
**Status:** IN-PROGRESS — implementation complete 2026-07-13, pending user gate
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,12 @@ history and the corrected contract live in
|
||||||
and the `IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the
|
and the `IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the
|
||||||
retained gameplay tree, LayoutDesc importer, window runtime, and panel
|
retained gameplay tree, LayoutDesc importer, window runtime, and panel
|
||||||
controllers. Neither presentation stack owns independent game-state truth.
|
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
|
Plugins register retained gameplay markup through the BCL-only
|
||||||
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui
|
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui
|
||||||
assemblies. Core `SelectionState` is the sole selected-object owner for world,
|
assemblies. Core `SelectionState` is the sole selected-object owner for world,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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-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-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-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 |
|
| # | 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-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-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-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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -2716,6 +2716,15 @@ public sealed class GameWindow : IDisposable
|
||||||
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
||||||
var turbineChat = TurbineChat;
|
var turbineChat = TurbineChat;
|
||||||
uint playerGuid = _playerServerGuid;
|
uint playerGuid = _playerServerGuid;
|
||||||
|
var clientCommandController = new AcDream.App.UI.ClientCommandController(
|
||||||
|
liveSession.SendTeleportToLifestone);
|
||||||
|
_commandBus.Register<AcDream.UI.Abstractions.ExecuteClientCommandCmd>(
|
||||||
|
clientCommandController.Execute);
|
||||||
|
_commandBus.Register<AcDream.UI.Abstractions.SendServerCommandCmd>(cmd =>
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(cmd.Text))
|
||||||
|
liveSession.SendTalk(cmd.Text);
|
||||||
|
});
|
||||||
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(cmd.Text)) return;
|
if (string.IsNullOrEmpty(cmd.Text)) return;
|
||||||
|
|
|
||||||
36
src/AcDream.App/UI/ClientCommandController.cs
Normal file
36
src/AcDream.App/UI/ClientCommandController.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
using AcDream.UI.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1262,6 +1262,16 @@ public sealed class WorldSession : IDisposable
|
||||||
SendGameAction(body);
|
SendGameAction(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send retail lifestone recall (0x0063). Retail source:
|
||||||
|
/// <c>CM_Character::Event_TeleToLifestone @ 0x006A1B90</c>.
|
||||||
|
/// </summary>
|
||||||
|
public void SendTeleportToLifestone()
|
||||||
|
{
|
||||||
|
uint seq = NextGameActionSequence();
|
||||||
|
SendGameAction(InteractRequests.BuildTeleToLifestone(seq));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
|
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
|
||||||
public void SendChangeCombatMode(CombatMode mode)
|
public void SendChangeCombatMode(CombatMode mode)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
11
src/AcDream.UI.Abstractions/ClientCommandId.cs
Normal file
11
src/AcDream.UI.Abstractions/ClientCommandId.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
namespace AcDream.UI.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Backend-neutral identity for a command that retail executes in the client
|
||||||
|
/// instead of sending as chat text.
|
||||||
|
/// </summary>
|
||||||
|
public enum ClientCommandId
|
||||||
|
{
|
||||||
|
/// <summary>Recall to the character's bound lifestone.</summary>
|
||||||
|
LifestoneRecall,
|
||||||
|
}
|
||||||
8
src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs
Normal file
8
src/AcDream.UI.Abstractions/ExecuteClientCommandCmd.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace AcDream.UI.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ExecuteClientCommandCmd(ClientCommandId Command, string Arguments);
|
||||||
|
|
@ -3,26 +3,23 @@ using System;
|
||||||
namespace AcDream.UI.Abstractions.Panels.Chat;
|
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
|
||||||
/// <summary>What a submit did, so the caller can clear its input + give feedback.
|
/// <summary>What a submit did, so the caller can clear its input + give feedback.
|
||||||
/// <c>UnknownCommand</c> is now produced only for command-SHAPED but verbless
|
/// <c>UnknownCommand</c> is produced only for command-shaped but verbless
|
||||||
/// input ("/", "//x", "@ x"); real unknown verbs route to the server (retail
|
/// input ("/", "//x", "@ x"); real unknown verbs route to the server.</summary>
|
||||||
/// treats / and @ as equivalent command prefixes, and ACE answers unknown
|
|
||||||
/// commands itself).</summary>
|
|
||||||
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
|
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @0x4f5100</c>
|
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @
|
||||||
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
|
/// 0x004F5100</c> analogue). Both the ImGui devtools <see cref="ChatPanel"/>
|
||||||
/// chat window route through here so command handling stays in one place.
|
/// and retained retail chat window route through here.
|
||||||
///
|
///
|
||||||
/// Flow: client-command intercept → degenerate-prefix guard →
|
/// <para>
|
||||||
/// <see cref="ChatInputParser.Parse"/> → <c>Publish(SendChatCmd)</c>.
|
/// Flow: retail client-command catalog, local presentation command,
|
||||||
/// Unknown slash/at verbs pass through the parser to the server (the parser
|
/// degenerate-prefix guard, explicit server command, then chat parse. Unknown
|
||||||
/// rewrites <c>/xyz</c> → <c>@xyz</c> and forces the Say action, the only
|
/// slash/at verbs publish <see cref="SendServerCommandCmd"/> in canonical
|
||||||
/// wire path ACE parses commands on); ACE replies "Unknown command: xyz"
|
/// <c>@</c> form; the App host sends those through Talk, the only wire path
|
||||||
/// for verbs it doesn't know, so the server stays the single authority on
|
/// ACE parses commands on. Prefix text with no letter verb is refused locally
|
||||||
/// what's a valid command. Prefix text with no letter verb is refused
|
/// so command-shaped input can never leak into speech.
|
||||||
/// locally — /-prefixed input must NEVER broadcast as speech (Phase J
|
/// </para>
|
||||||
/// Tier 4, from the 2026-04-25 "/ls echoed as speech" trace).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ChatCommandRouter
|
public static class ChatCommandRouter
|
||||||
{
|
{
|
||||||
|
|
@ -31,14 +28,28 @@ public static class ChatCommandRouter
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(vm);
|
ArgumentNullException.ThrowIfNull(vm);
|
||||||
ArgumentNullException.ThrowIfNull(bus);
|
ArgumentNullException.ThrowIfNull(bus);
|
||||||
var trimmed = (raw ?? string.Empty).Trim();
|
string trimmed = (raw ?? string.Empty).Trim();
|
||||||
if (trimmed.Length == 0) return SubmitOutcome.Empty;
|
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
|
bus.Publish(new ExecuteClientCommandCmd(
|
||||||
// locally. Rewriting to @ would put junk on the wire, and letting it
|
clientCommand.Command, clientCommand.Arguments));
|
||||||
// fall through would broadcast /-text as speech (Tier-4 violation).
|
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 '@'
|
if (trimmed[0] is '/' or '@'
|
||||||
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
|
&& (trimmed.Length == 1 || !char.IsLetter(trimmed[1])))
|
||||||
{
|
{
|
||||||
|
|
@ -47,33 +58,75 @@ public static class ChatCommandRouter
|
||||||
return SubmitOutcome.UnknownCommand;
|
return SubmitOutcome.UnknownCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed = ChatInputParser.Parse(
|
if (TryBuildServerCommand(trimmed, out string serverCommand))
|
||||||
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
|
|
||||||
if (parsed is { } p)
|
|
||||||
{
|
{
|
||||||
bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text));
|
bus.Publish(new SendServerCommandCmd(serverCommand));
|
||||||
return SubmitOutcome.Sent;
|
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;
|
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"))
|
if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
|
||||||
{ vm.ShowSystemMessage(BuildHelpText()); return true; }
|
{
|
||||||
|
vm.ShowSystemMessage(BuildHelpText());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
|
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
|
||||||
{ vm.Clear(); return true; }
|
{
|
||||||
|
vm.Clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (EqAny(trimmed, "/framerate", "@framerate"))
|
if (EqAny(trimmed, "/framerate", "@framerate"))
|
||||||
{ vm.ShowFps(); return true; }
|
{
|
||||||
|
vm.ShowFps();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (EqAny(trimmed, "/loc", "@loc"))
|
if (EqAny(trimmed, "/loc", "@loc"))
|
||||||
{ vm.ShowLocation(); return true; }
|
{
|
||||||
|
vm.ShowLocation();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
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++)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,5 +137,6 @@ public static class ChatCommandRouter
|
||||||
" /patron /vassals /monarch /covassals\n" +
|
" /patron /vassals /monarch /covassals\n" +
|
||||||
" /lfg /roleplay /society /olthoi\n" +
|
" /lfg /roleplay /society /olthoi\n" +
|
||||||
"Client: /help (this) /clear /framerate /loc\n" +
|
"Client: /help (this) /clear /framerate /loc\n" +
|
||||||
|
$" {RetailClientCommandCatalog.BuildHelpText()}\n" +
|
||||||
"Server: type @acehelp or @acecommands for ACE's full list.";
|
"Server: type @acehelp or @acecommands for ACE's full list.";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,10 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
/// <item><c>/say</c> with no message → <c>null</c></item>
|
/// <item><c>/say</c> with no message → <c>null</c></item>
|
||||||
/// <item><c>/t</c> with no target / no message → <c>null</c></item>
|
/// <item><c>/t</c> with no target / no message → <c>null</c></item>
|
||||||
/// <item><c>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
|
/// <item><c>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
|
||||||
/// <item>unknown <c>/xyz</c> verb → rewritten to <c>@xyz</c> and passed
|
/// <item>unknown <c>/xyz</c> verb → rewritten to <c>@xyz</c> when this pure
|
||||||
/// through on the default channel. Retail treats / and @ as
|
/// parser is called directly. The production <see
|
||||||
/// equivalent command prefixes; ACE's <c>GameActionTalk</c> only
|
/// cref="ChatCommandRouter"/> intercepts it first and publishes a typed
|
||||||
/// intercepts the <c>@</c> form on the wire. Deliberate divergence
|
/// <see cref="SendServerCommandCmd"/>.</item>
|
||||||
/// from holtburger's literal fall-through (which would SAY the
|
|
||||||
/// command text out loud).</item>
|
|
||||||
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
|
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
|
||||||
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
|
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Immutable catalog of commands the named retail client executes locally.
|
||||||
|
/// This is intentionally separate from chat aliases and ACE server commands.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// First vertical slice: retail registers <c>lifestone</c>, <c>lif</c>, and
|
||||||
|
/// <c>ls</c> against <c>ClientCommunicationSystem::DoLifestone @ 0x0056FC70</c>.
|
||||||
|
/// See <c>docs/research/2026-07-13-retail-client-command-routing-pseudocode.md</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class RetailClientCommandCatalog
|
||||||
|
{
|
||||||
|
private sealed record Definition(
|
||||||
|
ClientCommandId Command,
|
||||||
|
string Usage,
|
||||||
|
string HelpText,
|
||||||
|
Func<string, bool> ValidateArguments);
|
||||||
|
|
||||||
|
/// <summary>A resolved retail command plus its raw, trimmed argument text.</summary>
|
||||||
|
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<string, Definition> ByVerb =
|
||||||
|
new Dictionary<string, Definition>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["lifestone"] = Lifestone,
|
||||||
|
["lif"] = Lifestone,
|
||||||
|
["ls"] = Lifestone,
|
||||||
|
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Help line generated from the same definition routing uses.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/AcDream.UI.Abstractions/SendServerCommandCmd.cs
Normal file
8
src/AcDream.UI.Abstractions/SendServerCommandCmd.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace AcDream.UI.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command text owned by the connected server rather than the retail client.
|
||||||
|
/// <see cref="Text"/> is canonicalized to an <c>@</c> prefix because ACE
|
||||||
|
/// consumes administrator commands from the Talk game action.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SendServerCommandCmd(string Text);
|
||||||
28
tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs
Normal file
28
tests/AcDream.App.Tests/UI/ClientCommandControllerTests.cs
Normal file
|
|
@ -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<ArgumentOutOfRangeException>(() => controller.Execute(command));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -198,6 +198,21 @@ public class ChatWindowControllerTests
|
||||||
Assert.Equal("hello world", cmd.Text);
|
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<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
||||||
|
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Test 5: Channel change updates the channel used by subsequent submits ─
|
// ── Test 5: Channel change updates the channel used by subsequent submits ─
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -76,4 +76,17 @@ public sealed class WorldSessionChatTests
|
||||||
using var session = NewSession();
|
using var session = NewSession();
|
||||||
Assert.Throws<ArgumentNullException>(() => session.SendTalk(null!));
|
Assert.Throws<ArgumentNullException>(() => 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,10 @@ public class ChatCommandRouterTests
|
||||||
{
|
{
|
||||||
private sealed class CaptureBus : ICommandBus
|
private sealed class CaptureBus : ICommandBus
|
||||||
{
|
{
|
||||||
public SendChatCmd? Last;
|
public List<object> Published { get; } = new();
|
||||||
|
|
||||||
public void Publish<T>(T command) where T : notnull
|
public void Publish<T>(T command) where T : notnull
|
||||||
{
|
=> Published.Add(command);
|
||||||
if (command is SendChatCmd c) Last = c;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture()
|
private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture()
|
||||||
|
|
@ -28,10 +27,11 @@ public class ChatCommandRouterTests
|
||||||
{
|
{
|
||||||
var (vm, _, bus) = Fixture();
|
var (vm, _, bus) = Fixture();
|
||||||
var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say);
|
var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say);
|
||||||
|
|
||||||
Assert.Equal(SubmitOutcome.Sent, outcome);
|
Assert.Equal(SubmitOutcome.Sent, outcome);
|
||||||
Assert.NotNull(bus.Last);
|
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
||||||
Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel);
|
Assert.Equal(ChatChannelKind.Say, command.Channel);
|
||||||
Assert.Equal("hello there", bus.Last.Text);
|
Assert.Equal("hello there", command.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -39,7 +39,9 @@ public class ChatCommandRouterTests
|
||||||
{
|
{
|
||||||
var (vm, _, bus) = Fixture();
|
var (vm, _, bus) = Fixture();
|
||||||
ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship);
|
ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship);
|
||||||
Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel);
|
|
||||||
|
var command = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
||||||
|
Assert.Equal(ChatChannelKind.Fellowship, command.Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -47,48 +49,80 @@ public class ChatCommandRouterTests
|
||||||
{
|
{
|
||||||
var (vm, log, bus) = Fixture();
|
var (vm, log, bus) = Fixture();
|
||||||
log.OnSystemMessage("x", chatType: 0);
|
log.OnSystemMessage("x", chatType: 0);
|
||||||
|
|
||||||
var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say);
|
var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say);
|
||||||
|
|
||||||
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
|
||||||
Assert.Null(bus.Last);
|
Assert.Empty(bus.Published);
|
||||||
Assert.Empty(log.Snapshot());
|
Assert.Empty(log.Snapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public void UnknownSlashVerb_RoutesToServerInAtForm()
|
[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<ExecuteClientCommandCmd>(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 (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.Equal(SubmitOutcome.Sent, outcome);
|
||||||
Assert.NotNull(bus.Last);
|
var command = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
||||||
Assert.Equal("@notacommand", bus.Last!.Text);
|
Assert.Equal("@notacommand", command.Text);
|
||||||
Assert.DoesNotContain(log.Snapshot(), e => e.Text.Contains("Unknown command"));
|
Assert.DoesNotContain(log.Snapshot(), entry => entry.Text.Contains("Unknown command"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/ci 629 5")]
|
[InlineData("/ci 629 5")]
|
||||||
[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 (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(SubmitOutcome.Sent, outcome);
|
||||||
Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel);
|
var command = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
||||||
Assert.Equal("@ci 629 5", bus.Last.Text);
|
Assert.Equal("@ci 629 5", command.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EmptyInput_DoesNothing()
|
public void EmptyInput_DoesNothing()
|
||||||
{
|
{
|
||||||
var (vm, _, bus) = Fixture();
|
var (vm, _, bus) = Fixture();
|
||||||
|
|
||||||
var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say);
|
var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say);
|
||||||
|
|
||||||
Assert.Equal(SubmitOutcome.Empty, outcome);
|
Assert.Equal(SubmitOutcome.Empty, outcome);
|
||||||
Assert.Null(bus.Last);
|
Assert.Empty(bus.Published);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase I.4: when the user submits text via the chat input field, the
|
/// Phase I.4: when the user submits text via the chat input field, the
|
||||||
/// panel must publish a <see cref="SendChatCmd"/> to the command bus.
|
/// panel must publish the appropriate typed intent to the command bus.
|
||||||
/// We exercise the full Render path with the <see cref="FakePanelRenderer"/>
|
/// We exercise the full Render path with the <see cref="FakePanelRenderer"/>
|
||||||
/// pre-loading a "submitted" string and a recording bus capturing the
|
/// pre-loading a "submitted" string and a recording bus capturing the
|
||||||
/// resulting command.
|
/// resulting command.
|
||||||
|
|
@ -114,10 +114,9 @@ public sealed class ChatPanelInputTests
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/foo", "@foo")]
|
[InlineData("/foo", "@foo")]
|
||||||
[InlineData("/ls", "@ls")]
|
|
||||||
[InlineData("/mp /tools/script.py", "@mp /tools/script.py")]
|
[InlineData("/mp /tools/script.py", "@mp /tools/script.py")]
|
||||||
[InlineData("/genio public", "@genio public")]
|
[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
|
// Phase J Tier 4 held: /-prefixed text is still NEVER broadcast
|
||||||
// as plain speech. Retail treats / and @ as equivalent command
|
// 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);
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||||
|
|
||||||
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
||||||
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
|
|
||||||
Assert.Equal(expectedText, sendCmd.Text);
|
Assert.Equal(expectedText, sendCmd.Text);
|
||||||
Assert.Empty(log.Snapshot()); // no local "Unknown command" guess
|
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<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
|
||||||
|
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("/")]
|
[InlineData("/")]
|
||||||
[InlineData("//shrug")]
|
[InlineData("//shrug")]
|
||||||
|
|
@ -170,12 +190,12 @@ public sealed class ChatPanelInputTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Submit_AtAcehelp_PassesThroughToSayWithAtIntact()
|
public void Submit_AtAcehelp_PublishesExplicitServerCommand()
|
||||||
{
|
{
|
||||||
// Unknown @-verb falls through to the default channel with the
|
// Unknown @-verb falls through to the default channel with the
|
||||||
// literal "@acehelp" text intact so ACE's CommandManager
|
// literal "@acehelp" text intact so ACE's CommandManager
|
||||||
// intercepts it server-side. We DO publish a SendChatCmd here —
|
// intercepts it server-side. The explicit server-command record keeps
|
||||||
// the publish is what carries the message to the server.
|
// it distinct from ordinary Say text.
|
||||||
var log = new ChatLog();
|
var log = new ChatLog();
|
||||||
var vm = new ChatVM(log);
|
var vm = new ChatVM(log);
|
||||||
var panel = new ChatPanel(vm);
|
var panel = new ChatPanel(vm);
|
||||||
|
|
@ -188,8 +208,7 @@ public sealed class ChatPanelInputTests
|
||||||
|
|
||||||
panel.Render(new PanelContext(0.016f, bus), renderer);
|
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||||
|
|
||||||
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
|
||||||
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
|
|
||||||
Assert.Equal("@acehelp", sendCmd.Text);
|
Assert.Equal("@acehelp", sendCmd.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 _));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue