feat(chat): Campaign CH slice CH6b — floating chat windows 1-4
Mounts retail's four floating chat windows as always-resident, born-hidden children per gmGamePlayUI::SetupChildren @0x004E9EC0, all sharing LayoutDesc 0x2100005B (window ids 0x10000505/0x1000050E/0x1000050F/0x10000510). New FloatingChatWindowController (AcDream.App/UI/Layout) binds each window's own widget tree — built fresh per instance from one shared imported ElementInfo — reusing ChatWindowController's word-wrap + retail color-carry algorithm via the extracted ChatTranscriptRenderer instead of duplicating it. A floaty window has no talk-focus menu (research doc §2.2), so its entry field always sends on Say; the mismatch against retail's possible shared-channel behavior is UNVERIFIED and filed as #369/AP-188. Runtime owns the per-window filter/open state: ChatWindowState (new, AcDream.Core.Chat) seeds retail's exact PostInit defaults per window (window 1 0x0000101C Speech/Tell/DirectSend/Emote, window 2 0x00040C00 Social/SocialSend/Allegiance, window 3 0x00080000 Fellowship, window 4 0x78000000 Turbine General/Trade/LFG/Roleplay) and implements the full ShouldDisplay(windowId, targetWindowId, logTextType) display predicate from ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640. It lives on RuntimeCommunicationState.ChatWindows so every host borrows the same instance. The main window's filter (0xFBFFFFFF, "no user filter") never actually gates anything because its own explicit-address branch already covers every broadcast line — that's why UpdateFromPlayerModule early-returns for window 0 in retail, ported here by construction rather than a special case. Keybind wiring: InputAction.ToggleFloatingChatWindow1..4 and their KeyBindings.RetailDefaults() chords already existed since Phase K.1c (unwired until now). The MetaKeys table confirms retail's default is Alt+1 through Alt+4 (index 3 = bit 0x00000004, cross-checked against the same file's Alt+A/D strafe and Alt+Enter/Tab/F4 rows). Routes through GameplayInputCommandController -> RetainedGameplayWindowCommands -> RetailUiRuntime.ToggleFloatingChatWindow -> the generic UiHost.ToggleWindow, whose visibility-change event is the single chokepoint that syncs ChatWindowState.SetOpen and mirrors the main window's 1-4 indicator button regardless of what changed a window's visibility (keybind, close button, or a restored layout). A direct decomp read of gmMainChatUI::ListenToElementMessage @0x004CDA80 — the only function in the whole binary that branches on a click message — settles what the research doc had left as a hedge: it handles exactly 0x1000046f (max/min) and the talk-focus menu's selection message, with NO case for 0x10000522-0x10000525. The four indicator buttons are PURE one-directional mirrors in retail; clicking them does nothing. ChatWindowController.SetIndicatorOpen ports this with no OnClick at all. Corrected research doc §1.4 accordingly. Persistence is local-only (register row AP-187; the retail 0x1000008C GameplayOptions wire remains deferred to CH6f): window geometry and open/visible state ride the existing generic RetailWindowLayoutPersistence path for free once each window registers under its own WindowNames entry; the four filter masks get a dedicated ChatSettings round-trip (ChatWindow1Filter..ChatWindow4Filter, defaulting to the retail PostInit constants) loaded at mount and saved alongside SaveLayout(). Tests: ChatWindowStateTests (defaults, TypeIsActive, the full display-rule matrix, toggle/reset, revision counter), FloatingChatWindowControllerTests (bind smoke tests against a synthetic 0x2100005B tree, per-window filter routing, filter-change cache invalidation, fixed-Say submit), new ChatWindowController.SetIndicatorOpen tests (Highlight/Normal state, cross-window isolation, range validation), GameplayInputCommandController routing for the four toggle actions, and a SettingsStore filter round-trip. Full Release suite: 12,392 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
41b408f3e6
commit
22020ef2c4
21 changed files with 1699 additions and 46 deletions
|
|
@ -0,0 +1,282 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke + filter-routing tests for
|
||||
/// <see cref="FloatingChatWindowController.Bind"/> — no dats, no GL,
|
||||
/// mirroring <c>ChatWindowControllerTests</c>'s synthetic-tree approach but
|
||||
/// against the floating layout's element topology
|
||||
/// (<c>0x2100005B</c>, research doc §2.2): the input row id
|
||||
/// (<c>0x10000509</c>) differs from the main window's
|
||||
/// (<c>0x10000013</c>), and there is no talk-focus menu / max-min button /
|
||||
/// indicator row.
|
||||
/// </summary>
|
||||
public class FloatingChatWindowControllerTests
|
||||
{
|
||||
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
|
||||
|
||||
private sealed class CaptureBus : ICommandBus
|
||||
{
|
||||
public readonly List<object> Published = new();
|
||||
public void Publish<T>(T cmd) where T : notnull => Published.Add(cmd!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthetic tree mirroring the floating chat layout topology:
|
||||
/// root (Type-3) [0x100004F7]
|
||||
/// transcriptPanel (Type-3) [0x10000010]
|
||||
/// transcript (Type-12, no media) [0x10000011]
|
||||
/// track (Type-3) [0x10000012]
|
||||
/// inputRow (Type-3) [0x10000509] ← the floaty-specific id
|
||||
/// input (Type-12, Editable+Selectable) [0x10000016]
|
||||
/// send (Type-3) [0x10000019]
|
||||
/// titleBar (Type-3) [0x100004D9]
|
||||
/// closeButton (Type-3) [0x1000052A]
|
||||
/// </summary>
|
||||
private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree(
|
||||
ChatLog? log = null)
|
||||
{
|
||||
var transcriptNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000011u, Type = 12,
|
||||
X = 5, Y = 20, Width = 224, Height = 60,
|
||||
};
|
||||
var trackNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000012u, Type = 3,
|
||||
X = 229, Y = 20, Width = 16, Height = 60,
|
||||
};
|
||||
var transcriptPanel = new ElementInfo
|
||||
{
|
||||
Id = 0x10000010u, Type = 3, X = 0, Y = 20, Width = 250, Height = 60,
|
||||
};
|
||||
transcriptPanel.Children.Add(transcriptNode);
|
||||
transcriptPanel.Children.Add(trackNode);
|
||||
|
||||
var inputNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000016u, Type = 12,
|
||||
X = 0, Y = 80, Width = 202, Height = 18,
|
||||
};
|
||||
var inputState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||
inputState.Properties.Values[0x16u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true };
|
||||
inputState.Properties.Values[0x20u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true };
|
||||
inputState.Properties.Values[0x27u] = new UiPropertyValue { Kind = UiPropertyKind.Bool, BoolValue = true };
|
||||
inputNode.States[UiStateInfo.DirectStateId] = inputState;
|
||||
var sendNode = new ElementInfo
|
||||
{
|
||||
Id = 0x10000019u, Type = 3, X = 202, Y = 80, Width = 38, Height = 18,
|
||||
};
|
||||
var inputRow = new ElementInfo
|
||||
{
|
||||
Id = 0x10000509u, Type = 3, X = 0, Y = 80, Width = 250, Height = 18,
|
||||
};
|
||||
inputRow.Children.Add(inputNode);
|
||||
inputRow.Children.Add(sendNode);
|
||||
|
||||
var titleBar = new ElementInfo { Id = 0x100004D9u, Type = 3, X = 0, Y = 0, Width = 240, Height = 16 };
|
||||
var closeButton = new ElementInfo { Id = 0x1000052Au, Type = 3, X = 225, Y = 1, Width = 14, Height = 14 };
|
||||
|
||||
var root = new ElementInfo { Id = 0x100004F7u, Type = 3, Width = 250, Height = 108 };
|
||||
root.Children.Add(transcriptPanel);
|
||||
root.Children.Add(inputRow);
|
||||
root.Children.Add(titleBar);
|
||||
root.Children.Add(closeButton);
|
||||
|
||||
var layout = LayoutImporter.Build(root, NoTex, null);
|
||||
var vm = new ChatVM(log ?? new ChatLog());
|
||||
return (root, layout, vm);
|
||||
}
|
||||
|
||||
// ── Bind smoke tests ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Bind_Returns_NonNull_OnValidTree()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState();
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
Assert.Equal(1, ctrl!.WindowId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(5)]
|
||||
public void Bind_InvalidWindowId_Throws(int windowId)
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState();
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
FloatingChatWindowController.Bind(
|
||||
windowId, rootInfo, layout, vm, () => bus, filters, null, null, NoTex));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_Returns_Null_WhenTranscriptPanelMissing()
|
||||
{
|
||||
var root = new ElementInfo { Id = 0x100004F7u, Type = 3, Width = 250, Height = 108 };
|
||||
var layout = LayoutImporter.Build(root, NoTex, null);
|
||||
var vm = new ChatVM(new ChatLog());
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState();
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
1, root, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
|
||||
Assert.Null(ctrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_Transcript_IsChildOfTranscriptPanel()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState();
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
2, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
var panel = layout.FindElement(0x10000010u);
|
||||
Assert.NotNull(panel);
|
||||
Assert.Contains(ctrl!.Transcript, panel!.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_Input_IsChildOfFloatyInputRow_NotMainWindowInputBar()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState();
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
3, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
var row = layout.FindElement(0x10000509u);
|
||||
Assert.NotNull(row);
|
||||
Assert.Contains(ctrl!.Input, row!.Children);
|
||||
}
|
||||
|
||||
// ── Chat entry always sends on Say (no talk-focus menu authored) ───────
|
||||
|
||||
[Fact]
|
||||
public void Bind_InputSubmit_AlwaysSendsOnSayChannel()
|
||||
{
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState();
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
4, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
|
||||
Assert.NotNull(ctrl);
|
||||
ctrl!.Input.OnSubmit!.Invoke("hi everyone");
|
||||
|
||||
var cmd = Assert.IsType<SendChatCmd>(Assert.Single(bus.Published));
|
||||
Assert.Equal(ChatChannelKind.Say, cmd.Channel);
|
||||
Assert.Equal("hi everyone", cmd.Text);
|
||||
}
|
||||
|
||||
// ── Display rule matrix: this window's filter accepts/rejects a line ───
|
||||
|
||||
[Fact]
|
||||
public void Transcript_ShowsOnlyLinesThisWindowsFilterAccepts()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var (rootInfo, layout, vm) = BuildTestTree(log);
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState(); // window 1 default: Speech/Tell/SpeechDirectSend/Emote
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
Assert.NotNull(ctrl);
|
||||
|
||||
log.OnSystemMessage("visible speech", chatType: 0x02u); // Speech — in window 1's filter
|
||||
log.OnSystemMessage("hidden social", chatType: 0x0Au); // Social — NOT in window 1's filter
|
||||
|
||||
var lines = ctrl!.Transcript.LinesProvider();
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Equal("visible speech", lines[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transcript_DifferentWindow_AcceptsADifferentSubsetOfTypes()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var (rootInfo, layout, vm) = BuildTestTree(log);
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState(); // window 3 default: Fellowship only
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
3, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
Assert.NotNull(ctrl);
|
||||
|
||||
log.OnSystemMessage("speech line", chatType: 0x02u); // Speech — not window 3's filter
|
||||
log.OnSystemMessage("fellowship line", chatType: 0x13u); // Fellowship — window 3's filter
|
||||
|
||||
var lines = ctrl!.Transcript.LinesProvider();
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Equal("fellowship line", lines[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transcript_FilterChange_IsReflectedOnNextRebuild()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var (rootInfo, layout, vm) = BuildTestTree(log);
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState();
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
2, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
Assert.NotNull(ctrl);
|
||||
|
||||
log.OnSystemMessage("speech line", chatType: 0x02u); // Speech — not window 2's default filter
|
||||
Assert.Empty(ctrl!.Transcript.LinesProvider());
|
||||
|
||||
// Widen window 2's filter to include Speech (bit 0x02 = 0x4) without
|
||||
// touching the chat log — the cached transcript must invalidate on
|
||||
// the filter change alone.
|
||||
filters.SetFilter(2, filters.GetFilter(2) | (1UL << 0x02));
|
||||
|
||||
var lines = ctrl.Transcript.LinesProvider();
|
||||
Assert.Single(lines);
|
||||
Assert.Equal("speech line", lines[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Transcript_UnchangedFilterAndContent_ReusesCachedLayout()
|
||||
{
|
||||
var log = new ChatLog();
|
||||
var (rootInfo, layout, vm) = BuildTestTree(log);
|
||||
var bus = new CaptureBus();
|
||||
var filters = new ChatWindowState();
|
||||
|
||||
var ctrl = FloatingChatWindowController.Bind(
|
||||
1, rootInfo, layout, vm, () => bus, filters, null, null, NoTex);
|
||||
Assert.NotNull(ctrl);
|
||||
|
||||
log.OnSystemMessage("speech line", chatType: 0x02u);
|
||||
var first = ctrl!.Transcript.LinesProvider();
|
||||
var unchanged = ctrl.Transcript.LinesProvider();
|
||||
|
||||
Assert.Same(first, unchanged);
|
||||
Assert.Equal(1, ctrl.TranscriptLayoutBuildCount);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue