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:
Erik 2026-07-13 11:43:19 +02:00
parent 5090aa7217
commit 5a45a7ac7f
19 changed files with 604 additions and 78 deletions

View file

@ -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

View file

@ -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,

View file

@ -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` |
---

View file

@ -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.

View file

@ -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<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 =>
{
if (string.IsNullOrEmpty(cmd.Text)) return;

View 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.");
}
}
}

View file

@ -1262,6 +1262,16 @@ public sealed class WorldSession : IDisposable
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>
public void SendChangeCombatMode(CombatMode mode)
{

View 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,
}

View 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);

View file

@ -3,26 +3,23 @@ using System;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <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
/// input ("/", "//x", "@ x"); real unknown verbs route to the server (retail
/// treats / and @ as equivalent command prefixes, and ACE answers unknown
/// commands itself).</summary>
/// <c>UnknownCommand</c> is produced only for command-shaped but verbless
/// input ("/", "//x", "@ x"); real unknown verbs route to the server.</summary>
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
/// <summary>
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @0x4f5100</c>
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
/// chat window route through here so command handling stays in one place.
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @
/// 0x004F5100</c> analogue). Both the ImGui devtools <see cref="ChatPanel"/>
/// and retained retail chat window route through here.
///
/// Flow: client-command intercept → degenerate-prefix guard →
/// <see cref="ChatInputParser.Parse"/> → <c>Publish(SendChatCmd)</c>.
/// Unknown slash/at verbs pass through the parser to the server (the parser
/// rewrites <c>/xyz</c> → <c>@xyz</c> 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).
/// <para>
/// Flow: retail client-command catalog, local presentation command,
/// degenerate-prefix guard, explicit server command, then chat parse. Unknown
/// slash/at verbs publish <see cref="SendServerCommandCmd"/> in canonical
/// <c>@</c> 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.
/// </para>
/// </summary>
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.";
}

View file

@ -16,12 +16,10 @@ namespace AcDream.UI.Abstractions.Panels.Chat;
/// <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>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
/// <item>unknown <c>/xyz</c> verb → rewritten to <c>@xyz</c> and passed
/// through on the default channel. Retail treats / and @ as
/// equivalent command prefixes; ACE's <c>GameActionTalk</c> only
/// intercepts the <c>@</c> form on the wire. Deliberate divergence
/// from holtburger's literal fall-through (which would SAY the
/// command text out loud).</item>
/// <item>unknown <c>/xyz</c> verb → rewritten to <c>@xyz</c> when this pure
/// parser is called directly. The production <see
/// cref="ChatCommandRouter"/> intercepts it first and publishes a typed
/// <see cref="SendServerCommandCmd"/>.</item>
/// <item>multi-word <c>/t</c> target: first whitespace token is target,
/// rest is message (matches Rust <c>split_once</c> semantics)</item>
/// </list>

View file

@ -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;
}
}

View 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);

View 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));
}
}

View file

@ -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<ExecuteClientCommandCmd>(Assert.Single(bus.Published));
Assert.Equal(ClientCommandId.LifestoneRecall, command.Command);
}
// ── Test 5: Channel change updates the channel used by subsequent submits ─
[Fact]

View file

@ -76,4 +76,17 @@ public sealed class WorldSessionChatTests
using var session = NewSession();
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);
}
}

View file

@ -9,11 +9,10 @@ public class ChatCommandRouterTests
{
private sealed class CaptureBus : ICommandBus
{
public SendChatCmd? Last;
public List<object> Published { get; } = new();
public void Publish<T>(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<SendChatCmd>(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<SendChatCmd>(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<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 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<SendServerCommandCmd>(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<SendServerCommandCmd>(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);
}
}

View file

@ -5,7 +5,7 @@ namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// 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"/>
/// 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<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
var sendCmd = Assert.IsType<SendServerCommandCmd>(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<ExecuteClientCommandCmd>(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<SendChatCmd>(Assert.Single(bus.Published));
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
var sendCmd = Assert.IsType<SendServerCommandCmd>(Assert.Single(bus.Published));
Assert.Equal("@acehelp", sendCmd.Text);
}

View file

@ -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 _));
}