feat(ui): finish retail toolbar controls

Discover all seven panel launchers from their DAT panel-id attributes, route mounted panels through event-driven retained window state, and ghost unavailable panels. Port Use and Examine selection/target behavior with exact Appraise dispatch and retail cursor modes.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 12:11:53 +02:00
parent 21fefce0e0
commit d3d1c895a0
19 changed files with 478 additions and 72 deletions

View file

@ -157,6 +157,33 @@ public sealed class CursorFeedbackControllerTests
Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind);
}
[Fact]
public void GenericUseAndExamineModes_driveRetailGlobalCursors()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
var c = new CursorFeedbackController(interaction);
Assert.True(interaction.UseSelectedOrEnterMode(0));
Assert.Equal(RetailGlobalCursorKind.Use,
c.Resolve(new CursorFeedbackSnapshot()).GlobalKind);
Assert.Equal(RetailGlobalCursorKind.UseFound,
c.Resolve(new CursorFeedbackSnapshot(HoverTargetGuid: Target)).GlobalKind);
interaction.CancelTargetMode();
Assert.True(interaction.ExamineSelectedOrEnterMode(0));
Assert.Equal(RetailGlobalCursorKind.Examine,
c.Resolve(new CursorFeedbackSnapshot()).GlobalKind);
Assert.Equal(RetailGlobalCursorKind.ExamineFound,
c.Resolve(new CursorFeedbackSnapshot(HoverTargetGuid: Target)).GlobalKind);
}
public static TheoryData<CursorFeedbackSnapshot, RetailGlobalCursorKind> GlobalCursorCases => new()
{
{ new(), RetailGlobalCursorKind.Default },

View file

@ -15,6 +15,7 @@ public sealed class ItemInteractionControllerTests
{
public readonly ClientObjectTable Objects = new();
public readonly List<uint> Uses = new();
public readonly List<uint> Examines = new();
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<uint> Drops = new();
@ -45,6 +46,7 @@ public sealed class ItemInteractionControllerTests
Objects,
playerGuid: () => Player,
sendUse: Uses.Add,
sendExamine: Examines.Add,
sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)),
sendWield: (item, mask) => Wields.Add((item, mask)),
sendDrop: Drops.Add,
@ -116,6 +118,38 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget);
}
[Fact]
public void ToolbarUse_withoutSelection_armsOneShotUseMode()
{
var h = new Harness();
h.AddContained(0x50000A10u, item => item.Useability = ItemUseability.Contained);
Assert.True(h.Controller.UseSelectedOrEnterMode(0u));
Assert.True(h.Controller.IsAnyTargetModeActive);
Assert.Equal(InteractionModeKind.Use, h.Controller.InteractionState.Current.Kind);
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
h.Controller.OfferPrimaryClick(0x50000A10u));
Assert.Equal(new[] { 0x50000A10u }, h.Uses);
Assert.False(h.Controller.IsAnyTargetModeActive);
}
[Fact]
public void ToolbarExamine_usesSelectionOrArmsOneShotExamineMode()
{
var h = new Harness();
Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player));
Assert.Equal(new[] { Player }, h.Examines);
Assert.True(h.Controller.ExamineSelectedOrEnterMode(0u));
Assert.Equal(InteractionModeKind.Examine, h.Controller.InteractionState.Current.Kind);
Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess,
h.Controller.OfferPrimaryClick(Pack));
Assert.Equal(new[] { Player, Pack }, h.Examines);
Assert.False(h.Controller.IsAnyTargetModeActive);
}
[Fact]
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
{

View file

@ -20,6 +20,18 @@ public class ToolbarControllerTests
private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u;
private const uint UseButtonId = 0x1000019Du;
private const uint ExamineButtonId = 0x100001A5u;
private static readonly (uint ElementId, uint PanelId)[] PanelButtons =
{
(0x10000197u, 12u),
(0x10000198u, 13u),
(CharacterButtonId, RetailPanelCatalog.Character),
(0x1000055Au, 25u),
(0x1000019Au, 16u),
(0x1000019Bu, 10u),
(InventoryButtonId, RetailPanelCatalog.Inventory),
};
private static (ImportedLayout layout, Dictionary<uint, UiItemList> slots,
Dictionary<uint, UiElement> indicators) FakeToolbar()
@ -37,8 +49,10 @@ public class ToolbarControllerTests
AddButton(id);
indicators[id] = dict[id];
}
AddButton(CharacterButtonId);
AddButton(InventoryButtonId);
foreach (var (elementId, panelId) in PanelButtons)
AddButton(elementId, panelId);
AddButton(UseButtonId);
AddButton(ExamineButtonId);
return (new ImportedLayout(root, dict), slots, indicators);
void AddSlot(uint id)
@ -47,7 +61,7 @@ public class ToolbarControllerTests
dict[id] = list; slots[id] = list; root.AddChild(list);
}
void AddButton(uint id)
void AddButton(uint id, uint? panelId = null)
{
var info = new ElementInfo
{
@ -59,6 +73,25 @@ public class ToolbarControllerTests
};
info.StateMedia["Normal"] = (0x1u, 1);
info.StateMedia["Highlight"] = (0x2u, 1);
info.StateMedia["Ghosted"] = (0x3u, 1);
if (panelId is { } value)
{
info.States[UiStateInfo.DirectStateId] = new UiStateInfo
{
Id = UiStateInfo.DirectStateId,
Properties = new UiPropertyBag
{
Values =
{
[0x10000029u] = new UiPropertyValue
{
Kind = UiPropertyKind.Enum,
UnsignedValue = value,
},
},
},
};
}
var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 };
dict[id] = button;
root.AddChild(button);
@ -140,25 +173,30 @@ public class ToolbarControllerTests
}
[Fact]
public void WindowToggleButtons_clickCallbacks_fire()
public void PanelButtons_bindByDatPanelId_andGhostUnavailablePanels()
{
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
int inventoryClicks = 0;
int characterClicks = 0;
var toggles = new List<uint>();
var ctrl = ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
ctrl.BindWindowToggles(
toggleInventory: () => inventoryClicks++,
toggleCharacter: () => characterClicks++);
ctrl.BindPanelButtons(
panelId => panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
toggles.Add);
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
Assert.Equal(1, inventoryClicks);
Assert.Equal(1, characterClicks);
Assert.Equal(new[] { RetailPanelCatalog.Inventory, RetailPanelCatalog.Character }, toggles);
foreach (var (elementId, panelId) in PanelButtons)
{
var button = (UiButton)layout.FindElement(elementId)!;
Assert.Equal(
panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character,
button.Enabled);
}
}
[Fact]
@ -195,9 +233,9 @@ public class ToolbarControllerTests
iconIds: (_,_,_,_,_) => 0u,
useItem: _ => { },
itemInteraction: interaction);
ctrl.BindWindowToggles(
toggleInventory: () => inventoryClicks++,
toggleCharacter: () => { });
ctrl.BindPanelButtons(
panelId => panelId == RetailPanelCatalog.Inventory,
_ => inventoryClicks++);
interaction.ActivateItem(kit);
((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click));
@ -271,19 +309,82 @@ public class ToolbarControllerTests
var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!;
var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!;
ctrl.SetInventoryOpen(true);
ctrl.SetCharacterOpen(true);
ctrl.BindPanelButtons(_ => true, _ => { });
ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, true);
ctrl.SetPanelOpen(RetailPanelCatalog.Character, true);
Assert.Equal("Highlight", inventoryButton.ActiveState);
Assert.Equal("Highlight", characterButton.ActiveState);
ctrl.SetInventoryOpen(false);
ctrl.SetCharacterOpen(false);
ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, false);
ctrl.SetPanelOpen(RetailPanelCatalog.Character, false);
Assert.Equal("Normal", inventoryButton.ActiveState);
Assert.Equal("Normal", characterButton.ActiveState);
}
[Fact]
public void RetailFixture_panelButtonsExposeExactDatPanelIds()
{
ImportedLayout layout = FixtureLoader.LoadToolbar();
foreach (var (elementId, panelId) in PanelButtons)
{
var button = Assert.IsType<UiButton>(layout.FindElement(elementId));
Assert.True(button.TryGetEnumAttribute(0x10000029u, out uint actual));
Assert.Equal(panelId, actual);
}
}
[Fact]
public void UseAndExamineButtons_dispatchSelectedObjectPolicy()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint item = 0x50000003u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
repo.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject
{
ObjectId = item,
Type = ItemType.Misc,
Useability = ItemUseability.Contained,
});
repo.MoveItem(item, pack, 0);
var uses = new List<uint>();
var examines = new List<uint>();
uint selected = item;
var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: uses.Add,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: examines.Add);
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
itemInteraction: interaction,
selectedObjectId: () => selected);
((UiButton)layout.FindElement(UseButtonId)!).OnClick!.Invoke();
((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke();
Assert.Equal(new[] { item }, uses);
Assert.Equal(new[] { item }, examines);
selected = 0;
((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke();
Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind);
}
// ── C1: combat-mode indicator tests ─────────────────────────────────────
[Fact]

View file

@ -201,6 +201,31 @@ public sealed class RetailWindowManagerTests
Assert.Equal(1, controller.DisposeCount);
}
[Fact]
public void VisibilityEvent_tracksEveryRegisteredWindowTransition()
{
var root = new UiRoot { Width = 800, Height = 600 };
var inventory = new UiPanel { Width = 100, Height = 100 };
root.AddChild(inventory);
root.RegisterWindow("inventory", inventory);
var transitions = new List<(string Name, bool Visible)>();
root.WindowManager.WindowVisibilityChanged +=
(name, visible) => transitions.Add((name, visible));
Assert.True(root.HideWindow("inventory"));
Assert.True(root.ShowWindow("inventory"));
Assert.False(root.ToggleWindow("inventory"));
Assert.Equal(
new[]
{
("inventory", false),
("inventory", true),
("inventory", false),
},
transitions);
}
private sealed class RecordingController : IRetainedPanelController
{
public int ShownCount { get; private set; }