From 581a61ef0c184b7293e9e12d90a2269d17b836ea Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 06:23:46 +0200 Subject: [PATCH] feat(chat): the talk-focus menu's Tell-to / Squelch entries actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both entries were deliberate no-ops — the code said so — and both showed a static label where retail shows the selected player's NAME. Retail builds them in gmMainChatUI::InitTalkFocusMenu @0x004CDC50 and rebuilds their labels every time the menu opens, substituting the selection through StringInfo::AddVariable_String (@0x004CD91C / @0x004CD982). So they now read "Tell to Dww" / "Squelch (ignore) Dww", rebuilt on open from a live selection provider, and grey out with nothing selected — retail arms the tell slot only for a talkable target (SetTalkFocusEnabled(2, 1) @0x004CD9B0). Picking "Tell to X" aims the chat bar at X. That needed one piece of plumbing: the parser's plain-speech fallthrough returned a null target, so a line typed under a Tell focus was dropped by the router for having no one to send to. Parse/Submit now carry an optional default tell target for exactly that case. "Squelch X" publishes the ALREADY-REGISTERED /squelch verb rather than reimplementing the request — the ModifyCharacterSquelch wire builder (CM_Communication::Event_ModifyCharacterSquelch @0x006A42D0) has been there all along; only the menu path to it was missing. UiMenu gains an OnOpen seam, because a menu whose Items are fixed at Bind can only ever say "Tell to Selected". It fires before _open flips so the rebuilt rows are measured and drawn in the same opening. Solution builds clean; 14,480 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 --- .../UI/Layout/ChatWindowController.cs | 100 +++++++++++++++--- src/AcDream.App/UI/RetailUiRuntime.cs | 12 ++- src/AcDream.App/UI/UiMenu.cs | 14 +++ src/AcDream.Runtime/Chat/ChatCommandRouter.cs | 6 +- src/AcDream.Runtime/Chat/ChatInputParser.cs | 23 +++- .../UI/Layout/ChatWindowControllerTests.cs | 71 +++++++++++++ 6 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 02fc4eb1..e1392ee0 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -137,6 +137,21 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ── + /// + /// The two non-channel entries retail puts at the head of the talk-focus + /// menu. Both act on the CURRENT SELECTION and carry its name in their + /// label — retail substitutes it via StringInfo::AddVariable_String + /// (gmMainChatUI @0x004CD91C / @0x004CD982), which is why the screenshot + /// reads "Tell to +Acdream" rather than "Tell to Selected". + /// + private enum TalkFocusSpecial + { + Squelch, + TellToSelected, + } + + private string? _tellTarget; + private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = { ("Squelch (ignore)", null), @@ -225,7 +240,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta ChatWindowState windowFilters, UiDatFont? datFont, BitmapFont? debugFont, - Func resolve) + Func resolve, + Func? selectedTargetName = null) { ArgumentNullException.ThrowIfNull(windowFilters); @@ -319,7 +335,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // its own authored background sprite (0x0600113A); same reasoning as the // transcript above. c.Input.SpriteResolve = resolve; - c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); + c.Input.OnSubmit = text => ChatCommandRouter.Submit( + text, vm, busProvider(), c._activeChannel, c._tellTarget); // Campaign CH user-gate round 1 (item G): the imported field's right // edge otherwise holds a FIXED absolute pixel position across a @@ -373,19 +390,78 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; menu.PopupBgSprite = MenuPopupBg; menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; - menu.Items = System.Array.ConvertAll(ChannelItems, - t => new UiMenu.MenuItem(t.Label, (object?)t.Channel)); + string? SelectedName() => selectedTargetName?.Invoke(); + + // Retail rebuilds these two labels around the selected name every + // time the menu opens; ours does the same from the live provider. + void RebuildItems() + { + string? target = SelectedName(); + var items = new List(ChannelItems.Length) + { + new(target is null + ? "Squelch (ignore)" + : $"Squelch (ignore) {target}", + TalkFocusSpecial.Squelch), + new(target is null + ? "Tell to Selected" + : $"Tell to {target}", + TalkFocusSpecial.TellToSelected), + }; + foreach ((string label, ChatChannelKind? channel) in ChannelItems) + { + if (channel is { } ch) + items.Add(new UiMenu.MenuItem(label, ch)); + } + menu.Items = items.ToArray(); + } + + RebuildItems(); menu.Selected = (object?)c._activeChannel; - // Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like - // retail; only the talk-CHANNEL items grey when unavailable. - menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch); - menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); - // The widget reports the pick; the controller owns Selected. Only a talk-channel - // payload updates the active channel + highlight — the null-payload specials are - // deferred no-ops (see the chat re-drive deferred list) and leave selection intact. + // Talk-CHANNEL items grey when the channel is unavailable; the two + // specials grey when nothing is selected to act on — retail's + // SetTalkFocusEnabled(2, 1) @0x004CD9B0 arms the tell slot only + // once a talkable object is selected. + menu.EnabledProvider = p => p switch + { + ChatChannelKind ch => ChannelAvailable(ch), + TalkFocusSpecial => SelectedName() is not null, + _ => true, + }; + menu.ButtonLabelProvider = () => + c._activeChannel == ChatChannelKind.Tell && c._tellTarget is { } t + ? t + : ChannelButtonLabel(c._activeChannel); + menu.OnOpen = RebuildItems; menu.OnSelect = p => { - if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; } + switch (p) + { + case ChatChannelKind ch: + c._activeChannel = ch; + c._tellTarget = null; + menu.Selected = p; + break; + + // Aims the chat bar at the selected player, so ordinary + // typed text goes to them as a tell. + case TalkFocusSpecial.TellToSelected when SelectedName() is { } name: + c._activeChannel = ChatChannelKind.Tell; + c._tellTarget = name; + menu.Selected = p; + break; + + // The /squelch verb is already registered and carries the + // ModifyCharacterSquelch request + // (CM_Communication::Event_ModifyCharacterSquelch + // @0x006A42D0); the menu entry is another way to reach it, + // not a second implementation. Selection is left alone. + case TalkFocusSpecial.Squelch when SelectedName() is { } squelched: + busProvider().Publish( + new ExecuteClientCommandCmd( + ClientCommandId.Squelch, squelched)); + break; + } }; c.Menu = menu; } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 9e7e9db9..faad50d6 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1525,7 +1525,17 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Chat.Windows, _bindings.Assets.DefaultFont, _bindings.Assets.DebugFont, - _bindings.Assets.ResolveSprite); + _bindings.Assets.ResolveSprite, + // The talk-focus menu's "Tell to X" / "Squelch (ignore) X" act on + // the current selection and carry its name. + selectedTargetName: () => + { + uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u; + if (selected == 0u) + return null; + string? name = _bindings.Toolbar.ResolveName(selected); + return string.IsNullOrWhiteSpace(name) ? null : name; + }); if (controller is null) { Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F."); diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 9de157ef..7b087934 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -28,6 +28,17 @@ public sealed class UiMenu : UiElement /// Fired with the picked item's payload when a row is chosen. public Action? OnSelect { get; set; } + /// + /// Raised as the popup opens, before the rows are measured or drawn. + /// + /// + /// Retail rebuilds the talk-focus menu's two selection-dependent entries + /// each time it opens (gmMainChatUI @0x004CD8E9), so their labels track + /// whoever is selected right now. A menu whose Items were fixed at Bind + /// could only ever say "Tell to Selected". + /// + public Action? OnOpen { get; set; } + /// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled. public Func? EnabledProvider { get; set; } @@ -258,6 +269,9 @@ public sealed class UiMenu : UiElement private void SetOpen(bool value) { if (_open == value) return; + // Before _open flips, so a handler that replaces Items is reflected in + // the very first measure/draw of this opening. + if (value) OnOpen?.Invoke(); _open = value; if (FindRoot() is not { } root) return; if (value) root.SetActivePopup(this, () => SetOpen(false)); diff --git a/src/AcDream.Runtime/Chat/ChatCommandRouter.cs b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs index f370f176..8a309367 100644 --- a/src/AcDream.Runtime/Chat/ChatCommandRouter.cs +++ b/src/AcDream.Runtime/Chat/ChatCommandRouter.cs @@ -31,7 +31,8 @@ public static class ChatCommandRouter string? raw, IChatCommandFeedback feedback, ICommandBus bus, - ChatChannelKind defaultChannel) + ChatChannelKind defaultChannel, + string? defaultTellTarget = null) { ArgumentNullException.ThrowIfNull(feedback); ArgumentNullException.ThrowIfNull(bus); @@ -166,7 +167,8 @@ public static class ChatCommandRouter trimmed, defaultChannel, feedback.LastIncomingTellSender, - feedback.LastOutgoingTellTarget); + feedback.LastOutgoingTellTarget, + defaultTellTarget); if (parsed is { } chat) { bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text)); diff --git a/src/AcDream.Runtime/Chat/ChatInputParser.cs b/src/AcDream.Runtime/Chat/ChatInputParser.cs index 6c05892a..846b0f49 100644 --- a/src/AcDream.Runtime/Chat/ChatInputParser.cs +++ b/src/AcDream.Runtime/Chat/ChatInputParser.cs @@ -201,7 +201,8 @@ public static class ChatInputParser string raw, ChatChannelKind defaultChannel, string? lastTellSender, - string? lastOutgoingTellTarget = null) + string? lastOutgoingTellTarget = null, + string? defaultTellTarget = null) { if (string.IsNullOrWhiteSpace(raw)) return null; var trimmed = raw.Trim(); @@ -219,7 +220,12 @@ public static class ChatInputParser string verb = ExtractVerb(substituted); if (IsKnownVerb(verb)) { - return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget); + return Parse( + substituted, + defaultChannel, + lastTellSender, + lastOutgoingTellTarget, + defaultTellTarget); } // Unknown @-verb — keep the original @ so ACE recognizes // it server-side. Always emit as Say: ACE's GameActionTalk @@ -281,6 +287,19 @@ public static class ChatInputParser // Plain speech (no recognized verb): emit on the default channel // so the user's text round-trips instead of being silently dropped. + // + // A Tell focus needs a target to go with it. Retail's talk-focus menu + // aims the chat bar at the SELECTED player + // (gmMainChatUI::InitTalkFocusMenu -> SetTalkFocusEnabled(2, 1) + // @0x004CD9B0), and without that name a plain line typed under a Tell + // focus is dropped by the router for having no target. + if (defaultChannel == ChatChannelKind.Tell) + { + return string.IsNullOrEmpty(defaultTellTarget) + ? null + : new ParsedInput(ChatChannelKind.Tell, defaultTellTarget, trimmed); + } + return new ParsedInput(defaultChannel, null, trimmed); } diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 071a1fd7..d2c1365c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -5,6 +5,7 @@ using AcDream.App.UI.Layout; using AcDream.Core.Chat; using AcDream.UI.Abstractions; using AcDream.UI.Abstractions.Panels.Chat; +using AcDream.Runtime.Chat; namespace AcDream.App.Tests.UI.Layout; @@ -157,6 +158,76 @@ public class ChatWindowControllerTests Assert.NotNull(ctrl); } + // ── Talk-focus specials: "Tell to X" / "Squelch (ignore) X" ───────────── + + /// + /// Both entries act on the CURRENT SELECTION and carry its name, and both + /// used to be deliberate no-ops. + /// + [Fact] + public void TalkFocusSpecials_CarryTheSelectedName_AndActOnIt() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + string? selected = "Dww"; + + ChatWindowController? ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex, + selectedTargetName: () => selected); + Assert.NotNull(ctrl); + UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u)); + + // Retail substitutes the name into both labels + // (StringInfo::AddVariable_String @0x004CD91C / @0x004CD982). + menu.OnOpen!.Invoke(); + Assert.Equal("Squelch (ignore) Dww", menu.Items[0].Label); + Assert.Equal("Tell to Dww", menu.Items[1].Label); + + // Picking "Tell to Dww" aims the chat bar at them, so an ordinary typed + // line is sent as a tell rather than being dropped for want of a target. + menu.OnSelect!.Invoke(menu.Items[1].Payload); + ctrl!.Input.OnSubmit!.Invoke("hello"); + SendChatCmd tell = Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ChatChannelKind.Tell, tell.Channel); + Assert.Equal("Dww", tell.TargetName); + Assert.Equal("hello", tell.Text); + + // Squelch reaches the already-registered /squelch verb rather than + // reimplementing the request. + bus.Published.Clear(); + menu.OnSelect.Invoke(menu.Items[0].Payload); + ExecuteClientCommandCmd squelch = + Assert.IsType(Assert.Single(bus.Published)); + Assert.Equal(ClientCommandId.Squelch, squelch.Command); + Assert.Equal("Dww", squelch.Arguments); + } + + [Fact] + public void TalkFocusSpecials_AreInertAndUnnamedWithNothingSelected() + { + var (rootInfo, layout, vm) = BuildTestTree(); + var bus = new CaptureBus(); + + ChatWindowController? ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex, + selectedTargetName: () => null); + Assert.NotNull(ctrl); + UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u)); + + menu.OnOpen!.Invoke(); + Assert.Equal("Squelch (ignore)", menu.Items[0].Label); + Assert.Equal("Tell to Selected", menu.Items[1].Label); + + // Retail arms the tell slot only once a talkable object is selected + // (SetTalkFocusEnabled(2, 1) @0x004CD9B0). + Assert.False(menu.EnabledProvider!(menu.Items[0].Payload)); + Assert.False(menu.EnabledProvider(menu.Items[1].Payload)); + + menu.OnSelect!.Invoke(menu.Items[1].Payload); + menu.OnSelect.Invoke(menu.Items[0].Payload); + Assert.Empty(bus.Published); + } + // ── Test 2: Transcript is placed as a child of the transcript panel ────── [Fact]