acdream/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs
Erik 22020ef2c4 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>
2026-08-10 12:10:20 +02:00

230 lines
8.1 KiB
C#

using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Input;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
public sealed class GameplayInputCommandControllerTests
{
[Theory]
[InlineData(InputAction.ToggleInventoryPanel, "inventory")]
[InlineData(InputAction.AcdreamToggleDebugPanel, "debug")]
[InlineData(InputAction.AcdreamToggleFlyMode, "fly-or-chase")]
[InlineData(InputAction.AcdreamTogglePlayerMode, "player-mode")]
[InlineData(InputAction.ToggleChatEntry, "chat")]
[InlineData(InputAction.ToggleOptionsPanel, "settings")]
[InlineData(InputAction.CombatToggleCombat, "combat")]
[InlineData(InputAction.ToggleFloatingChatWindow1, "chat-window-1")]
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
[InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")]
[InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")]
public void RecognizedCommand_RoutesToTypedOwner(
InputAction action,
string expected)
{
var harness = new Harness();
bool handled = harness.Controller.Handle(action);
Assert.True(handled);
Assert.Equal([expected], harness.Calls);
}
[Fact]
public void DiagnosticCommand_PrecedesRemainingCommandSwitch()
{
var harness = new Harness();
harness.Diagnostics.HandledAction = InputAction.AcdreamToggleDebugPanel;
bool handled = harness.Controller.Handle(
InputAction.AcdreamToggleDebugPanel);
Assert.True(handled);
Assert.Equal(["diagnostic"], harness.Calls);
}
[Fact]
public void UnknownCommand_IsNotClaimed()
{
var harness = new Harness();
bool handled = harness.Controller.Handle(InputAction.MovementForward);
Assert.False(handled);
Assert.Empty(harness.Calls);
}
[Theory]
[InlineData(true, true, true, "cancel-target")]
[InlineData(false, true, true, "exit-fly")]
[InlineData(false, false, true, "exit-player")]
[InlineData(false, false, false, "close")]
public void Escape_PreservesTargetFlyPlayerWindowPriority(
bool targetMode,
bool flyMode,
bool playerMode,
string expected)
{
var harness = new Harness
{
TargetMode = { IsActive = targetMode },
Camera = { IsFly = flyMode },
Player = { IsPlayer = playerMode },
};
bool handled = harness.Controller.Handle(InputAction.EscapeKey);
Assert.True(handled);
Assert.Equal([expected], harness.Calls);
}
private sealed class Harness
{
public Harness()
{
Retained = new FakeRetained(Calls);
DevTools = new FakeDevTools(Calls);
Diagnostics = new FakeDiagnostics(Calls);
Player = new FakePlayerMode(Calls);
TargetMode = new FakeTargetMode(Calls);
Camera = new FakeCamera(Calls);
Combat = new FakeCombat(Calls);
Runtime = new FakeRuntimeView();
Window = new FakeWindow(Calls);
Controller = new GameplayInputCommandController(
Retained,
DevTools,
Diagnostics,
Player,
TargetMode,
Camera,
Runtime,
Combat,
Window);
}
public List<string> Calls { get; } = [];
public FakeRetained Retained { get; }
public FakeDevTools DevTools { get; }
public FakeDiagnostics Diagnostics { get; }
public FakePlayerMode Player { get; }
public FakeTargetMode TargetMode { get; }
public FakeCamera Camera { get; }
public FakeCombat Combat { get; }
public FakeRuntimeView Runtime { get; }
public FakeWindow Window { get; }
public GameplayInputCommandController Controller { get; }
}
private sealed class FakeRetained(List<string> calls)
: IRetainedGameplayWindowCommands
{
public void ToggleInventory() => calls.Add("inventory");
public void ToggleFloatingChatWindow(int windowId) =>
calls.Add($"chat-window-{windowId}");
}
private sealed class FakeDevTools(List<string> calls)
: IDevToolsGameplayCommands
{
public void ToggleDebugPanel() => calls.Add("debug");
public void FocusChatInput() => calls.Add("chat");
public void ToggleSettingsPanel() => calls.Add("settings");
}
private sealed class FakeDiagnostics(List<string> calls)
: IRuntimeDiagnosticCommands
{
public InputAction? HandledAction { get; set; }
public bool Handle(InputAction action)
{
if (action != HandledAction)
return false;
calls.Add("diagnostic");
return true;
}
public void CycleTimeOfDay() => calls.Add("time");
public void CycleWeather() => calls.Add("weather");
public void ToggleCollisionWireframes() => calls.Add("collision");
}
private sealed class FakePlayerMode(List<string> calls)
: IPlayerModeGameplayCommands
{
public bool IsPlayer { get; set; }
public bool IsPlayerMode => IsPlayer;
public void ToggleFlyOrChase() => calls.Add("fly-or-chase");
public void TogglePlayerMode() => calls.Add("player-mode");
public void ExitPlayerMode() => calls.Add("exit-player");
}
private sealed class FakeTargetMode(List<string> calls)
: IItemTargetModeCommands
{
public bool IsActive { get; set; }
public bool IsAnyTargetModeActive => IsActive;
public void CancelTargetMode() => calls.Add("cancel-target");
}
private sealed class FakeCamera(List<string> calls)
: IGameplayCameraModeCommands
{
public bool IsFly { get; set; }
public bool IsFlyMode => IsFly;
public void ExitFlyMode() => calls.Add("exit-fly");
}
private sealed class FakeCombat(List<string> calls) : IRuntimeCombatCommands
{
public RuntimeCommandResult Execute(
RuntimeGenerationToken expectedGeneration,
RuntimeCombatCommand command)
{
Assert.Equal(RuntimeCombatCommand.ToggleMode, command);
calls.Add("combat");
return new RuntimeCommandResult(
RuntimeCommandStatus.Accepted,
expectedGeneration);
}
public RuntimeCommandResult ExecuteAttack(
RuntimeGenerationToken expectedGeneration,
in RuntimeCombatAttackInput command) =>
new(
RuntimeCommandStatus.Unsupported,
expectedGeneration);
}
private sealed class FakeRuntimeView : IGameRuntimeView
{
public RuntimeGenerationToken Generation => new(7);
public RuntimeLifecycleSnapshot Lifecycle => throw new NotSupportedException();
public IGameRuntimeClock Clock => throw new NotSupportedException();
public IRuntimeEntityView Entities => throw new NotSupportedException();
public IRuntimeInventoryView Inventory => throw new NotSupportedException();
public IRuntimeInventoryStateView InventoryState =>
throw new NotSupportedException();
public IRuntimeCharacterView Character =>
throw new NotSupportedException();
public IRuntimeSocialView Social => throw new NotSupportedException();
public IRuntimeChatView Chat => throw new NotSupportedException();
public IRuntimeActionView Actions => throw new NotSupportedException();
public IRuntimeMovementView Movement => throw new NotSupportedException();
public AcDream.Runtime.World.IRuntimeWorldEnvironmentView Environment =>
throw new NotSupportedException();
public IRuntimePortalView Portal => throw new NotSupportedException();
public RuntimeStateCheckpoint CaptureCheckpoint() =>
throw new NotSupportedException();
}
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands
{
public void Close() => calls.Add("close");
}
}