User reported typing /ls (a command-style request, not chat) gets
echoed by the server as "You say, \"/ls\"". Slash-prefix is a
COMMAND surface, never a chat surface. Filed after the same flow
that produced @help and the welcome-message work.
Behavior change at the ChatPanel submit layer:
- Any /-prefixed input whose verb isn't in our alias tables now
renders a local "[System] Unknown command: /foo. Type /help for
the list." line and is NEVER published to the bus. No SendChatCmd,
no Talk packet. The server never sees /foo.
- Known /-verbs (/say /tell /reply /retell /general /allegiance
/patron /vassals /monarch /covassals /fellowship /lookingforgroup
/trade /roleplay /society /olthoi /help /clear /framerate /loc
and friends) still flow through ChatInputParser.Parse → SendChatCmd
exactly as before.
- @-prefix unchanged: ACE's CommandManager handles unknown @ verbs
server-side and replies via SystemChat ("Unknown command: foo")
per ACE GameActionTalk.cs:21. Our @ -> / normalization for known
verbs (Phase J Tier 1) and the @-passthrough fallthrough for
unknown verbs both still apply.
ChatInputParser now exposes:
- IsKnownVerb(string verb): query against the union of every alias
table. Used by ChatPanel to discriminate "unknown verb" from
"known verb with bad args".
- GetVerbToken(string command): public alias of the existing
ExtractVerb so callers can pull the first whitespace token without
reproducing the helper.
Parse itself is unchanged — its existing fall-through (Say with
literal text) still applies for unknown /-verbs called directly via
the parser, but ChatPanel intercepts before reaching that path so
the fall-through never fires through the live submit pipeline. Tests
that directly call Parse continue to pass; the new ChatPanel-level
tests pin the unknown-command rejection.
19 new tests:
- ChatInputParserTests: 10 IsKnownVerb Theory cases + 4 GetVerbToken
Theory cases.
- ChatPanelInputTests: 5 Theory cases for Submit_UnknownSlashCommand
covering /foo, /ls, /mp <path>, /genio, and bare /.
Solution total: 1086 green (243 Core.Net + 183 UI + 660 Core),
0 warnings.
Acceptance: type /ls, /mp /path, /anything-not-known — see local
"[System] Unknown command: /xxx. Type /help for the list of
supported commands." Nothing reaches the wire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
324 lines
14 KiB
C#
324 lines
14 KiB
C#
namespace AcDream.UI.Abstractions.Panels.Chat;
|
|
|
|
/// <summary>
|
|
/// Phase I.4: pure-function parse of a chat-input line into the
|
|
/// <c>(channel, target?, text)</c> triple a <see cref="SendChatCmd"/>
|
|
/// needs. Ported from holtburger
|
|
/// <c>references/holtburger/apps/holtburger-cli/src/pages/game/input/commands.rs</c>
|
|
/// — the alias table around line ~457 and the <c>parse_targeted_chat_command</c>
|
|
/// / <c>parse_message_only_command</c> helpers near the top of the file.
|
|
///
|
|
/// <para>
|
|
/// Edge cases handled (each pinned by a test in
|
|
/// <c>ChatInputParserTests</c>):
|
|
/// <list type="bullet">
|
|
/// <item>empty / whitespace-only → <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>/r</c> with no <c>lastTellSender</c> → <c>null</c></item>
|
|
/// <item>unknown <c>/xyz</c> verb → fall through to default channel
|
|
/// carrying the literal text (matches holtburger
|
|
/// <c>handle_slash_command</c> default arm at line ~744 — they
|
|
/// send <c>ClientCommand::Talk(command)</c> with the original
|
|
/// slash-prefixed string)</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>
|
|
/// </para>
|
|
/// </summary>
|
|
public static class ChatInputParser
|
|
{
|
|
/// <summary>
|
|
/// Outcome of a successful parse. Pass straight into
|
|
/// <see cref="SendChatCmd"/>'s constructor.
|
|
/// </summary>
|
|
public readonly record struct ParsedInput(
|
|
ChatChannelKind Channel,
|
|
string? TargetName,
|
|
string Text);
|
|
|
|
// Alias tables. Order matters only for error messages — verb
|
|
// matching is exact-token, not prefix.
|
|
private static readonly string[] SayAliases = { "/say", "/s" };
|
|
private static readonly string[] TellAliases = { "/tell", "/t" };
|
|
private static readonly string[] ReplyAliases = { "/reply", "/r" };
|
|
// Phase J Tier 2: /retell <msg> — resend to last person YOU tell'd.
|
|
// Mirrors retail's @retell. Distinct from /reply which targets the
|
|
// last person who tell'd US.
|
|
private static readonly string[] RetellAliases = { "/retell" };
|
|
|
|
// Channel aliases. Each maps a single verb token to a channel kind.
|
|
// The same list drives both the verb test and the prefix-strip.
|
|
// Long-form aliases mirror retail muscle memory (e.g. "/allegiance"
|
|
// for "/a", "/patron" for "/p"). Phase J added the long forms after
|
|
// a 2026-04-25 live session showed "/patron hello" falling through
|
|
// as plain Say with the literal "/patron " prefix.
|
|
private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
|
|
{
|
|
("/g", ChatChannelKind.General),
|
|
("/general", ChatChannelKind.General),
|
|
("/gen", ChatChannelKind.General),
|
|
("/f", ChatChannelKind.Fellowship),
|
|
("/fellow", ChatChannelKind.Fellowship),
|
|
("/fellowship", ChatChannelKind.Fellowship),
|
|
("/a", ChatChannelKind.Allegiance),
|
|
("/allegiance", ChatChannelKind.Allegiance),
|
|
("/m", ChatChannelKind.Monarch),
|
|
("/monarch", ChatChannelKind.Monarch),
|
|
("/p", ChatChannelKind.Patron),
|
|
("/patron", ChatChannelKind.Patron),
|
|
("/v", ChatChannelKind.Vassals),
|
|
("/vassals", ChatChannelKind.Vassals),
|
|
("/cv", ChatChannelKind.CoVassals),
|
|
("/covassals", ChatChannelKind.CoVassals),
|
|
("/lfg", ChatChannelKind.Lfg),
|
|
("/lookingforgroup", ChatChannelKind.Lfg),
|
|
("/trade", ChatChannelKind.Trade),
|
|
("/tr", ChatChannelKind.Trade),
|
|
("/role", ChatChannelKind.Roleplay),
|
|
("/rp", ChatChannelKind.Roleplay),
|
|
("/roleplay", ChatChannelKind.Roleplay),
|
|
("/society", ChatChannelKind.Society),
|
|
("/olthoi", ChatChannelKind.Olthoi),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Parse <paramref name="raw"/> into a <see cref="ParsedInput"/>
|
|
/// triple, or <c>null</c> if the input is empty / whitespace /
|
|
/// missing required arguments (e.g. <c>/t</c> with no target).
|
|
/// </summary>
|
|
/// <param name="raw">User input. The caller should
|
|
/// <see cref="string.Trim()"/> before passing.</param>
|
|
/// <param name="defaultChannel">Channel to use for unprefixed text
|
|
/// or unrecognised slash commands.</param>
|
|
/// <param name="lastTellSender">Sender of the most recent incoming
|
|
/// Tell, used to resolve <c>/r</c> reply target. Null if no Tell
|
|
/// has arrived this session.</param>
|
|
/// <param name="lastOutgoingTellTarget">Target of the most recent
|
|
/// outgoing Tell, used to resolve <c>/retell</c>. Null if the
|
|
/// player hasn't sent a Tell yet this session.</param>
|
|
public static ParsedInput? Parse(
|
|
string raw,
|
|
ChatChannelKind defaultChannel,
|
|
string? lastTellSender,
|
|
string? lastOutgoingTellTarget = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
|
var trimmed = raw.Trim();
|
|
|
|
// Phase J Tier 1: ACE accepts both / and @ as equivalent verb
|
|
// prefixes (per ACE help: "Note: You may substitute a forward
|
|
// slash (/) for the at symbol (@)."). For verbs WE recognize,
|
|
// normalize @ to / and re-enter parsing. For unknown @-verbs
|
|
// (e.g. @acehelp, @tele, @die), pass the literal @-prefixed
|
|
// text through to the default channel so ACE's CommandManager
|
|
// server-side handler intercepts it.
|
|
if (trimmed.StartsWith("@"))
|
|
{
|
|
string substituted = "/" + trimmed.Substring(1);
|
|
string verb = ExtractVerb(substituted);
|
|
if (AllKnownVerbs.Contains(verb))
|
|
{
|
|
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
|
|
}
|
|
// Unknown @-verb — keep the original @ so ACE recognizes
|
|
// it server-side when the message arrives via Talk.
|
|
return new ParsedInput(defaultChannel, null, trimmed);
|
|
}
|
|
|
|
// /say <msg>
|
|
if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg))
|
|
return new ParsedInput(ChatChannelKind.Say, null, sayMsg);
|
|
if (IsBareVerb(trimmed, SayAliases))
|
|
return null;
|
|
|
|
// /tell <target> <msg> or /t <target> <msg>
|
|
if (TryParseTargeted(trimmed, TellAliases, out var tellTarget, out var tellMsg))
|
|
return new ParsedInput(ChatChannelKind.Tell, tellTarget, tellMsg);
|
|
if (IsBareVerb(trimmed, TellAliases) || IsVerbWithSingleToken(trimmed, TellAliases))
|
|
return null;
|
|
|
|
// /r <msg> / /reply <msg> — needs prior incoming tell.
|
|
if (TryParseMessageOnly(trimmed, ReplyAliases, out var replyMsg))
|
|
{
|
|
if (string.IsNullOrEmpty(lastTellSender)) return null;
|
|
return new ParsedInput(ChatChannelKind.Tell, lastTellSender, replyMsg);
|
|
}
|
|
if (IsBareVerb(trimmed, ReplyAliases))
|
|
return null;
|
|
|
|
// /retell <msg> — needs prior outgoing tell.
|
|
if (TryParseMessageOnly(trimmed, RetellAliases, out var retellMsg))
|
|
{
|
|
if (string.IsNullOrEmpty(lastOutgoingTellTarget)) return null;
|
|
return new ParsedInput(ChatChannelKind.Tell, lastOutgoingTellTarget, retellMsg);
|
|
}
|
|
if (IsBareVerb(trimmed, RetellAliases))
|
|
return null;
|
|
|
|
// Channel verbs (/g, /f, /a, ...).
|
|
foreach (var (verb, channel) in ChannelVerbs)
|
|
{
|
|
if (TryParseMessageOnly(trimmed, new[] { verb }, out var msg))
|
|
return new ParsedInput(channel, null, msg);
|
|
if (IsBareVerb(trimmed, new[] { verb }))
|
|
return null;
|
|
}
|
|
|
|
// Unknown slash command: holtburger falls back to Talk(literal).
|
|
// We mirror that — emit on the default channel with the
|
|
// slash-prefixed text intact so the server treats it as speech.
|
|
// No special-case for known-but-unimplemented verbs; the user's
|
|
// text round-trips so their intent isn't silently dropped.
|
|
return new ParsedInput(defaultChannel, null, trimmed);
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Match holtburger's <c>parse_targeted_chat_command</c>: split on
|
|
/// first whitespace into verb / rest, check verb against aliases,
|
|
/// then split rest into target / message. Returns false if either
|
|
/// the verb is wrong or target / message is empty.
|
|
/// </summary>
|
|
private static bool TryParseTargeted(string command, string[] aliases, out string target, out string message)
|
|
{
|
|
target = string.Empty;
|
|
message = string.Empty;
|
|
|
|
int firstWs = IndexOfWhitespace(command);
|
|
if (firstWs < 0) return false;
|
|
|
|
var verb = command.Substring(0, firstWs);
|
|
if (!ContainsExact(aliases, verb)) return false;
|
|
|
|
var rest = command.Substring(firstWs + 1).TrimStart();
|
|
if (rest.Length == 0) return false;
|
|
|
|
int targetEnd = IndexOfWhitespace(rest);
|
|
if (targetEnd < 0) return false; // target only, no message
|
|
|
|
target = rest.Substring(0, targetEnd);
|
|
message = rest.Substring(targetEnd + 1).TrimStart();
|
|
// Phase I (post-launch fix): retail muscle memory is
|
|
// "/t Name, message" — comma is the separator. Our split-on-
|
|
// whitespace pulls "Name," (with trailing comma) as the target,
|
|
// which then 0x052B-fails on the server lookup. Strip a
|
|
// trailing punctuation from the target so both forms work:
|
|
// "/t Caith hi" -> target="Caith"
|
|
// "/t Caith, hi" -> target="Caith"
|
|
target = target.TrimEnd(',', ';', ':', '.', '!', '?');
|
|
if (target.Length == 0 || message.Length == 0) return false;
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Match holtburger's <c>parse_message_only_command</c>: split on
|
|
/// first whitespace, alias-match the verb, return the trimmed rest
|
|
/// as the message. Empty-message → false.
|
|
/// </summary>
|
|
private static bool TryParseMessageOnly(string command, string[] aliases, out string message)
|
|
{
|
|
message = string.Empty;
|
|
int firstWs = IndexOfWhitespace(command);
|
|
if (firstWs < 0) return false;
|
|
|
|
var verb = command.Substring(0, firstWs);
|
|
if (!ContainsExact(aliases, verb)) return false;
|
|
|
|
message = command.Substring(firstWs + 1).TrimStart();
|
|
return message.Length > 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="command"/> is exactly one of the
|
|
/// alias verbs (no message, no extra whitespace beyond trimming).
|
|
/// </summary>
|
|
private static bool IsBareVerb(string command, string[] aliases)
|
|
{
|
|
foreach (var alias in aliases)
|
|
if (command == alias) return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="command"/> is "verb arg" with no
|
|
/// further whitespace — e.g. <c>/t Bestie</c> (target but no
|
|
/// message). Used to short-circuit the "fall through to default
|
|
/// channel" path so a half-typed tell doesn't get sent as Say.
|
|
/// </summary>
|
|
private static bool IsVerbWithSingleToken(string command, string[] aliases)
|
|
{
|
|
int firstWs = IndexOfWhitespace(command);
|
|
if (firstWs < 0) return false;
|
|
var verb = command.Substring(0, firstWs);
|
|
if (!ContainsExact(aliases, verb)) return false;
|
|
|
|
var rest = command.Substring(firstWs + 1).TrimStart();
|
|
if (rest.Length == 0) return true;
|
|
return IndexOfWhitespace(rest) < 0;
|
|
}
|
|
|
|
private static int IndexOfWhitespace(string s)
|
|
{
|
|
for (int i = 0; i < s.Length; i++)
|
|
if (char.IsWhiteSpace(s[i])) return i;
|
|
return -1;
|
|
}
|
|
|
|
private static bool ContainsExact(string[] aliases, string verb)
|
|
{
|
|
for (int i = 0; i < aliases.Length; i++)
|
|
if (aliases[i] == verb) return true;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pull the first whitespace-separated token from <paramref name="command"/>.
|
|
/// Used by the @-prefix normalization to know whether the verb is
|
|
/// one we route locally (Tell / Channel / etc.) or an unknown
|
|
/// command that should pass through to the server intact.
|
|
/// </summary>
|
|
private static string ExtractVerb(string command)
|
|
{
|
|
int ws = IndexOfWhitespace(command);
|
|
return ws < 0 ? command : command.Substring(0, ws);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Union of every alias verb (with leading <c>/</c>) the parser
|
|
/// recognises. Used by the <c>@</c>-prefix passthrough decision:
|
|
/// if the substituted verb is in this set, we normalize to the
|
|
/// <c>/</c> path; otherwise the original <c>@</c>-prefixed text
|
|
/// is preserved so ACE's command handler sees it.
|
|
/// </summary>
|
|
private static readonly HashSet<string> AllKnownVerbs = BuildKnownVerbs();
|
|
|
|
private static HashSet<string> BuildKnownVerbs()
|
|
{
|
|
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var v in SayAliases) set.Add(v);
|
|
foreach (var v in TellAliases) set.Add(v);
|
|
foreach (var v in ReplyAliases) set.Add(v);
|
|
foreach (var v in RetellAliases) set.Add(v);
|
|
foreach (var (v, _) in ChannelVerbs) set.Add(v);
|
|
return set;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if <paramref name="verb"/> (with leading
|
|
/// <c>/</c>) is one this parser routes — used by callers that
|
|
/// need to distinguish "unknown slash command" from "known
|
|
/// verb with bad arguments" without reproducing the alias
|
|
/// tables. <c>@</c>-prefixed verbs need to be normalized to
|
|
/// <c>/</c> before passing.
|
|
/// </summary>
|
|
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(verb);
|
|
|
|
/// <summary>
|
|
/// Pull the first whitespace-separated token (the command verb)
|
|
/// from <paramref name="command"/>. Returns the entire string if
|
|
/// there is no whitespace.
|
|
/// </summary>
|
|
public static string GetVerbToken(string command) => ExtractVerb(command);
|
|
}
|