feat(ui): centralize retail selection state

This commit is contained in:
Erik 2026-07-11 00:51:20 +02:00
parent c7607f019c
commit 7983309d23
30 changed files with 591 additions and 108 deletions

View file

@ -0,0 +1,32 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class InteractionStateTests
{
[Fact]
public void Modes_HaveOneOwnerAndOneDeduplicatedTransitionStream()
{
var state = new InteractionState();
var changes = new List<InteractionModeTransition>();
state.Changed += changes.Add;
Assert.True(state.EnterUse());
Assert.False(state.EnterUse());
Assert.True(state.EnterExamine());
Assert.True(state.EnterUseItemOnTarget(0x100u));
Assert.True(state.Clear());
Assert.Equal(4, changes.Count);
Assert.Equal(InteractionModeKind.None, state.Current.Kind);
Assert.Equal(0x100u, changes[2].Current.SourceObjectId);
}
[Fact]
public void UseItemOnTarget_RejectsMissingSource()
{
var state = new InteractionState();
Assert.Throws<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0));
Assert.Equal(InteractionMode.None, state.Current);
}
}

View file

@ -77,6 +77,10 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.IsTargetModeActive);
Assert.True(h.Controller.IsPendingSource(0x50000A01u));
Assert.Equal(InteractionModeKind.UseItemOnTarget,
h.Controller.InteractionState.Current.Kind);
Assert.Equal(0x50000A01u,
h.Controller.InteractionState.Current.SourceObjectId);
Assert.Empty(h.UseWithTarget);
}

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -56,7 +57,8 @@ public class InventoryControllerTests
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
List<(uint item, uint container, int placement)>? puts = null,
string? ownerName = null,
Action? onClose = null)
Action? onClose = null,
SelectionState? selection = null)
=> InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null,
@ -64,7 +66,8 @@ public class InventoryControllerTests
sendUse: uses is null ? null : g => uses.Add(g),
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
onClose: onClose);
onClose: onClose,
selection: selection ?? new SelectionState());
private static UiButton MakeButton(uint id)
{
@ -316,7 +319,8 @@ public class InventoryControllerTests
{
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null,
iconIds: (_, _, _, _, _) => 0u, strength: () => 100,
selection: new SelectionState(), datFont: null,
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
@ -414,6 +418,27 @@ public class InventoryControllerTests
Assert.Empty(closes);
}
[Fact]
public void ClickGridItem_updatesSharedSelection_andExternalSelectionMovesSquare()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xAu, Player, slot: 0);
SeedContained(objects, 0xBu, Player, slot: 1);
var selection = new SelectionState();
Bind(layout, objects, selection: selection);
grid.GetItem(0)!.Clicked!();
Assert.Equal(0xAu, selection.SelectedObjectId);
Assert.True(grid.GetItem(0)!.Selected);
selection.Select(0xBu, SelectionChangeSource.World);
Assert.False(grid.GetItem(0)!.Selected);
Assert.True(grid.GetItem(1)!.Selected);
}
[Fact]
public void TargetMode_suppressesSelectedSquare_onPendingSource()
{
@ -439,6 +464,7 @@ public class InventoryControllerTests
InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
itemInteraction: interaction);
grid.GetItem(0)!.Clicked!();
@ -469,7 +495,7 @@ public class InventoryControllerTests
(ItemType type, uint icon)? mainPackCall = null;
InventoryController.Bind(layout, objects, () => Player,
iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; },
strength: () => 100, datFont: null);
strength: () => 100, selection: new SelectionState(), datFont: null);
// Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons
// IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22).

View file

@ -110,6 +110,7 @@ public class InventoryFrameImportProbe
playerGuid: static () => 0u,
iconIds: static (_, _, _, _, _) => 0u,
strength: static () => 100,
selection: new AcDream.Core.Selection.SelectionState(),
datFont: null,
onClose: () => closes++);

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -34,11 +35,13 @@ public class PaperdollControllerTests
}
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u)
List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u,
SelectionState? selection = null)
=> PaperdollController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0x1234u,
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)),
emptySlotSprite: emptySlot);
emptySlotSprite: emptySlot,
selection: selection ?? new SelectionState());
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
{
@ -63,6 +66,21 @@ public class PaperdollControllerTests
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
}
[Fact]
public void ClickEquippedItem_updatesSharedSelection()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
var selection = new SelectionState();
Bind(layout, objects, selection: selection);
lists[HeadSlot].Cell.Clicked!();
Assert.Equal(0xA01u, selection.SelectedObjectId);
Assert.True(lists[HeadSlot].Cell.Selected);
}
[Fact]
public void Populate_matches_a_weapon_into_the_composite_slot()
{

View file

@ -4,6 +4,7 @@ using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Selection;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -65,7 +66,7 @@ public class SelectedObjectControllerTests
private sealed class Harness
{
public Action<uint?>? SelectionHandler;
public readonly SelectionState Selection = new();
public Action<uint, float>? HealthHandler;
public readonly List<uint> QueryHealthCalls = new();
@ -75,17 +76,19 @@ public class SelectedObjectControllerTests
public readonly Dictionary<uint, bool> HasHealthMap = new();
public readonly Dictionary<uint, uint> StackMap = new();
public void FireSelection(uint? g) => SelectionHandler?.Invoke(g);
public void FireSelection(uint? g)
{
if (g is { } guid)
Selection.Select(guid, SelectionChangeSource.System);
else
Selection.Clear(SelectionChangeSource.System);
}
public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct);
public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null)
=> SelectedObjectController.Bind(
layout,
subscribeSelectionChanged: h => SelectionHandler = h,
unsubscribeSelectionChanged: h =>
{
if (SelectionHandler == h) SelectionHandler = null;
},
selection: Selection,
subscribeHealthChanged: h => HealthHandler = h,
unsubscribeHealthChanged: h =>
{
@ -362,7 +365,6 @@ public class SelectedObjectControllerTests
h.NameMap[0x12345678u] = "Something";
var c = h.Bind(layout);
Assert.NotNull(h.SelectionHandler);
Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u)));
Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f)));
Assert.Null(Record.Exception(() => c.Tick(0.5)));
@ -425,7 +427,6 @@ public class SelectedObjectControllerTests
h.FireSelection(0xAA09u);
h.FireHealth(0xAA09u, 0.5f);
Assert.Null(h.SelectionHandler);
Assert.Null(h.HealthHandler);
Assert.False(healthMeterEl.Visible);
Assert.Empty(h.QueryHealthCalls);

View file

@ -32,6 +32,7 @@ public sealed class RetailUiInteractionFlowTests
public readonly List<uint> Uses = new();
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly AcDream.Core.Selection.SelectionState Selection = new();
public readonly List<uint> Drops = new();
public long Now = 10_000;
@ -149,6 +150,7 @@ public sealed class RetailUiInteractionFlowTests
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
strength: () => 100,
selection: Selection,
datFont: null,
itemInteraction: interaction);
@ -167,6 +169,7 @@ public sealed class RetailUiInteractionFlowTests
Objects,
playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u,
selection: Selection,
sendWield: (item, mask) => Wields.Add((item, mask)),
emptySlotSprite: 0x06004D20u);