feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline

Both the ImGui devtools ChatPanel and the upcoming retail chat window
now route through ChatCommandRouter.Submit so command handling lives in
one place. The ChatPanel inline block (TryHandleClientCommand / EqAny /
BuildHelpText) is deleted; ChatCommandRouter carries the same logic
verbatim. ChatPanel.Render becomes a 2-line delegate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-15 22:09:27 +02:00
parent 3d25e8760f
commit 50883e445b
3 changed files with 153 additions and 122 deletions

View file

@ -0,0 +1,78 @@
using System;
namespace AcDream.UI.Abstractions.Panels.Chat;
/// <summary>What a submit did, so the caller can clear its input + give feedback.</summary>
public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped }
/// <summary>
/// Shared chat-submit pipeline (retail <c>ChatInterface::ProcessCommand @0x4f5100</c>
/// analogue). Both the ImGui devtools <see cref="ChatPanel"/> and the retail
/// chat window route through here so command handling stays in one place.
///
/// Order mirrors the prior inline <see cref="ChatPanel"/> flow:
/// client-command intercept → unknown-slash-verb guard → <see cref="ChatInputParser.Parse"/>
/// → <c>Publish(SendChatCmd)</c>.
/// </summary>
public static class ChatCommandRouter
{
public static SubmitOutcome Submit(
string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel)
{
ArgumentNullException.ThrowIfNull(vm);
ArgumentNullException.ThrowIfNull(bus);
var trimmed = (raw ?? string.Empty).Trim();
if (trimmed.Length == 0) return SubmitOutcome.Empty;
if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled;
if (trimmed[0] == '/')
{
var verb = ChatInputParser.GetVerbToken(trimmed);
if (!ChatInputParser.IsKnownVerb(verb))
{
vm.ShowSystemMessage(
$"Unknown command: {verb}. Type /help for the list of supported commands.");
return SubmitOutcome.UnknownCommand;
}
}
var parsed = ChatInputParser.Parse(
trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget);
if (parsed is { } p)
{
bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text));
return SubmitOutcome.Sent;
}
return SubmitOutcome.Dropped;
}
private static bool TryHandleClientCommand(string trimmed, ChatVM vm)
{
if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
{ vm.ShowSystemMessage(BuildHelpText()); return true; }
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
{ vm.Clear(); return true; }
if (EqAny(trimmed, "/framerate", "@framerate"))
{ vm.ShowFps(); return true; }
if (EqAny(trimmed, "/loc", "@loc"))
{ vm.ShowLocation(); return true; }
return false;
}
private static bool EqAny(string s, params string[] options)
{
for (int i = 0; i < options.Length; i++)
if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
private static string BuildHelpText() =>
"Note: / and @ are equivalent prefixes.\n" +
"Chat: /say (default), /tell <name>, /reply, /retell\n" +
"Channels: /general /trade /fellowship /allegiance\n" +
" /patron /vassals /monarch /covassals\n" +
" /lfg /roleplay /society /olthoi\n" +
"Client: /help (this) /clear /framerate /loc\n" +
"Server: type @acehelp or @acecommands for ACE's full list.";
}

View file

