feat(ui): #17 ChatPanel input field + slash commands + reply-to-last-tell
ChatPanel gains an Enter-to-submit input field via the I.1 InputTextSubmit widget. Submitted text routes through ChatInputParser to a SendChatCmd published on ctx.Commands; LiveCommandBus (I.3) handles the wire send + ChatLog echo. Recognised prefixes (ported from holtburger commands.rs): /say msg or no prefix -> Say /t Name msg or /tell -> Tell (first whitespace token = target) /r msg -> Tell (target = LastIncomingTellSender) /g msg -> General /f msg -> Fellowship /a msg -> Allegiance /m msg -> Monarch /p msg -> Patron /v msg -> Vassals /cv msg -> CoVassals /lfg msg -> Lfg /trade msg -> Trade /role msg -> Roleplay /society msg -> Society /olthoi msg -> Olthoi Edge cases: empty / whitespace / cmd-without-message / /r without prior tell -> null (no-op). Unknown /xyz prefix -> Say with literal text (matches holtburger's Talk(command) default arm). ChatVM.LastIncomingTellSender populated only on incoming Tell entries; discriminated by SenderGuid != 0 (OnSelfSent echoes always carry 0). 32 new tests: - ChatInputParserTests: 22 covering every prefix + edge case - ChatVMLastTellSenderTests: 6 covering capture + skip rules - ChatPanelInputTests: 6 using FakePanelRenderer + recording ICommandBus to assert publish behaviour UI.Abstractions.Tests: 60 -> 92. Solution total: 934 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8e6e5a0b61
commit
f14296c75f
6 changed files with 710 additions and 10 deletions
210
src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
Normal file
210
src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.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 → fall through to default channel
|
||||
/// carrying the literal text (matches holtburger
|
||||
/// <c>handle_slash_command</c> default arm at line ~744 — they
|
||||
/// send <c>ClientCommand::Talk(command)</c> with the original
|
||||
/// slash-prefixed string)</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" };
|
||||
private static readonly string[] TellAliases = { "/tell", "/t" };
|
||||
private static readonly string[] ReplyAliases = { "/reply", "/r" };
|
||||
|
||||
// Channel aliases. Each maps a single verb token to a channel kind.
|
||||
// The same list drives both the verb test and the prefix-strip.
|
||||
private static readonly (string Verb, ChatChannelKind Channel)[] ChannelVerbs =
|
||||
{
|
||||
("/g", ChatChannelKind.General),
|
||||
("/f", ChatChannelKind.Fellowship),
|
||||
("/a", ChatChannelKind.Allegiance),
|
||||
("/m", ChatChannelKind.Monarch),
|
||||
("/p", ChatChannelKind.Patron),
|
||||
("/v", ChatChannelKind.Vassals),
|
||||
("/cv", ChatChannelKind.CoVassals),
|
||||
("/lfg", ChatChannelKind.Lfg),
|
||||
("/trade", ChatChannelKind.Trade),
|
||||
("/role", ChatChannelKind.Roleplay),
|
||||
("/society", ChatChannelKind.Society),
|
||||
("/olthoi", ChatChannelKind.Olthoi),
|
||||
};
|
||||
|
||||
/// <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>
|
||||
public static ParsedInput? Parse(string raw, ChatChannelKind defaultChannel, string? lastTellSender)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var trimmed = raw.Trim();
|
||||
|
||||
// /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;
|
||||
|
||||
// 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 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Match holtburger's <c>parse_targeted_chat_command</c>: 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.
|
||||
/// </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 = 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();
|
||||
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 = 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)
|
||||
{
|
||||
foreach (var alias in aliases)
|
||||
if (command == 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 = 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue