acdream/src/AcDream.Runtime/Chat/ChatInputParser.cs

471 lines
22 KiB
C#

namespace AcDream.Runtime.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 → 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>
/// </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" };
// Campaign CH slice CH4 (2026-08-09): retail's DoTell @0x00577E40
// registers "tell" under FOUR verb strings — tell/t/send/whisper/w —
// per the command-registry doc §2.2.
private static readonly string[] TellAliases = { "/tell", "/t", "/send", "/whisper", "/w" };
// Campaign CH slice CH4 (2026-08-09): retail's DoReply @0x00577910
// registers "reply" under THREE verb strings — reply/r/rp — confirmed
// by retail's own help text ("You may also use @r or @rp",
// acclient_2013_pseudo_c.txt:1030742). "/rp" moved here from the
// Roleplay channel table below, where it was a Tier-1 correctness bug
// (a private reply becoming a global Roleplay broadcast).
private static readonly string[] ReplyAliases = { "/reply", "/r", "/rp" };
// 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. Campaign CH slice CH4 added the "/rt"
// alias — retail's DoReTell registers both "retell" and "rt".
private static readonly string[] RetellAliases = { "/retell", "/rt" };
// 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. "/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.
//
// Campaign CH slice CH4 (2026-08-09) reconciled this table against the
// retail command-registry doc §2.3/§2.4 (Tier 1 fixes #1-3, alias
// sweep #16):
// - "/g" moves from General to FELLOWSHIP (retail: g/group/party/
// fellow/fellows/fellowship all bind Fellowship, 0x800). Sending
// fellowship chatter to General was a live correctness bug.
// - "/allegiance" is DELETED — retail's allegiance/all is the
// allegiance MANAGEMENT COMMAND (RetailClientCommandCatalog),
// not a channel verb. The channel-send verbs are a/ab/guild/gu.
// - "/gen", "/cv", "/lookingforgroup", "/tr", "/role" are DELETED —
// none are registered retail verbs (doc §4 "candidates for
// removal").
// - Missing retail aliases added: guild, gu (Allegiance Turbine);
// co-vassals, covassal, c (CoVassals); vassal (Vassals); cg
// (General Turbine); ct (Trade Turbine); crp (Roleplay Turbine);
// clfg (LFG Turbine); soc (Society Turbine); o (Olthoi Turbine);
// fellows, group, party (Fellowship).
private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
{
("/general", ChatChannelKind.General),
("/cg", ChatChannelKind.General),
("/f", ChatChannelKind.Fellowship),
("/fellow", ChatChannelKind.Fellowship),
("/fellows", ChatChannelKind.Fellowship),
("/fellowship", ChatChannelKind.Fellowship),
("/g", ChatChannelKind.Fellowship),
("/group", ChatChannelKind.Fellowship),
("/party", ChatChannelKind.Fellowship),
("/a", ChatChannelKind.Allegiance),
("/guild", ChatChannelKind.Allegiance),
("/gu", ChatChannelKind.Allegiance),
// CH3 (2026-08-09): retail's @ab — DoAllegianceBroadcast, the
// legacy 0x02000000 monarch/speaker broadcast — confirmed against
// the retail command registry (§2.3/§2.5). "/allegiancebroadcast"
// was deliberately NOT added: it is not a registered retail verb
// (only "ab" is), and CH3's scope is "cross-check the registry;
// do not add verbs it doesn't have."
("/ab", ChatChannelKind.AllegianceBroadcast),
("/m", ChatChannelKind.Monarch),
("/monarch", ChatChannelKind.Monarch),
("/p", ChatChannelKind.Patron),
("/patron", ChatChannelKind.Patron),
("/v", ChatChannelKind.Vassals),
("/vassal", ChatChannelKind.Vassals),
("/vassals", ChatChannelKind.Vassals),
("/c", ChatChannelKind.CoVassals),
("/covassal", ChatChannelKind.CoVassals),
("/covassals", ChatChannelKind.CoVassals),
("/co-vassals", ChatChannelKind.CoVassals),
("/lfg", ChatChannelKind.Lfg),
("/clfg", ChatChannelKind.Lfg),
("/trade", ChatChannelKind.Trade),
("/ct", ChatChannelKind.Trade),
("/crp", ChatChannelKind.Roleplay),
("/roleplay", ChatChannelKind.Roleplay),
("/society", ChatChannelKind.Society),
("/soc", ChatChannelKind.Society),
("/olthoi", ChatChannelKind.Olthoi),
("/o", ChatChannelKind.Olthoi),
};
/// <summary>
/// The six retail channel groups whose registered-verb handler is
/// <c>ClientCommunicationSystem::DoStupidChannelHack @0x0057B144</c>
/// (command-registry doc §2.3) — the legacy 0x0147 channels. The
/// Turbine-only channels (General/Trade/Lfg/Roleplay/Society/Olthoi)
/// are never this handler's clients. <see cref="ChatChannelKind.Allegiance"/>
/// (retail's <c>@a</c>) still belongs here even though acdream always
/// sends it over Turbine once connected (<see cref="ChatChannelKind"/>'s
/// own doc comment) — DoStupidChannelHack dispatches on the REGISTERED
/// VERB, before Turbine send-time routing decides where the text goes;
/// retail's <c>"a"</c>/<c>"ab"</c> are one registered verb group.
/// </summary>
private static readonly HashSet<ChatChannelKind> LegacyChannelHackKinds =
[
ChatChannelKind.Fellowship,
ChatChannelKind.Allegiance,
ChatChannelKind.AllegianceBroadcast,
ChatChannelKind.Vassals,
ChatChannelKind.Patron,
ChatChannelKind.Monarch,
ChatChannelKind.CoVassals,
];
/// <summary>
/// True when <paramref name="trimmed"/> is one of the
/// <see cref="LegacyChannelHackKinds"/> verbs with NO message — retail's
/// <c>DoStupidChannelHack</c> "You must specify the text you wish to
/// say!" refusal (<c>0x1A</c> ClientLocal,
/// acclient_2013_pseudo_c.txt:1030730, data_7da9b0). This parser stays
/// pure (no side effects — <see cref="Parse"/> just returns
/// <see langword="null"/> for this shape); <see cref="ChatCommandRouter"/>
/// owns the actual refusal text and routes it to the SpewBox.
/// </summary>
public static bool IsBareRegisteredChannelVerb(string trimmed)
{
foreach (var (verb, channel) in ChannelVerbs)
{
if (LegacyChannelHackKinds.Contains(channel) && IsBareVerb(trimmed, [verb]))
return true;
}
return false;
}
/// <summary>
/// True when <paramref name="trimmed"/> is <c>/reply</c>/<c>/r</c>/
/// <c>/rp</c> WITH a message but no prior incoming Tell — retail's
/// <c>ClientCommunicationSystem::DoReply @0x00577910</c> "Someone must
/// @tell you first!" refusal (<c>0x1A</c> ClientLocal,
/// acclient_2013_pseudo_c.txt:387538, data_7da974), the
/// <c>gmCCommunicationSystem::GetLastTeller() == 0</c> branch. Bare
/// <c>/r</c> with no message at all is a DIFFERENT retail branch (its
/// own copy of the "you must specify text" string) and is deliberately
/// out of scope here — not named by register row AP-183 / issue #363.
/// </summary>
public static bool IsReplyMissingLastTeller(string trimmed, string? lastTellSender) =>
string.IsNullOrEmpty(lastTellSender) && TryParseMessageOnly(trimmed, ReplyAliases, out _);
/// <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 (IsKnownVerb(verb))
{
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
}
// Unknown @-verb — keep the original @ so ACE recognizes
// it server-side. Always emit as Say: ACE's GameActionTalk
// (the 0x0015 Talk action) is the ONLY wire path that parses
// @commands — on a chat channel the text would broadcast as
// ordinary channel speech. Retail likewise resolves commands
// before channel routing.
return new ParsedInput(ChatChannelKind.Say, 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 verb: retail treats / and @ as equivalent command
// prefixes, but ACE's GameActionTalk only intercepts the @ form on
// the wire (a /-prefixed Talk is plain speech server-side). Rewrite
// to @ and emit as Say — same reasoning as the unknown-@ branch at
// the top: only the Talk action parses commands. (Deliberate
// divergence from holtburger's literal fall-through, which would
// SAY "/ci 629" out loud.)
if (trimmed.Length > 1 && trimmed[0] == '/' && char.IsLetter(trimmed[1]))
return new ParsedInput(ChatChannelKind.Say, null, "@" + trimmed.Substring(1));
// Plain speech (no recognized verb): emit on the default channel
// so the user's text round-trips instead of being silently dropped.
return new ParsedInput(defaultChannel, null, trimmed);
}
// ── helpers ──────────────────────────────────────────────────────
/// <summary>
/// Match holtburger's <c>parse_targeted_chat_command</c>, corrected for
/// retail's ACTUAL <c>@tell</c> shape (Campaign CH slice CH4,
/// 2026-08-09): split on first whitespace into verb / rest, check verb
/// against aliases, then split rest on the FIRST COMMA into target /
/// message — retail's <c>DoTell @ 0x00577E40</c> requires a comma after
/// the name ("you must put a comma after the character's name",
/// acclient_2013_pseudo_c.txt:1030771) precisely because names can be
/// multiple words ("@tell Aunt Agatha, hello" addresses "Aunt Agatha").
/// Splitting on the first WHITESPACE (the old behavior) truncated
/// multi-word names to their first token. Returns false if either the
/// verb is wrong, there's no comma, 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 = TrimVerbComma(command.Substring(0, firstWs));
if (!ContainsExact(aliases, verb)) return false;
var rest = command.Substring(firstWs + 1).TrimStart();
if (rest.Length == 0) return false;
int commaIndex = rest.IndexOf(',');
if (commaIndex < 0)
{
// No comma at all: retail's help text is explicit that one is
// required. Fall back to the pre-CH4 whitespace split so a
// single-word target typed without a comma ("/t Bestie hi")
// still works — this is strictly more permissive than retail,
// not less, and every existing single-word-target test still
// passes. Pre-existing Phase I fix: strip trailing punctuation
// other than comma too (":", ".", "!", "?", ";") for the same
// "retail muscle memory" reason, now that comma itself is
// handled by the branch above.
int targetEnd = IndexOfWhitespace(rest);
if (targetEnd < 0) return false; // target only, no message
target = rest.Substring(0, targetEnd).TrimEnd(',', ';', ':', '.', '!', '?');
message = rest.Substring(targetEnd + 1).TrimStart();
}
else
{
target = rest.Substring(0, commaIndex).TrimEnd();
message = rest.Substring(commaIndex + 1).TrimStart();
}
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 = TrimVerbComma(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)
{
string trimmedVerb = TrimVerbComma(command);
foreach (var alias in aliases)
if (trimmedVerb == 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 = TrimVerbComma(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. Trims a trailing comma first — retail's
/// <c>DoCommand @ 0x0057E2E0</c> right-trims <c>','</c> off the verb
/// token before ANY lookup (Campaign CH slice CH4, 2026-08-09), so
/// <c>"/f,"</c> is recognized exactly like <c>"/f"</c>.
/// </summary>
public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(TrimVerbComma(verb));
/// <summary>
/// Every chat-alias / channel verb this parser recognizes (with
/// leading <c>/</c>). Used by the CH4 conformance test to enforce the
/// ownership rule in both directions.
/// </summary>
public static IReadOnlyCollection<string> KnownVerbs => AllKnownVerbs;
/// <summary>
/// Right-trim a trailing <c>','</c> from a verb token — retail's
/// <c>DoCommand</c> trim-char set (<c>0x0079452C</c>, right-trim only)
/// applied before every verb-hash-table lookup. <c>"@f, hi"</c> ≡
/// <c>"@f hi"</c>.
/// </summary>
private static string TrimVerbComma(string verb) => verb.TrimEnd(',');
/// <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);
}