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
|
|
@ -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.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue