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:
parent
6fcc510d5d
commit
e65119f0c6
24 changed files with 650 additions and 69 deletions
|
|
@ -8,15 +8,15 @@ public class DragDropSpineTests
|
|||
// A spy handler used across the spine tests.
|
||||
private sealed class SpyHandler : IItemListDragHandler
|
||||
{
|
||||
public bool AcceptResult = true;
|
||||
public ItemDragAcceptance Acceptance = ItemDragAcceptance.Accept;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop;
|
||||
public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift;
|
||||
public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastLift = (list, cell, p); }
|
||||
|
||||
public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastOver = (list, cell, p); return AcceptResult; }
|
||||
public ItemDragAcceptance OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastOver = (list, cell, p); return Acceptance; }
|
||||
|
||||
public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p)
|
||||
{ LastDrop = (list, cell, p); }
|
||||
|
|
@ -92,7 +92,7 @@ public class DragDropSpineTests
|
|||
public void DragEnter_setsAcceptOverlay_whenHandlerAccepts()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = true;
|
||||
h.Acceptance = ItemDragAcceptance.Accept;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual);
|
||||
}
|
||||
|
|
@ -101,11 +101,20 @@ public class DragDropSpineTests
|
|||
public void DragEnter_setsRejectOverlay_whenHandlerRejects()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.AcceptResult = false;
|
||||
h.Acceptance = ItemDragAcceptance.Reject;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragEnter_keepsNeutralOverlay_whenHandlerIgnoresAlias()
|
||||
{
|
||||
var (_, cell, h) = ListWithHandler();
|
||||
h.Acceptance = ItemDragAcceptance.None;
|
||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||
Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DragOver_resetsOverlayToNeutral()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -591,8 +591,10 @@ public class InventoryControllerTests
|
|||
objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu });
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
|
||||
Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red
|
||||
Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
|
||||
Assert.Equal(ItemDragAcceptance.Reject,
|
||||
ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red
|
||||
Assert.Equal(ItemDragAcceptance.Accept,
|
||||
ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -616,7 +618,38 @@ public class InventoryControllerTests
|
|||
var ctrl = (IItemListDragHandler)Bind(layout, objects);
|
||||
|
||||
// GetContents(0xC) is empty (a closed bag's contents aren't indexed until opened) → not known-full → accept.
|
||||
Assert.True(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu)));
|
||||
Assert.Equal(ItemDragAcceptance.Accept,
|
||||
ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortcutAliasDropOnInventory_removesOnlyAlias_andNeverMovesEquippedItem()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
const uint helmet = 0xD00Du;
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = helmet,
|
||||
Type = ItemType.Armor,
|
||||
ValidLocations = EquipMask.HeadWear,
|
||||
});
|
||||
objects.MoveItem(helmet, Player, newSlot: 0, newEquipLocation: EquipMask.HeadWear);
|
||||
var puts = new List<(uint item, uint container, int placement)>();
|
||||
var ctrl = (IItemListDragHandler)Bind(layout, objects, puts: puts);
|
||||
var payload = new ItemDragPayload(
|
||||
helmet,
|
||||
ItemDragSource.ShortcutBar,
|
||||
SourceSlot: 3,
|
||||
SourceCell: new UiItemSlot());
|
||||
|
||||
Assert.Equal(ItemDragAcceptance.None,
|
||||
ctrl.OnDragOver(grid, grid.GetItem(0)!, payload));
|
||||
ctrl.HandleDropRelease(grid, grid.GetItem(0)!, payload);
|
||||
|
||||
Assert.Empty(puts);
|
||||
Assert.Equal(Player, objects.Get(helmet)!.ContainerId);
|
||||
Assert.Equal(EquipMask.HeadWear, objects.Get(helmet)!.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -99,8 +99,29 @@ public class PaperdollControllerTests
|
|||
SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack
|
||||
var ctrl = Bind(layout, objects);
|
||||
var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
|
||||
Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK
|
||||
Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO
|
||||
Assert.Equal(ItemDragAcceptance.Accept,
|
||||
ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK
|
||||
Assert.Equal(ItemDragAcceptance.Reject,
|
||||
ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortcutAlias_isNeutral_andNeverWieldsThroughPaperdoll()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedPackItem(objects, 0xC02u, EquipMask.HeadWear);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var ctrl = Bind(layout, objects, wields);
|
||||
var payload = new ItemDragPayload(
|
||||
0xC02u, ItemDragSource.ShortcutBar, 2, lists[HeadSlot].Cell);
|
||||
|
||||
Assert.Equal(ItemDragAcceptance.None,
|
||||
ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload));
|
||||
ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload);
|
||||
|
||||
Assert.Empty(wields);
|
||||
Assert.Equal(EquipMask.None, objects.Get(0xC02u)!.CurrentlyEquippedLocation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -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) ───────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
using AcDream.App.UI.Layout;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
public sealed class ToolbarInputControllerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(InputAction.UseQuickSlot_1, 0, true)]
|
||||
[InlineData(InputAction.UseQuickSlot_9, 8, true)]
|
||||
[InlineData(InputAction.SelectQuickSlot_1, 0, false)]
|
||||
[InlineData(InputAction.SelectQuickSlot_9, 8, false)]
|
||||
[InlineData(InputAction.UseQuickSlot_14, 13, true)]
|
||||
[InlineData(InputAction.UseQuickSlot_18, 17, true)]
|
||||
public void ShortcutActions_mapRetailSlotAndIntent(InputAction action, int slot, bool use)
|
||||
{
|
||||
Assert.True(ToolbarInputController.TryMapShortcut(action, out int actualSlot, out bool actualUse));
|
||||
Assert.Equal(slot, actualSlot);
|
||||
Assert.Equal(use, actualUse);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InputAction.CreateShortcut)]
|
||||
[InlineData(InputAction.CombatToggleCombat)]
|
||||
[InlineData(InputAction.ToggleChatEntry)]
|
||||
public void NonSlotActions_doNotMapAsShortcut(InputAction action)
|
||||
{
|
||||
Assert.False(ToolbarInputController.TryMapShortcut(action, out int slot, out bool use));
|
||||
Assert.Equal(-1, slot);
|
||||
Assert.False(use);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,8 @@ public sealed class RetailUiAutomationProbeTests
|
|||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
=> LastLift = (sourceList, sourceCell, payload);
|
||||
|
||||
public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> true;
|
||||
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> ItemDragAcceptance.Accept;
|
||||
|
||||
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
|
||||
=> LastDrop = (targetList, targetCell, payload);
|
||||
|
|
|
|||
|
|
@ -191,6 +191,38 @@ public class KeyBindingsJsonTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrDefault_migratesV1CtrlNumberQuickSlotFromUseToSelect()
|
||||
{
|
||||
var path = TempFile();
|
||||
try
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"version": 1,
|
||||
"actions": {
|
||||
"UseQuickSlot_5": [
|
||||
{ "key": "Number5" },
|
||||
{ "key": "Number5", "mod": "Ctrl" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(path, json);
|
||||
|
||||
var loaded = KeyBindings.LoadOrDefault(path);
|
||||
|
||||
Assert.Equal(InputAction.UseQuickSlot_5,
|
||||
loaded.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press)?.Action);
|
||||
Assert.Equal(InputAction.SelectQuickSlot_5,
|
||||
loaded.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press)?.Action);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrDefault_unknown_action_name_skipped_silently()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -109,12 +109,14 @@ public class KeyBindingsRetailTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void UseQuickSlot_5_bound_to_Number5_with_and_without_Ctrl()
|
||||
public void QuickSlot_5_bareUsesAndCtrlSelects()
|
||||
{
|
||||
var b = KeyBindings.RetailDefaults();
|
||||
var binds = b.ForAction(InputAction.UseQuickSlot_5).ToList();
|
||||
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.None));
|
||||
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.Ctrl));
|
||||
var bare = b.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press);
|
||||
var ctrl = b.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press);
|
||||
|
||||
Assert.Equal(InputAction.UseQuickSlot_5, bare?.Action);
|
||||
Assert.Equal(InputAction.SelectQuickSlot_5, ctrl?.Action);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue