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

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