namespace AcDream.Runtime.Chat; /// /// Phase I.4: pure-function parse of a chat-input line into the /// (channel, target?, text) triple a /// needs. Ported from holtburger /// references/holtburger/apps/holtburger-cli/src/pages/game/input/commands.rs /// — the alias table around line ~457 and the parse_targeted_chat_command /// / parse_message_only_command helpers near the top of the file. /// /// /// Edge cases handled (each pinned by a test in /// ChatInputParserTests): /// /// empty / whitespace-only → null /// /say with no message → null /// /t with no target / no message → null /// /r with no lastTellSendernull /// unknown /xyz verb → rewritten to @xyz when this pure /// parser is called directly. The production intercepts it first and publishes a typed /// . /// multi-word /t target: first whitespace token is target, /// rest is message (matches Rust split_once semantics) /// /// /// public static class ChatInputParser { /// /// Outcome of a successful parse. Pass straight into /// 's constructor. /// 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 — 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), }; /// /// The six retail channel groups whose registered-verb handler is /// ClientCommunicationSystem::DoStupidChannelHack @0x0057B144 /// (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. /// (retail's @a) still belongs here even though acdream always /// sends it over Turbine once connected ('s /// own doc comment) — DoStupidChannelHack dispatches on the REGISTERED /// VERB, before Turbine send-time routing decides where the text goes; /// retail's "a"/"ab" are one registered verb group. /// private static readonly HashSet LegacyChannelHackKinds = [ ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.AllegianceBroadcast, ChatChannelKind.Vassals, ChatChannelKind.Patron, ChatChannelKind.Monarch, ChatChannelKind.CoVassals, ]; /// /// True when is one of the /// verbs with NO message — retail's /// DoStupidChannelHack "You must specify the text you wish to /// say!" refusal (0x1A ClientLocal, /// acclient_2013_pseudo_c.txt:1030730, data_7da9b0). This parser stays /// pure (no side effects — just returns /// for this shape); /// owns the actual refusal text and routes it to the SpewBox. /// public static bool IsBareRegisteredChannelVerb(string trimmed) { foreach (var (verb, channel) in ChannelVerbs) { if (LegacyChannelHackKinds.Contains(channel) && IsBareVerb(trimmed, [verb])) return true; } return false; } /// /// True when is /reply//r/ /// /rp WITH a message but no prior incoming Tell — retail's /// ClientCommunicationSystem::DoReply @0x00577910 "Someone must /// @tell you first!" refusal (0x1A ClientLocal, /// acclient_2013_pseudo_c.txt:387538, data_7da974), the /// gmCCommunicationSystem::GetLastTeller() == 0 branch. Bare /// /r 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. /// public static bool IsReplyMissingLastTeller(string trimmed, string? lastTellSender) => string.IsNullOrEmpty(lastTellSender) && TryParseMessageOnly(trimmed, ReplyAliases, out _); /// /// Parse into a /// triple, or null if the input is empty / whitespace / /// missing required arguments (e.g. /t with no target). /// /// User input. The caller should /// before passing. /// Channel to use for unprefixed text /// or unrecognised slash commands. /// Sender of the most recent incoming /// Tell, used to resolve /r reply target. Null if no Tell /// has arrived this session. /// Target of the most recent /// outgoing Tell, used to resolve /retell. Null if the /// player hasn't sent a Tell yet this session. 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 if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg)) return new ParsedInput(ChatChannelKind.Say, null, sayMsg); if (IsBareVerb(trimmed, SayAliases)) return null; // /tell or /t 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 / /reply — 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 — 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 ────────────────────────────────────────────────────── /// /// Match holtburger's parse_targeted_chat_command, corrected for /// retail's ACTUAL @tell 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 DoTell @ 0x00577E40 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. /// 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; } /// /// Match holtburger's parse_message_only_command: split on /// first whitespace, alias-match the verb, return the trimmed rest /// as the message. Empty-message → false. /// 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; } /// /// True when is exactly one of the /// alias verbs (no message, no extra whitespace beyond trimming). /// private static bool IsBareVerb(string command, string[] aliases) { string trimmedVerb = TrimVerbComma(command); foreach (var alias in aliases) if (trimmedVerb == alias) return true; return false; } /// /// True when is "verb arg" with no /// further whitespace — e.g. /t Bestie (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. /// 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; } /// /// Pull the first whitespace-separated token from . /// 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. /// private static string ExtractVerb(string command) { int ws = IndexOfWhitespace(command); return ws < 0 ? command : command.Substring(0, ws); } /// /// Union of every alias verb (with leading /) the parser /// recognises. Used by the @-prefix passthrough decision: /// if the substituted verb is in this set, we normalize to the /// / path; otherwise the original @-prefixed text /// is preserved so ACE's command handler sees it. /// private static readonly HashSet AllKnownVerbs = BuildKnownVerbs(); private static HashSet BuildKnownVerbs() { var set = new HashSet(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; } /// /// Returns true if (with leading /// /) 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. @-prefixed verbs need to be normalized to /// / before passing. Trims a trailing comma first — retail's /// DoCommand @ 0x0057E2E0 right-trims ',' off the verb /// token before ANY lookup (Campaign CH slice CH4, 2026-08-09), so /// "/f," is recognized exactly like "/f". /// public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(TrimVerbComma(verb)); /// /// Every chat-alias / channel verb this parser recognizes (with /// leading /). Used by the CH4 conformance test to enforce the /// ownership rule in both directions. /// public static IReadOnlyCollection KnownVerbs => AllKnownVerbs; /// /// Right-trim a trailing ',' from a verb token — retail's /// DoCommand trim-char set (0x0079452C, right-trim only) /// applied before every verb-hash-table lookup. "@f, hi" ≡ /// "@f hi". /// private static string TrimVerbComma(string verb) => verb.TrimEnd(','); /// /// Pull the first whitespace-separated token (the command verb) /// from . Returns the entire string if /// there is no whitespace. /// public static string GetVerbToken(string command) => ExtractVerb(command); }