feat(chat): the talk-focus menu's Tell-to / Squelch entries actually work
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 <noreply@anthropic.com>
This commit is contained in:
parent
10304f6dc2
commit
581a61ef0c
6 changed files with 209 additions and 17 deletions
|
|
@ -137,6 +137,21 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
||||||
|
|
||||||
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
|
// ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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".
|
||||||
|
/// </summary>
|
||||||
|
private enum TalkFocusSpecial
|
||||||
|
{
|
||||||
|
Squelch,
|
||||||
|
TellToSelected,
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? _tellTarget;
|
||||||
|
|
||||||
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
|
||||||
{
|
{
|
||||||
("Squelch (ignore)", null),
|
("Squelch (ignore)", null),
|
||||||
|
|
@ -225,7 +240,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
||||||
ChatWindowState windowFilters,
|
ChatWindowState windowFilters,
|
||||||
UiDatFont? datFont,
|
UiDatFont? datFont,
|
||||||
BitmapFont? debugFont,
|
BitmapFont? debugFont,
|
||||||
Func<uint, (uint tex, int w, int h)> resolve)
|
Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
Func<string?>? selectedTargetName = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(windowFilters);
|
ArgumentNullException.ThrowIfNull(windowFilters);
|
||||||
|
|
||||||
|
|
@ -319,7 +335,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
||||||
// its own authored background sprite (0x0600113A); same reasoning as the
|
// its own authored background sprite (0x0600113A); same reasoning as the
|
||||||
// transcript above.
|
// transcript above.
|
||||||
c.Input.SpriteResolve = resolve;
|
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
|
// Campaign CH user-gate round 1 (item G): the imported field's right
|
||||||
// edge otherwise holds a FIXED absolute pixel position across a
|
// 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.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
|
||||||
menu.PopupBgSprite = MenuPopupBg;
|
menu.PopupBgSprite = MenuPopupBg;
|
||||||
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
|
||||||
menu.Items = System.Array.ConvertAll(ChannelItems,
|
string? SelectedName() => selectedTargetName?.Invoke();
|
||||||
t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
|
|
||||||
|
// 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<UiMenu.MenuItem>(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;
|
menu.Selected = (object?)c._activeChannel;
|
||||||
// Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like
|
// Talk-CHANNEL items grey when the channel is unavailable; the two
|
||||||
// retail; only the talk-CHANNEL items grey when unavailable.
|
// specials grey when nothing is selected to act on — retail's
|
||||||
menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
|
// SetTalkFocusEnabled(2, 1) @0x004CD9B0 arms the tell slot only
|
||||||
menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
|
// once a talkable object is selected.
|
||||||
// The widget reports the pick; the controller owns Selected. Only a talk-channel
|
menu.EnabledProvider = p => p switch
|
||||||
// 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.
|
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 =>
|
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;
|
c.Menu = menu;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1525,7 +1525,17 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Chat.Windows,
|
_bindings.Chat.Windows,
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
_bindings.Assets.DebugFont,
|
_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)
|
if (controller is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F.");
|
Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F.");
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,17 @@ public sealed class UiMenu : UiElement
|
||||||
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
|
/// <summary>Fired with the picked item's payload when a row is chosen.</summary>
|
||||||
public Action<object?>? OnSelect { get; set; }
|
public Action<object?>? OnSelect { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised as the popup opens, before the rows are measured or drawn.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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".
|
||||||
|
/// </remarks>
|
||||||
|
public Action? OnOpen { get; set; }
|
||||||
|
|
||||||
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
|
/// <summary>Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled.</summary>
|
||||||
public Func<object?, bool>? EnabledProvider { get; set; }
|
public Func<object?, bool>? EnabledProvider { get; set; }
|
||||||
|
|
||||||
|
|
@ -258,6 +269,9 @@ public sealed class UiMenu : UiElement
|
||||||
private void SetOpen(bool value)
|
private void SetOpen(bool value)
|
||||||
{
|
{
|
||||||
if (_open == value) return;
|
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;
|
_open = value;
|
||||||
if (FindRoot() is not { } root) return;
|
if (FindRoot() is not { } root) return;
|
||||||
if (value) root.SetActivePopup(this, () => SetOpen(false));
|
if (value) root.SetActivePopup(this, () => SetOpen(false));
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ public static class ChatCommandRouter
|
||||||
string? raw,
|
string? raw,
|
||||||
IChatCommandFeedback feedback,
|
IChatCommandFeedback feedback,
|
||||||
ICommandBus bus,
|
ICommandBus bus,
|
||||||
ChatChannelKind defaultChannel)
|
ChatChannelKind defaultChannel,
|
||||||
|
string? defaultTellTarget = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(feedback);
|
ArgumentNullException.ThrowIfNull(feedback);
|
||||||
ArgumentNullException.ThrowIfNull(bus);
|
ArgumentNullException.ThrowIfNull(bus);
|
||||||
|
|
@ -166,7 +167,8 @@ public static class ChatCommandRouter
|
||||||
trimmed,
|
trimmed,
|
||||||
defaultChannel,
|
defaultChannel,
|
||||||
feedback.LastIncomingTellSender,
|
feedback.LastIncomingTellSender,
|
||||||
feedback.LastOutgoingTellTarget);
|
feedback.LastOutgoingTellTarget,
|
||||||
|
defaultTellTarget);
|
||||||
if (parsed is { } chat)
|
if (parsed is { } chat)
|
||||||
{
|
{
|
||||||
bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text));
|
bus.Publish(new SendChatCmd(chat.Channel, chat.TargetName, chat.Text));
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,8 @@ public static class ChatInputParser
|
||||||
string raw,
|
string raw,
|
||||||
ChatChannelKind defaultChannel,
|
ChatChannelKind defaultChannel,
|
||||||
string? lastTellSender,
|
string? lastTellSender,
|
||||||
string? lastOutgoingTellTarget = null)
|
string? lastOutgoingTellTarget = null,
|
||||||
|
string? defaultTellTarget = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
var trimmed = raw.Trim();
|
var trimmed = raw.Trim();
|
||||||
|
|
@ -219,7 +220,12 @@ public static class ChatInputParser
|
||||||
string verb = ExtractVerb(substituted);
|
string verb = ExtractVerb(substituted);
|
||||||
if (IsKnownVerb(verb))
|
if (IsKnownVerb(verb))
|
||||||
{
|
{
|
||||||
return Parse(substituted, defaultChannel, lastTellSender, lastOutgoingTellTarget);
|
return Parse(
|
||||||
|
substituted,
|
||||||
|
defaultChannel,
|
||||||
|
lastTellSender,
|
||||||
|
lastOutgoingTellTarget,
|
||||||
|
defaultTellTarget);
|
||||||
}
|
}
|
||||||
// Unknown @-verb — keep the original @ so ACE recognizes
|
// Unknown @-verb — keep the original @ so ACE recognizes
|
||||||
// it server-side. Always emit as Say: ACE's GameActionTalk
|
// 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
|
// Plain speech (no recognized verb): emit on the default channel
|
||||||
// so the user's text round-trips instead of being silently dropped.
|
// 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);
|
return new ParsedInput(defaultChannel, null, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ using AcDream.App.UI.Layout;
|
||||||
using AcDream.Core.Chat;
|
using AcDream.Core.Chat;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
using AcDream.UI.Abstractions.Panels.Chat;
|
using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
|
using AcDream.Runtime.Chat;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.UI.Layout;
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
|
@ -157,6 +158,76 @@ public class ChatWindowControllerTests
|
||||||
Assert.NotNull(ctrl);
|
Assert.NotNull(ctrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Talk-focus specials: "Tell to X" / "Squelch (ignore) X" ─────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Both entries act on the CURRENT SELECTION and carry its name, and both
|
||||||
|
/// used to be deliberate no-ops.
|
||||||
|
/// </summary>
|
||||||
|
[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<UiMenu>(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<SendChatCmd>(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<ExecuteClientCommandCmd>(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<UiMenu>(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 ──────
|
// ── Test 2: Transcript is placed as a child of the transcript panel ──────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue