feat(ui): complete retail quick-slot input

Port global toolbar use/select/create actions, migrate the collapsed Ctrl-number bindings, and route them through a focused retained-UI controller. Preserve shortcut aliases as aliases across inventory and paperdoll drops with retail's neutral/accept/reject drag states, preventing physical item moves such as unwielding an equipped helmet.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 08:59:49 +02:00
parent 6fcc510d5d
commit e65119f0c6
24 changed files with 650 additions and 69 deletions

View file

@ -254,6 +254,124 @@ public class ToolbarControllerTests
Assert.Equal(CombatIds.Length, toggles);
}
[Fact]
public void UseShortcut_usesOrSelectsAccordingToRetailIntent()
{
const uint itemId = 0x50001001u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = itemId, Type = ItemType.Misc });
var shortcuts = new[]
{
new PlayerDescriptionParser.ShortcutEntry(0, itemId, 0, 0),
};
uint used = 0;
uint selected = 0;
var controller = ToolbarController.Bind(
layout,
repo,
() => shortcuts,
iconIds: (_, _, _, _, _) => 1u,
useItem: id => used = id,
selectItem: id => selected = id);
Assert.True(controller.UseShortcut(0, use: true));
Assert.Equal(itemId, used);
Assert.Equal(0u, selected);
Assert.True(controller.UseShortcut(0, use: false));
Assert.Equal(itemId, selected);
}
[Fact]
public void UseShortcut_targetModePrecedesUseAndClearsOneShotMode()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint source = 0x50000003u;
const uint target = 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 = source,
Type = ItemType.Misc,
Useability = 0x00220008u,
TargetType = (uint)ItemType.Creature,
});
repo.MoveItem(source, pack, 0);
repo.AddOrUpdate(new ClientObject { ObjectId = target, Type = ItemType.Creature });
uint sentSource = 0;
uint sentTarget = 0;
var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: null,
sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t),
sendWield: null,
sendDrop: null,
nowMs: () => 1_000);
var shortcuts = new[]
{
new PlayerDescriptionParser.ShortcutEntry(0, target, 0, 0),
};
var controller = ToolbarController.Bind(
layout,
repo,
() => shortcuts,
iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { },
itemInteraction: interaction);
Assert.True(interaction.ActivateItem(source));
Assert.True(controller.UseShortcut(0, use: false));
Assert.Equal(source, sentSource);
Assert.Equal(target, sentTarget);
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void CreateShortcutToItem_addsFirstEmptyOwnedEligibleItemOnce()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint item = 0x50000003u;
var (layout, slots, _) = 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 });
repo.MoveItem(item, pack, 0);
var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
var sends = new List<(uint Slot, uint Item)>();
var controller = ToolbarController.Bind(
layout,
repo,
() => Array.Empty<PlayerDescriptionParser.ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 1u,
useItem: _ => { },
itemInteraction: interaction,
sendAddShortcut: (slot, id) => sends.Add((slot, id)));
Assert.True(controller.CreateShortcutToItem(item));
Assert.Equal(item, slots[Row1[0]].Cell.ItemId);
Assert.Equal(new[] { (0u, item) }, sends);
Assert.False(controller.CreateShortcutToItem(item));
Assert.Single(sends);
}
/// <summary>
/// At bind time (default NonCombat), only the peace indicator (0x10000192) is visible;
/// the melee/missile/magic indicators (0x10000193/4/5) are hidden.
@ -620,7 +738,7 @@ public class ToolbarControllerTests
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.True(ctrl.OnDragOver(list, list.Cell, payload));
Assert.Equal(ItemDragAcceptance.Accept, ctrl.OnDragOver(list, list.Cell, payload));
}
/// <summary>OnDragOver rejects a ghost payload with ObjId 0 (the guard against an
@ -634,7 +752,7 @@ public class ToolbarControllerTests
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
var list = slots[Row1[0]];
var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot());
Assert.False(ctrl.OnDragOver(list, list.Cell, payload));
Assert.Equal(ItemDragAcceptance.None, ctrl.OnDragOver(list, list.Cell, payload));
}
// ── B.2: live drag handler (store + reorder/remove + wire) ───────────────