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