acdream/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs
Erik a316d6359c feat(chat): Phase J Tier 1+2 - @ verb prefix, /retell, /framerate, /loc
Three-tier rollout per the 2026-04-25 retail @help dump showing the
full ACE command surface. Tier 1 + most of Tier 2 in one commit.

TIER 1 - @ as / equivalent

ACE accepts both / and @ as verb prefixes (per its own help text:
"Note: You may substitute a forward slash (/) for the at symbol
(@)."). ChatInputParser now normalises @ to / for the verb-match
phase and re-enters parsing. Critical: for verbs we don't recognise
(@acehelp, @tele, @die, @version, @loc-on-server, @nonsense, ...),
the original @ is kept in the message text so ACE's CommandManager
intercepts the message server-side. If we substituted / there too,
ACE would treat it as plain Talk and broadcast it.

Result: @a hi / @tell Bob hi / @help / @clear / @reply / @retell
all route exactly like their / counterparts. @acehelp / @tele /
@version / @die etc. pass through to the server intact.

TIER 2 - client-only commands

- /retell <msg> (also @retell): resend to the last person you
  tell'd. Mirrors retail @retell. ChatVM tracks
  LastOutgoingTellTarget on each OnSelfSent(Tell, ...) entry —
  SenderGuid==0 distinguishes outgoing echo from inbound whispers,
  same way LastIncomingTellSender already worked. ChatInputParser
  takes a new optional lastOutgoingTellTarget param.

- /framerate (also @framerate): prints "Framerate: 144.2 FPS"
  into chat. Wired via a new ChatVM.FpsProvider Func<float>
  callback set by GameWindow at construction (closes over
  _lastFps). Falls back to "(provider unavailable)" if no
  callback is wired (tests / pre-live).

- /loc (also @loc): prints "Location: (123.4, 567.8, 60.0)" into
  chat. Wired via ChatVM.PositionProvider Func<Vector3> closing
  over GetDebugPlayerPosition() in GameWindow. ACE has a server-
  side @loc too; client wins here (instantaneous + uses the local
  interpolated position).

ChatPanel.TryHandleClientCommand grew @ aliases for /help /clear
/framerate /loc and the new EqAny helper for case-insensitive
multi-string matching. Help text rewritten to reference the
/ <-> @ equivalence and point at @acehelp / @acecommands for ACE's
full command list.

TIER 3 - automatic (no code)

Most retail @-commands (@allegiance motd, @afk, @die, @lifestone,
@corpse, @marketplace, @pkarena, @emote/@emotes, @fillcomps,
@permit, @consent, @squelch, @unsquelch, @messagetypes, @age,
@birth, @day, @endurance, @pklite, @version, @filter, @unfilter,
@loadfile, @log, @marketplace, ...) are server-side ACE commands.
Tier 1's passthrough takes care of them automatically — they
arrive via Talk, ACE recognises the @ and intercepts, replies via
SystemChat (which our 0xF7E0 wiring renders as [System] lines).

DEFERRED

- @saveui / @loadui / @lockui: ImGui layout save/load, ~1 hr
  standalone task. Filed for follow-up.
- @title <text>: rename chat window. ImGui window-id complications.
- Toggle-style @framerate (FPS overlay on/off): print-once is
  simpler and matches retail's most-common usage.

30 new tests:
- ChatInputParserAtPrefixTests: 11 covering @-prefix recognition,
  unknown-@ passthrough, /retell and @retell.
- ChatVMRetellAndProvidersTests: 8 covering LastOutgoingTellTarget
  tracking, FpsProvider/PositionProvider callbacks, no-provider
  fallback.
- ChatPanelInputTests: +3 (/framerate, @loc, @acehelp passthrough).

Solution total: 1063 green (243 Core.Net + 160 UI + 660 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:34:13 +02:00

131 lines
4.8 KiB
C#

using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
/// <summary>
/// Phase J Tier 1: ACE accepts <c>/</c> and <c>@</c> as equivalent verb
/// prefixes (per ACE help: "Note: You may substitute a forward slash
/// (/) for the at symbol (@)."). For verbs the parser recognizes, we
/// normalize <c>@</c> to <c>/</c> and re-enter parsing. For unknown
/// <c>@</c>-verbs (e.g. <c>@acehelp</c>, <c>@tele</c>, <c>@die</c>),
/// we keep the original <c>@</c> intact so ACE's CommandManager
/// intercepts the message server-side.
/// </summary>
public sealed class ChatInputParserAtPrefixTests
{
[Theory]
[InlineData("@a hi gang", ChatChannelKind.Allegiance, "hi gang")]
[InlineData("@allegiance recall", ChatChannelKind.Allegiance, "recall")]
[InlineData("@p heads up", ChatChannelKind.Patron, "heads up")]
[InlineData("@patron heads up", ChatChannelKind.Patron, "heads up")]
[InlineData("@f buff time", ChatChannelKind.Fellowship, "buff time")]
[InlineData("@g general msg", ChatChannelKind.General, "general msg")]
public void AtPrefix_KnownChannelVerb_RoutesSameAsSlash(string raw, ChatChannelKind expected, string text)
{
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(expected, parsed!.Value.Channel);
Assert.Null(parsed.Value.TargetName);
Assert.Equal(text, parsed.Value.Text);
}
[Fact]
public void AtTell_KnownVerb_RoutesAsTell()
{
var parsed = ChatInputParser.Parse("@tell Bestie hi", ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Bestie", parsed.Value.TargetName);
Assert.Equal("hi", parsed.Value.Text);
}
[Fact]
public void AtReply_NeedsLastIncomingTell_LikeSlashReply()
{
var with = ChatInputParser.Parse("@reply back at you", ChatChannelKind.Say, lastTellSender: "Bestie");
Assert.NotNull(with);
Assert.Equal(ChatChannelKind.Tell, with!.Value.Channel);
Assert.Equal("Bestie", with.Value.TargetName);
Assert.Equal("back at you", with.Value.Text);
var without = ChatInputParser.Parse("@reply hi", ChatChannelKind.Say, lastTellSender: null);
Assert.Null(without);
}
[Theory]
[InlineData("@acehelp")]
[InlineData("@acecommands")]
[InlineData("@tele 30 30 30")]
[InlineData("@die")]
[InlineData("@version")]
[InlineData("@loc")] // ACE has @loc server-side too; passes through
[InlineData("@nonsense filler")]
public void AtPrefix_UnknownVerb_PassesThroughIntactAsDefaultChannel(string raw)
{
// Critical: the @-prefix is preserved in Text so ACE's
// CommandManager recognizes the message as a server-side
// command when it arrives via Talk. If we substituted to /,
// ACE would treat it as plain speech and broadcast it.
var parsed = ChatInputParser.Parse(raw, ChatChannelKind.Say, lastTellSender: null);
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Say, parsed!.Value.Channel);
Assert.Equal(raw, parsed.Value.Text);
}
[Fact]
public void Retell_WithLastOutgoingTarget_RoutesToTell()
{
var parsed = ChatInputParser.Parse(
"/retell once more with feeling",
ChatChannelKind.Say,
lastTellSender: null,
lastOutgoingTellTarget: "Caith");
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Caith", parsed.Value.TargetName);
Assert.Equal("once more with feeling", parsed.Value.Text);
}
[Fact]
public void Retell_AtPrefix_AlsoWorks()
{
var parsed = ChatInputParser.Parse(
"@retell hi again",
ChatChannelKind.Say,
lastTellSender: null,
lastOutgoingTellTarget: "Caith");
Assert.NotNull(parsed);
Assert.Equal(ChatChannelKind.Tell, parsed!.Value.Channel);
Assert.Equal("Caith", parsed.Value.TargetName);
Assert.Equal("hi again", parsed.Value.Text);
}
[Fact]
public void Retell_NoPriorOutgoingTell_ReturnsNull()
{
var parsed = ChatInputParser.Parse(
"/retell hello",
ChatChannelKind.Say,
lastTellSender: "Bestie", // incoming is irrelevant for retell
lastOutgoingTellTarget: null);
Assert.Null(parsed);
}
[Fact]
public void Retell_BareVerb_ReturnsNull()
{
var parsed = ChatInputParser.Parse(
"/retell",
ChatChannelKind.Say,
lastTellSender: null,
lastOutgoingTellTarget: "Caith");
Assert.Null(parsed);
}
}