From a316d6359c4ce7482befd2c2962d05bee2bb71f3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Apr 2026 21:34:13 +0200 Subject: [PATCH] feat(chat): Phase J Tier 1+2 - @ verb prefix, /retell, /framerate, /loc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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 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 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 : 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) --- src/AcDream.App/Rendering/GameWindow.cs | 10 +- .../Panels/Chat/ChatInputParser.cs | 74 +++++++++- .../Panels/Chat/ChatPanel.cs | 52 +++++-- .../Panels/Chat/ChatVM.cs | 71 +++++++++- .../Chat/ChatInputParserAtPrefixTests.cs | 131 ++++++++++++++++++ .../Panels/Chat/ChatPanelInputTests.cs | 71 +++++++++- .../Chat/ChatVMRetellAndProvidersTests.cs | 110 +++++++++++++++ 7 files changed, 497 insertions(+), 22 deletions(-) create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs create mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4db989c..77fe759 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -917,7 +917,15 @@ public sealed class GameWindow : IDisposable // ChatPanel: reads the tail of the shared ChatLog. No GUID // dependency — works pre-login (empty) and post-login (live // tail of received speech/tells/channels/system msgs). - var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat); + // FpsProvider + PositionProvider plumb the runtime state + // the client-side /framerate and /loc commands need; the + // panel asks the VM, the VM asks GameWindow via these + // delegates, no panel-vs-renderer-vs-state coupling. + var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat) + { + FpsProvider = () => (float)_lastFps, + PositionProvider = () => GetDebugPlayerPosition(), + }; _panelHost.Register( new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm)); diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs index 138ed48..2e41997 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatInputParser.cs @@ -42,6 +42,10 @@ public static class ChatInputParser private static readonly string[] SayAliases = { "/say", "/s" }; private static readonly string[] TellAliases = { "/tell", "/t" }; private static readonly string[] ReplyAliases = { "/reply", "/r" }; + // 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. + private static readonly string[] RetellAliases = { "/retell" }; // Channel aliases. Each maps a single verb token to a channel kind. // The same list drives both the verb test and the prefix-strip. @@ -90,11 +94,38 @@ public static class ChatInputParser /// Sender of the most recent incoming /// Tell, used to resolve /r reply target. Null if no Tell /// has arrived this session. - public static ParsedInput? Parse(string raw, ChatChannelKind defaultChannel, string? lastTellSender) + /// 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 (AllKnownVerbs.Contains(verb)) + { + return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget); + } + // Unknown @-verb — keep the original @ so ACE recognizes + // it server-side when the message arrives via Talk. + return new ParsedInput(defaultChannel, null, trimmed); + } + // /say if (TryParseMessageOnly(trimmed, SayAliases, out var sayMsg)) return new ParsedInput(ChatChannelKind.Say, null, sayMsg); @@ -116,6 +147,15 @@ public static class ChatInputParser 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) { @@ -232,4 +272,36 @@ public static class ChatInputParser 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; + } } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index 4c90dd2..b54621f 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -107,7 +107,11 @@ public sealed class ChatPanel : IPanel return; } - var parsed = ChatInputParser.Parse(trimmed, ChatChannelKind.Say, _vm.LastIncomingTellSender); + 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)); @@ -154,21 +158,44 @@ public sealed class ChatPanel : IPanel { if (trimmed.Length == 0) return false; - if (trimmed.Equals("/help", StringComparison.OrdinalIgnoreCase) || - trimmed.Equals("/?", StringComparison.OrdinalIgnoreCase) || - trimmed.Equals("/h", StringComparison.OrdinalIgnoreCase)) + // /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence. + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) { _vm.ShowSystemMessage(BuildHelpText()); return true; } - if (trimmed.Equals("/clear", StringComparison.OrdinalIgnoreCase) || - trimmed.Equals("/cls", StringComparison.OrdinalIgnoreCase)) + // /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; + } + + /// Case-insensitive multi-string equality test. + 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; } @@ -178,10 +205,11 @@ public sealed class ChatPanel : IPanel /// as one ChatLog entry that visually wraps to several lines. /// private static string BuildHelpText() => - "acdream chat commands:\n" + - " /say (default), /tell , /reply, /general, /trade,\n" + - " /fellowship, /allegiance, /patron, /vassals, /monarch,\n" + - " /covassals, /lfg, /roleplay, /society, /olthoi\n" + - " /help (this), /clear (clear chat tail)\n" + - "ACE server commands: prefix with @ (e.g. @acehelp, @acecommands)."; + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /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."; } diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 0099e12..41b9158 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; @@ -42,6 +43,32 @@ public sealed class ChatVM /// public string? LastIncomingTellSender { get; private set; } + /// + /// Target of the most recent OUTGOING Tell (the player's own + /// /tell <name> …). Drives the /retell <msg> + /// (or @retell) slash command, which resends to the same + /// target. Mirrors retail's @retell. Self-sent echoes flow + /// through with + /// SenderGuid == 0 and the target name in Sender — + /// that's the discriminator we capture here. + /// + public string? LastOutgoingTellTarget { get; private set; } + + /// + /// Optional callback exposing the live framerate. Wired by + /// GameWindow at construction so the client-side + /// /framerate command can print "Framerate: 144.2 FPS" + /// into chat without the panel knowing about the render-loop. + /// + public Func? FpsProvider { get; init; } + + /// + /// Optional callback exposing the local player's world position. + /// Used by /loc to print + /// "Location: (123.4, 567.8, 60.0)". Wired by GameWindow. + /// + public Func? PositionProvider { get; init; } + /// /// Build a ChatVM bound to a instance. /// @@ -62,15 +89,17 @@ public sealed class ChatVM private void OnEntryAppended(ChatEntry entry) { - // Only INCOMING tells update the reply target. Self-sent - // echoes from OnSelfSent always carry SenderGuid == 0 (we - // never know our own guid here), so guid == 0 is a safe - // discriminator for "skip this one". An incoming tell from - // a player always carries the sender's real guid. + // Tell-tracking discriminator (SenderGuid): + // != 0 = real incoming whisper; capture sender for /reply. + // == 0 = our own outgoing echo via OnSelfSent; capture the + // target (Sender field) for /retell. if (entry.Kind != ChatKind.Tell) return; - if (entry.SenderGuid == 0) return; if (string.IsNullOrEmpty(entry.Sender)) return; - LastIncomingTellSender = entry.Sender; + + if (entry.SenderGuid != 0) + LastIncomingTellSender = entry.Sender; + else + LastOutgoingTellTarget = entry.Sender; } /// @@ -85,6 +114,34 @@ public sealed class ChatVM /// public void Clear() => _log.Clear(); + /// + /// Print the current framerate into chat. Used by + /// /framerate / @framerate. Falls back to a + /// helpful diagnostic line if no + /// is wired (test / pre-live-session scenarios). + /// + public void ShowFps() + { + var fps = FpsProvider?.Invoke(); + ShowSystemMessage(fps is null + ? "Framerate: (provider unavailable)" + : $"Framerate: {fps.Value:F1} FPS"); + } + + /// + /// Print the local player's world position into chat. Used by + /// /loc / @loc. Falls back to a helpful + /// diagnostic line if no is + /// wired (pre-EnterWorld / tests). + /// + public void ShowLocation() + { + var pos = PositionProvider?.Invoke(); + ShowSystemMessage(pos is null + ? "Location: (provider unavailable)" + : $"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})"); + } + /// /// Snapshot the tail of the chat log, formatted as display strings, /// oldest-first. Never returns null; returns an empty array if the diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs new file mode 100644 index 0000000..67477e4 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatInputParserAtPrefixTests.cs @@ -0,0 +1,131 @@ +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Phase J Tier 1: ACE accepts / and @ 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 @ to / and re-enter parsing. For unknown +/// @-verbs (e.g. @acehelp, @tele, @die), +/// we keep the original @ intact so ACE's CommandManager +/// intercepts the message server-side. +/// +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); + } +} diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 0c12a84..3496a42 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -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(Assert.Single(bus.Published)); + Assert.Equal(ChatChannelKind.Say, sendCmd.Channel); + Assert.Equal("@acehelp", sendCmd.Text); + } + [Fact] public void Submit_ClearCommand_DrainsLog_AndDoesNotPublish() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs new file mode 100644 index 0000000..a29cca1 --- /dev/null +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.UI.Abstractions.Tests.Panels.Chat; + +/// +/// Phase J Tier 2: tracks the last OUTGOING tell +/// target (for /retell) and exposes optional FpsProvider / +/// PositionProvider callbacks the panel uses to render +/// /framerate and /loc client-side commands without +/// reaching into render-loop state. +/// +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); + } +}