feat(ui): drive toolbar use state from selection

Port the retail selected-object availability predicate into Core and project it through the shared interaction owner. The imported hand now follows canonical selection/object notices, keeps weapon and targeted-tool activation on the existing wield/use cursor paths, and ghosts empty or explicitly unusable selections per the connected UX requirement.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 08:07:47 +02:00
parent 0134122c28
commit 60387668d0
11 changed files with 269 additions and 16 deletions

View file

@ -5,6 +5,7 @@ using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -515,6 +516,123 @@ public class ToolbarControllerTests
Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind);
}
[Fact]
public void UseButton_tracksEmptyUnusableAndTargetedSelection()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint component = 0x50000003u;
const uint healthKit = 0x50000004u;
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 = component,
Type = ItemType.SpellComponents,
Useability = ItemUseability.No,
});
repo.MoveItem(component, pack, 0);
repo.AddOrUpdate(new ClientObject
{
ObjectId = healthKit,
Type = ItemType.Misc,
Useability = 0x00220008u,
TargetType = (uint)ItemType.Creature,
});
repo.MoveItem(healthKit, pack, 1);
var selection = new SelectionState();
using var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
using var controller = ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
itemInteraction: interaction,
selectedObjectId: () => selection.SelectedObjectId ?? 0u,
selection: selection);
var use = Assert.IsType<UiButton>(layout.FindElement(UseButtonId));
Assert.False(use.Enabled);
Assert.Equal("Ghosted", use.ActiveState);
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
Assert.Equal(InteractionModeKind.None, interaction.InteractionState.Current.Kind);
selection.Select(component, SelectionChangeSource.Inventory);
Assert.False(use.Enabled);
Assert.Equal("Ghosted", use.ActiveState);
selection.Select(healthKit, SelectionChangeSource.Inventory);
Assert.True(use.Enabled);
Assert.Equal("Normal", use.ActiveState);
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
Assert.Equal(
InteractionModeKind.UseItemOnTarget,
interaction.InteractionState.Current.Kind);
selection.Clear(SelectionChangeSource.Inventory);
Assert.False(use.Enabled);
Assert.Equal("Ghosted", use.ActiveState);
}
[Fact]
public void UseButton_selectedCombatItem_routesExistingWieldPath()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint sword = 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 = sword,
Type = ItemType.MeleeWeapon,
CombatUse = 1,
Useability = ItemUseability.No,
ValidLocations = EquipMask.MeleeWeapon,
});
repo.MoveItem(sword, pack, 0);
var selection = new SelectionState();
selection.Select(sword, SelectionChangeSource.Inventory);
var wields = new List<(uint Item, uint Location)>();
using var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: null,
sendUseWithTarget: null,
sendWield: (item, location) => wields.Add((item, location)),
sendDrop: null);
using var controller = ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
itemInteraction: interaction,
selectedObjectId: () => selection.SelectedObjectId ?? 0u,
selection: selection);
var use = Assert.IsType<UiButton>(layout.FindElement(UseButtonId));
Assert.True(use.Enabled);
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields);
}
// ── C1: combat-mode indicator tests ─────────────────────────────────────
[Fact]

View file

@ -7,6 +7,28 @@ public sealed class ItemInteractionPolicyTests
private const uint Player = 0x50000001u;
private const uint GroundContainer = 0x70000001u;
[Fact]
public void ToolbarUseEnabled_matchesRetailSelectionPredicate()
{
Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled(
ItemType.MeleeWeapon,
combatUse: 1,
useability: ItemUseability.No));
Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled(
ItemType.Armor | ItemType.Clothing,
combatUse: 0,
useability: ItemUseability.No));
Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled(
ItemType.Misc,
combatUse: 0,
useability: 0x00220008u));
Assert.False(ItemInteractionPolicy.IsToolbarUseEnabled(
ItemType.SpellComponents,
combatUse: 0,
useability: ItemUseability.No));
}
[Fact]
public void DetermineUseResult_looseItemAndComponentPack_matchRetailPickupBranch()
{