@ -191,53 +191,7 @@ public sealed class ChatPanel : IPanel
if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
&& submitted is not null)
{
var trimmed = submitted.Trim();
// Phase J follow-up: client-side commands intercepted before
// the server-bound parse path. Avoids the /help round-trip
// that produced "Unknown command: help" duplicates from
// ACE's command-error replies, AND gives users a discoverable
// local cheat-sheet of acdream's own slash prefixes.
if (TryHandleClientCommand(trimmed))
{
_input = string.Empty;
renderer.EndChild(); // outer ##chatbody
renderer.End();
return;
}
// Phase J Tier 4: any /-prefixed input that ISN'T one of our
// known verbs gets a local "Unknown command" message instead
// of being broadcast to the server as plain speech. The
// user reported "/ls" / "/mp /path" leaking out as chat —
// a / prefix is a command, never speech. (@-prefixed unknown
// verbs still pass through to ACE because ACE's
// CommandManager intercepts @ server-side and replies with
// its own "Unknown command" / valid command output.)
if (trimmed.Length > 0 && trimmed[0] == '/')
{
string verb = ChatInputParser.GetVerbToken(trimmed);
if (!ChatInputParser.IsKnownVerb(verb))
{
_vm.ShowSystemMessage(
$"Unknown command: {verb}. Type /help for the list of supported commands.");
_input = string.Empty;
renderer.EndChild(); // outer ##chatbody
renderer.End();
return;
}
}
var parsed = ChatInputParser.Parse(
trimmed,
ChatChannelKind.Say,
_vm.LastIncomingTellSender,
_vm.LastOutgoingTellTarget);
if (parsed is { } p)
{
ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text));
}
// Defensive: if the backend ever forgot to clear on submit,
// do it here. Cheap; no harm if already empty.
ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say);
_input = string.Empty;
}
@ -258,79 +212,4 @@ public sealed class ChatPanel : IPanel
_ => new Vector4(1f, 1f, 1f, 1f),
};
/// <summary>
/// Phase J follow-up: handle client-side slash commands before
/// the parser passes anything to the server bus. Returns true
/// when the input was consumed (and the caller should clear the
/// buffer + skip the SendChatCmd path); false otherwise.
/// </summary>
/// <remarks>
/// Recognised client-side commands:
/// <list type="bullet">
/// <item><c>/help</c>, <c>/?</c>, <c>/h</c> — render the slash-prefix
/// cheat-sheet locally. Avoids the server's "Unknown command"
/// round-trip when the user just wants to know what they can
/// type.</item>
/// <item><c>/clear</c>, <c>/cls</c> — drain the chat log so the
/// panel starts empty.</item>
/// </list>
/// </remarks>
private bool TryHandleClientCommand(string trimmed)
{
if (trimmed.Length == 0) return false;
// /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence.
if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h"))
{
_vm.ShowSystemMessage(BuildHelpText());
return true;
}
// /clear, /cls — also @clear, @cls.
if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls"))
{
_vm.Clear();
return true;
}
// /framerate — also @framerate. Prints current FPS to chat.
if (EqAny(trimmed, "/framerate", "@framerate"))
{
_vm.ShowFps();
return true;
}
// /loc — also @loc. Prints current player position to chat.
// ACE has a server-side @loc too; client-side wins here
// (instantaneous + uses our local interpolated position).
if (EqAny(trimmed, "/loc", "@loc"))
{
_vm.ShowLocation();
return true;
}
return false;
}
/// <summary>Case-insensitive multi-string equality test.</summary>
private static bool EqAny(string s, params string[] options)
{
for (int i = 0; i < options.Length; i++)
if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
/// <summary>
/// Multi-line cheat-sheet text rendered by <c>/help</c>. ImGui's
/// <c>Text</c> path flows embedded newlines naturally so this lands
/// as one ChatLog entry that visually wraps to several lines.
/// </summary>
private static string BuildHelpText() =>
"Note: / and @ are equivalent prefixes.\n" +
"Chat: /say (default), /tell <name>, /reply, /retell\n" +
"Channels: /general /trade /fellowship /allegiance\n" +
" /patron /vassals /monarch /covassals\n" +
" /lfg /roleplay /society /olthoi\n" +
"Client: /help (this) /clear /framerate /loc\n" +
"Server: type @acehelp or @acecommands for ACE's full list.";
}

View file

@ -0,0 +1,74 @@
using AcDream.Core.Chat;
using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat;
using Xunit;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
public class ChatCommandRouterTests
{
private sealed class CaptureBus : ICommandBus
{
public SendChatCmd? Last;
public void Publish<T>(T command) where T : notnull
{
if (command is SendChatCmd c) Last = c;
}
}
private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture()
{
var log = new ChatLog();
var vm = new ChatVM(log, displayLimit: 50);
return (vm, log, new CaptureBus());
}
[Fact]
public void PlainText_PublishesOnDefaultChannel()
{
var (vm, _, bus) = Fixture();
var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.Sent, outcome);
Assert.NotNull(bus.Last);
Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel);
Assert.Equal("hello there", bus.Last.Text);
}
[Fact]
public void DefaultChannel_IsHonored()
{
var (vm, _, bus) = Fixture();
ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship);
Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel);
}
[Fact]
public void ClearCommand_DrainsLog_DoesNotPublish()
{
var (vm, log, bus) = Fixture();
log.OnSystemMessage("x", chatType: 0);
var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.ClientHandled, outcome);
Assert.Null(bus.Last);
Assert.Empty(log.Snapshot());
}
[Fact]
public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish()
{
var (vm, log, bus) = Fixture();
var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.UnknownCommand, outcome);
Assert.Null(bus.Last);
Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command"));
}
[Fact]
public void EmptyInput_DoesNothing()
{
var (vm, _, bus) = Fixture();
var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say);
Assert.Equal(SubmitOutcome.Empty, outcome);
Assert.Null(bus.Last);
}
}