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>
This commit is contained in:
parent
3501194083
commit
a316d6359c
7 changed files with 497 additions and 22 deletions
|
|
@ -0,0 +1,131 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -41,8 +41,10 @@ public sealed class ChatPanelInputTests
|
|||
Assert.Empty(bus.Published);
|
||||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Equal(ChatKind.System, entry.Kind);
|
||||
Assert.Contains("acdream chat commands:", entry.Text);
|
||||
// Help text mentions / and @ equivalence and points at @acehelp
|
||||
// for the server's full command list.
|
||||
Assert.Contains("/tell", entry.Text);
|
||||
Assert.Contains("@acehelp", entry.Text);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -67,6 +69,73 @@ public sealed class ChatPanelInputTests
|
|||
Assert.Single(log.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_FramerateCommand_PrintsFpsAndDoesNotPublish()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log) { FpsProvider = () => 60f };
|
||||
var panel = new ChatPanel(vm);
|
||||
var bus = new RecordingBus();
|
||||
var renderer = new FakePanelRenderer
|
||||
{
|
||||
InputTextSubmitNextSubmitted = "/framerate",
|
||||
InputTextSubmitNextBufferAfter = "",
|
||||
};
|
||||
|
||||
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||
|
||||
Assert.Empty(bus.Published);
|
||||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Contains("60.0 FPS", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_LocCommand_PrintsPositionAndDoesNotPublish()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log)
|
||||
{
|
||||
PositionProvider = () => new System.Numerics.Vector3(10f, 20f, 30f),
|
||||
};
|
||||
var panel = new ChatPanel(vm);
|
||||
var bus = new RecordingBus();
|
||||
var renderer = new FakePanelRenderer
|
||||
{
|
||||
InputTextSubmitNextSubmitted = "@loc",
|
||||
InputTextSubmitNextBufferAfter = "",
|
||||
};
|
||||
|
||||
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||
|
||||
Assert.Empty(bus.Published);
|
||||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Contains("(10.0, 20.0, 30.0)", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_AtAcehelp_PassesThroughToSayWithAtIntact()
|
||||
{
|
||||
// Unknown @-verb falls through to the default channel with the
|
||||
// literal "@acehelp" text intact so ACE's CommandManager
|
||||
// intercepts it server-side. We DO publish a SendChatCmd here —
|
||||
// the publish is what carries the message to the server.
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
var panel = new ChatPanel(vm);
|
||||
var bus = new RecordingBus();
|
||||
var renderer = new FakePanelRenderer
|
||||
{
|
||||
InputTextSubmitNextSubmitted = "@acehelp",
|
||||
InputTextSubmitNextBufferAfter = "",
|
||||
};
|
||||
|
||||
panel.Render(new PanelContext(0.016f, bus), renderer);
|
||||
|
||||
var sendCmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ChatChannelKind.Say, sendCmd.Channel);
|
||||
Assert.Equal("@acehelp", sendCmd.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Submit_ClearCommand_DrainsLog_AndDoesNotPublish()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Phase J Tier 2: <see cref="ChatVM"/> tracks the last OUTGOING tell
|
||||
/// target (for <c>/retell</c>) and exposes optional FpsProvider /
|
||||
/// PositionProvider callbacks the panel uses to render
|
||||
/// <c>/framerate</c> and <c>/loc</c> client-side commands without
|
||||
/// reaching into render-loop state.
|
||||
/// </summary>
|
||||
public sealed class ChatVMRetellAndProvidersTests
|
||||
{
|
||||
[Fact]
|
||||
public void LastOutgoingTellTarget_StartsNull()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
Assert.Null(vm.LastOutgoingTellTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnSelfSentTell_PopulatesLastOutgoingTellTarget()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
log.OnSelfSent(ChatKind.Tell, "hi there", targetOrChannel: "Caith");
|
||||
|
||||
Assert.Equal("Caith", vm.LastOutgoingTellTarget);
|
||||
// Inbound-tell tracker must NOT pick up an outgoing echo —
|
||||
// the SenderGuid==0 discriminator separates the two paths.
|
||||
Assert.Null(vm.LastIncomingTellSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncomingTell_DoesNotTouchOutgoingTarget()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042);
|
||||
|
||||
Assert.Equal("Bestie", vm.LastIncomingTellSender);
|
||||
Assert.Null(vm.LastOutgoingTellTarget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutgoingThenIncoming_TracksBothIndependently()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
log.OnSelfSent(ChatKind.Tell, "hi", targetOrChannel: "Caith");
|
||||
log.OnTellReceived("Bestie", "psst", senderGuid: 0x5000_0042);
|
||||
|
||||
Assert.Equal("Caith", vm.LastOutgoingTellTarget);
|
||||
Assert.Equal("Bestie", vm.LastIncomingTellSender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowFps_WithProvider_AppendsFormattedLine()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log) { FpsProvider = () => 144.2f };
|
||||
|
||||
vm.ShowFps();
|
||||
|
||||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Equal(ChatKind.System, entry.Kind);
|
||||
Assert.Equal("Framerate: 144.2 FPS", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowFps_NoProvider_StillProducesDiagnosticLine()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
vm.ShowFps();
|
||||
|
||||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Contains("provider unavailable", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowLocation_WithProvider_AppendsFormattedLine()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log) { PositionProvider = () => new Vector3(123.4f, 567.8f, 60f) };
|
||||
|
||||
vm.ShowLocation();
|
||||
|
||||
var entry = Assert.Single(log.Snapshot());
|
||||
Assert.Equal("Location: (123.4, 567.8, 60.0)", entry.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowLocation_NoProvider_StillProducesDiagnosticLine()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var vm = new ChatVM(log);
|
||||
|
||||
vm.ShowLocation();
|
||||
|
||||
Assert.Contains("provider unavailable", log.Snapshot()[0].Text);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue