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 lastTellSender → null
/// - 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);
}