namespace AcDream.UI.Abstractions.Panels.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 → fall through to default channel /// carrying the literal text (matches holtburger /// handle_slash_command default arm at line ~744 — they /// send ClientCommand::Talk(command) with the original /// slash-prefixed string) /// 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" }; private static readonly string[] TellAliases = { "/tell", "/t" }; private static readonly string[] ReplyAliases = { "/reply", "/r" }; // 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. 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), }; /// /// 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 (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 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 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 ────────────────────────────────────────────────────── /// /// Match holtburger's parse_targeted_chat_command: 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. /// 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; } /// /// 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 = 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) { foreach (var alias in aliases) if (command == 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 = 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. /// public static bool IsKnownVerb(string verb) => AllKnownVerbs.Contains(verb); /// /// 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); }