From 81d9b3b37a30ffd83e86e7314ad2cee5a8c0b7ab Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:54:53 +0200 Subject: [PATCH] feat(D.2b): InventoryController drag-drop handler (optimistic move + green-arrow/red-circle) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UI/Layout/InventoryController.cs | 86 ++++++++++++++++-- .../UI/Layout/InventoryControllerTests.cs | 87 ++++++++++++++++++- 2 files changed, 165 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index c0dbc8d6..7d43c24e 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -1,5 +1,6 @@ using System; using System.Numerics; +using AcDream.App.UI; using AcDream.Core.Items; namespace AcDream.App.UI.Layout; @@ -11,7 +12,7 @@ namespace AcDream.App.UI.Layout; /// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace); /// drag-into-bag / wield-drop wire are later sub-phases. /// -public sealed class InventoryController +public sealed class InventoryController : IItemListDragHandler { // Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds). public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack") @@ -58,6 +59,7 @@ public sealed class InventoryController private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) private readonly Action? _sendUse; private readonly Action? _sendNoLongerViewing; + private readonly Action? _sendPutItemInContainer; // (item, container, placement) // ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)). private const uint EncumbranceValProperty = 5u; // total carried burden @@ -74,7 +76,8 @@ public sealed class InventoryController uint sideBagEmptySprite, uint mainPackEmptySprite, Action? sendUse, - Action? sendNoLongerViewing) + Action? sendNoLongerViewing, + Action? sendPutItemInContainer) { _objects = objects; _playerGuid = playerGuid; @@ -82,6 +85,7 @@ public sealed class InventoryController _strength = strength; _sendUse = sendUse; _sendNoLongerViewing = sendNoLongerViewing; + _sendPutItemInContainer = sendPutItemInContainer; _contentsGrid = layout.FindElement(ContentsGridId) as UiItemList; _containerList = layout.FindElement(ContainerListId) as UiItemList; @@ -121,6 +125,11 @@ public sealed class InventoryController if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite; if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite; + // B-Drag: register this controller as the drag handler on all three lists. + _contentsGrid?.RegisterDragHandler(this); + _containerList?.RegisterDragHandler(this); + _topContainer?.RegisterDragHandler(this); + // Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4). _burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter; if (_burdenMeter is not null) @@ -157,10 +166,11 @@ public sealed class InventoryController uint sideBagEmptySprite = 0u, uint mainPackEmptySprite = 0u, Action? sendUse = null, - Action? sendNoLongerViewing = null) + Action? sendNoLongerViewing = null, + Action? sendPutItemInContainer = null) => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, - sendUse, sendNoLongerViewing); + sendUse, sendNoLongerViewing, sendPutItemInContainer); private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectMoved(ClientObject o, uint from, uint to) @@ -237,6 +247,7 @@ public sealed class InventoryController _topContainer.Flush(); var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u)); + main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; main.Clicked = () => OpenContainer(p); SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) _topContainer.AddItem(main); @@ -262,13 +273,21 @@ public sealed class InventoryController : _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; cell.SetItem(guid, tex); + cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) + cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed) + cell.DragRejectSprite = 0x060011F8u; // red circle if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } else cell.Clicked = () => SelectItem(guid); list.AddItem(cell); } private static void AddEmptyCell(UiItemList list) - => list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve }); + { + var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() }; + cell.DragAcceptSprite = 0x060011F7u; + cell.DragRejectSprite = 0x060011F8u; + list.AddItem(cell); + } /// Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay /// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill = @@ -283,6 +302,63 @@ public sealed class InventoryController cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f); } + // ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ────────────── + /// Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot + /// until the server confirms the move. Retail dims the source; we leave it + the floating ghost. + public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } + + /// Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always + /// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full. + public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + { + if (payload.ObjId == 0) return false; + if (targetList == _contentsGrid) return true; + if (targetList == _containerList || targetList == _topContainer) + { + if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false; + return !IsContainerFull(targetCell.ItemId); + } + return false; + } + + /// Perform the move: resolve (container, placement) from the target, optimistically move + /// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem. + public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + { + uint item = payload.ObjId; + if (item == 0) return; + + uint container; int placement; + if (targetList == _contentsGrid) + { + container = EffectiveOpen(); + placement = targetCell.ItemId != 0 + ? (_objects.Get(targetCell.ItemId)?.ContainerSlot ?? targetCell.SlotIndex) // insert-before + : _objects.GetContents(container).Count; // first empty = append + } + else if (targetList == _containerList || targetList == _topContainer) + { + if (targetCell.ItemId == 0 || targetCell.ItemId == item) return; + container = targetCell.ItemId; // the bag / main pack + if (IsContainerFull(container)) return; // red already shown + placement = _objects.GetContents(container).Count; // append into it + } + else return; + + if (container == item) return; // never into itself + _objects.MoveItemOptimistic(item, container, placement); // instant local move + _sendPutItemInContainer?.Invoke(item, container, placement); + } + + /// True only when we KNOW the container is full (capacity known + contents indexed). A + /// closed bag (unknown count) returns false → advisory accept; the server is authoritative. + private bool IsContainerFull(uint container) + { + int cap = _objects.Get(container)?.ItemsCapacity ?? 0; + if (cap <= 0) return false; + return _objects.GetContents(container).Count >= cap; + } + /// Select an item (panel-wide green square) without changing the open container or /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). private void SelectItem(uint guid) diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 9772068f..296a1059 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -51,12 +51,14 @@ public class InventoryControllerTests } private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100, List? uses = null, List? closes = null) + int? strength = 100, List? uses = null, List? closes = null, + List<(uint item, uint container, int placement)>? puts = null) => InventoryController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests + iconIds: (_, _, _, _, _) => 0u, strength: () => strength, datFont: null, sendUse: uses is null ? null : g => uses.Add(g), - sendNoLongerViewing: closes is null ? null : g => closes.Add(g)); + sendNoLongerViewing: closes is null ? null : g => closes.Add(g), + sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p))); // Seed a side bag (a container) in the player's pack, plus optionally its own contents. private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24) @@ -403,6 +405,85 @@ public class InventoryControllerTests Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container } + private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot()); + + [Fact] + public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); + SeedContained(objects, 0xB, Player, slot: 1); + var puts = new List<(uint, uint, int)>(); + var ctrl = Bind(layout, objects, puts: puts); + + // Drag a fresh item 0xFFFF from elsewhere; drop on the grid cell holding 0xB (slot 1). + objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); + var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1 + ((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu)); + + Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container + Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant) + } + + [Fact] + public void Drop_onEmptyGridCell_appendsToFirstEmpty() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); // 1 loose item → first empty = slot 1 + var puts = new List<(uint, uint, int)>(); + var ctrl = Bind(layout, objects, puts: puts); + + objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); + var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0) + ((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xFFFFu)); + + Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty + } + + [Fact] + public void Drop_onSideBagCell_movesIntoThatBag() + { + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); + var puts = new List<(uint, uint, int)>(); + var ctrl = Bind(layout, objects, puts: puts); + + objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); + var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag) + ((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu)); + + Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0) + Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId); + } + + [Fact] + public void OnDragOver_fullSideBag_rejects_butGridAccepts() + { + var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedBag(objects, 0xC, slot: 0, itemsCapacity: 1); // capacity 1... + SeedContained(objects, 0xB0, 0xC, slot: 0); // ...and already full + 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 + } + + [Fact] + public void OnDragLift_isNoOp_itemStaysUntilServerConfirms() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); + var ctrl = (IItemListDragHandler)Bind(layout, objects); + ((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xAu)); + Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // NOT removed on lift (unlike the toolbar) + } + // Reads the text of the UiText caption child attached by the controller. private static string CaptionText(UiElement host) {