From b657c82df5101f103c2489fe997ef86e99f1fca5 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:45:47 +0200 Subject: [PATCH] docs(D.2b): inventory drag-drop implementation plan 7-task TDD plan: ClientObjectTable optimistic move + confirm/rollback -> SendPutItemInContainer 0x0019 -> ConfirmMove(0x0022)/RollbackMove(0x00A0) wiring -> InventoryController : IItemListDragHandler (no-op lift, accept/reject overlay, optimistic drop + wire; green-arrow 0x060011F7 / red-circle 0x060011F8 export-confirmed) -> GameWindow wiring -> divergence AP-60/61 -> full-suite + visual gate. Exact code per step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-22-d2b-inventory-drag-drop.md | 577 ++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md diff --git a/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md b/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md new file mode 100644 index 00000000..365fca0a --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md @@ -0,0 +1,577 @@ +# D.2b inventory drag-drop (item moving) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Drag an inventory item and drop it to move it instantly — empty grid slot → first empty; on an item → insert before; on a side-bag cell → into that container — with a green insert-arrow (valid) / red circle (full) overlay and a server-rollback if the move bounces. + +**Architecture:** `InventoryController` implements `IItemListDragHandler` (like `ToolbarController`) on the contents grid + side-bag list + main-pack cell. Drop does an **optimistic** local `MoveItem` (instant repaint) + `PutItemInContainer 0x0019` wire; the server's `0x0022` echo confirms and `0x00A0` rolls back. Pending-move tracking lives in `ClientObjectTable` so both layers reach it. + +**Tech Stack:** C# .NET 10, xUnit. Layers: `AcDream.Core` (table + pending moves), `AcDream.Core.Net` (wire + wiring), `AcDream.App` (the drag handler). + +**Spec:** `docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md`. Sprites (export-confirmed): green insert-arrow `0x060011F7`, red circle `0x060011F8`. Retail: `InqDropIconInfo 0x004e26f0`, `ItemList_InsertItem`, `HandleDropRelease`, `ServerSaysMoveItem`; wire `PutItemInContainer 0x0019` (`item, container, placement`). + +**Build/test:** from repo root. Per-project e.g. `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. Full (phase boundary): `dotnet test` (use `--no-build` if a client is running, to avoid the App.dll lock). + +--- + +## Task 1: `ClientObjectTable` optimistic move + rollback + +**Files:** +- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add a pending-move map field near the other fields ~line 45, and three methods after `MoveItem` ~line 131) +- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp +[Fact] +public void MoveItemOptimistic_movesAndRemembersPreMove() +{ + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x700u, container: 0xC0u)); table.MoveItem(0x700u, 0xC0u, newSlot: 3); + table.MoveItemOptimistic(0x700u, 0xC1u, 0); + Assert.Equal(0xC1u, table.Get(0x700u)!.ContainerId); // moved now (instant) + Assert.True(table.RollbackMove(0x700u)); // pre-move was remembered + Assert.Equal(0xC0u, table.Get(0x700u)!.ContainerId); // snapped back to the original container + Assert.Equal(3, table.Get(0x700u)!.ContainerSlot); // and slot +} + +[Fact] +public void ConfirmMove_clearsPending_soRollbackIsNoOp() +{ + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x701u, container: 0xC0u)); + table.MoveItemOptimistic(0x701u, 0xC1u, 0); + table.ConfirmMove(0x701u); + Assert.False(table.RollbackMove(0x701u)); // nothing pending → no rollback + Assert.Equal(0xC1u, table.Get(0x701u)!.ContainerId); // stays at the confirmed container +} + +[Fact] +public void MoveItemOptimistic_twice_keepsTheOriginalPreMove() +{ + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x702u, container: 0xC0u)); + table.MoveItemOptimistic(0x702u, 0xC1u, 0); + table.MoveItemOptimistic(0x702u, 0xC2u, 0); // a second move before any confirm + table.RollbackMove(0x702u); + Assert.Equal(0xC0u, table.Get(0x702u)!.ContainerId); // rolls back to the FIRST pre-move +} + +[Fact] +public void RollbackMove_unknownItem_false() + => Assert.False(new ClientObjectTable().RollbackMove(0xDEADu)); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Optimistic|FullyQualifiedName~ConfirmMove|FullyQualifiedName~RollbackMove"` +Expected: FAIL — `'ClientObjectTable' does not contain a definition for 'MoveItemOptimistic'`. + +- [ ] **Step 3: Add the field + methods** + +Add the field near the other private fields (after `_containerIndex`, ~line 45): +```csharp + // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot) BEFORE + // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), + // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. + private readonly Dictionary _pendingMoves = new(); +``` + +Add the methods after `MoveItem` (~line 131): +```csharp + /// + /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST + /// time it moves, then (immediate repaint via ObjectMoved). The wire + /// PutItemInContainer is sent by the caller; / + /// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile. + /// + public bool MoveItemOptimistic(uint itemId, uint newContainerId, int newSlot) + { + if (!_objects.TryGetValue(itemId, out var item)) return false; + if (!_pendingMoves.ContainsKey(itemId)) + _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot); // snapshot the ORIGINAL once + return MoveItem(itemId, newContainerId, newSlot); + } + + /// The server confirmed the move (InventoryPutObjInContainer 0x0022 echo) — drop the + /// pending snapshot. No-op for a server-initiated move we never tracked. + public void ConfirmMove(uint itemId) => _pendingMoves.Remove(itemId); + + /// The server rejected the move (InventoryServerSaveFailed 0x00A0) — restore the item to + /// its pre-move (container, slot) and drop the snapshot. False if nothing was pending. + public bool RollbackMove(uint itemId) + { + if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; + _pendingMoves.Remove(itemId); + return MoveItem(itemId, pre.container, pre.slot); + } +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Optimistic|FullyQualifiedName~ConfirmMove|FullyQualifiedName~RollbackMove"` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +git commit -m "feat(D.2b): ClientObjectTable optimistic move + confirm/rollback for inventory drag + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: `WorldSession.SendPutItemInContainer` + +**Files:** +- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add after `SendUse`, ~line 1226) + +**Test note:** no new unit test — the wire is locked by `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs` (`BuildPickUp` opcode `0x0019` + fields); the wrapper mirrors the test-free `SendUse`/`SendNoLongerViewingContents`. Round-trip confirmed at the WireMCP gate (Task 7). + +- [ ] **Step 1: Add the wrapper** + +After `SendUse` (~line 1226) add: +```csharp + /// Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement + /// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail: + /// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer. + public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement) + { + uint seq = NextGameActionSequence(); + SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)); + } +``` + +- [ ] **Step 2: Verify it builds** + +Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.Core.Net/WorldSession.cs +git commit -m "feat(D.2b): WorldSession.SendPutItemInContainer (0x0019) for inventory moves + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: wire the confirm/rollback handlers + +**Files:** +- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (the `InventoryPutObjInContainer` handler ~line 246-251 and the `InventoryServerSaveFailed` handler ~line 280-285) +- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +[Fact] +public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove() +{ + var (d, items, _, _, _) = MakeAll(); + // An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1. + items.Ingest(FullWeenieAt(0x50000B01u, 0x500000C0u, slot: 2)); + items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0); + Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic) + + // Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError). + byte[] payload = new byte[8]; + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError + var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload)); + d.Dispatch(env!.Value); + + Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back + Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot); +} +``` + +Add this helper to the test class if `FullWeenieAt` isn't present (place near the other helpers): +```csharp +private static WeenieData FullWeenieAt(uint guid, uint container, int slot) +{ + var d = new WeenieData { Guid = guid, ContainerId = container }; + // Ingest sets ContainerId from the wire; the slot is set by the follow-up MoveItem. + return d; +} +``` +(If the test file already constructs `WeenieData` inline elsewhere, match that style instead and set `ContainerId`. After `Ingest`, call `items.MoveItem(guid, container, slot)` to pin the slot — adjust the test's first two lines to: `items.Ingest(new WeenieData{Guid=0x50000B01u, ContainerId=0x500000C0u}); items.MoveItem(0x50000B01u, 0x500000C0u, 2);`.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~RollsBackOptimisticMove"` +Expected: FAIL — the item stays at `0x500000C1` (the handler only logs today; no rollback). + +- [ ] **Step 3: Wire confirm + rollback** + +In `GameEventWiring.cs`, the `InventoryPutObjInContainer` handler — add `ConfirmMove` after the existing `MoveItem`: +```csharp + dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => + { + var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); + if (p is null) return; + items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement); + items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move + }); +``` + +Replace the `InventoryServerSaveFailed` handler body (the `Console.WriteLine` log) with the rollback: +```csharp + dispatcher.Register(GameEventType.InventoryServerSaveFailed, e => + { + var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); + if (p is null) return; + // B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot. + if (!items.RollbackMove(p.Value.ItemGuid)) + Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)"); + }); +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~RollsBackOptimisticMove"` +Expected: PASS. Also run the existing inventory wiring tests to confirm no regression: `--filter "FullyQualifiedName~Inventory"`. + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +git commit -m "feat(D.2b): wire ConfirmMove (0x0022 echo) + RollbackMove (0x00A0) for optimistic inventory moves + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: `InventoryController` drag-drop handler + +**Files:** +- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` (implement `IItemListDragHandler`; register on the 3 lists; set drag sprites + `SlotIndex` on cells; add `sendPutItemInContainer` ctor/Bind param) +- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` + +- [ ] **Step 1: Extend the test harness + write the failing tests** + +In `InventoryControllerTests.cs`, replace the `Bind` helper with one that captures the new callback: +```csharp + private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, + 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, + strength: () => strength, datFont: null, + 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))); +``` + +Add these tests at the end of the class (before the `CaptionText` helper): +```csharp + 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 0xZ from elsewhere; drop on the grid cell holding 0xB (slot 1). + objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ }); + var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1 + ((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xZ)); + + Assert.Contains((0xZu, Player, 1), puts); // insert-before slot 1, into the open container + Assert.Equal(Player, objects.Get(0xZ)!.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 = 0xZ }); + var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0) + ((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xZ)); + + Assert.Contains((0xZu, 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 = 0xZ }); + var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag) + ((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xZ)); + + Assert.Contains((0xZu, 0xCu, 0), puts); // into the bag, append (placement 0) + Assert.Equal(0xCu, objects.Get(0xZ)!.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 = 0xZ }); + var ctrl = (IItemListDragHandler)Bind(layout, objects); + + Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xZ))); // full bag → red + Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xZ))); // 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(0xA)); + Assert.Equal(Player, objects.Get(0xA)!.ContainerId); // NOT removed on lift (unlike the toolbar) + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` +Expected: FAIL — `Bind` has no `sendPutItemInContainer`; `InventoryController` doesn't implement `IItemListDragHandler` (compile errors). + +- [ ] **Step 3: Make `InventoryController` the drag handler** + +(a) Class declaration — add the interface: +```csharp +public sealed class InventoryController : IItemListDragHandler +``` + +(b) Add the field + ctor/Bind param. Add field near `_sendNoLongerViewing`: +```csharp + private readonly Action? _sendPutItemInContainer; // (item, container, placement) +``` +Append to the private ctor params (after `sendNoLongerViewing`): +```csharp + Action? sendNoLongerViewing, + Action? sendPutItemInContainer) +``` +Assign in the ctor body (after `_sendNoLongerViewing = ...`): +```csharp + _sendPutItemInContainer = sendPutItemInContainer; +``` +Register the handler on the three lists in the ctor (after the lists are resolved, near the other `_contentsGrid` setup): +```csharp + _contentsGrid?.RegisterDragHandler(this); + _containerList?.RegisterDragHandler(this); + _topContainer?.RegisterDragHandler(this); +``` +Append to the public `Bind` params (after `sendNoLongerViewing = null`) and thread it through: +```csharp + Action? sendNoLongerViewing = null, + Action? sendPutItemInContainer = null) + => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, + contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, + sendUse, sendNoLongerViewing, sendPutItemInContainer); +``` + +(c) Set drag sprites + `SlotIndex` on cells. In `AddCell`, after `cell.SetItem(...)`: +```csharp + 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 +``` +In `AddEmptyCell`, change it to set the same so empty grid cells are valid drop targets: +```csharp + private static void AddEmptyCell(UiItemList list) + { + var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() }; + cell.DragAcceptSprite = 0x060011F7u; + cell.DragRejectSprite = 0x060011F8u; + list.AddItem(cell); + } +``` +In the main-pack cell block (`Populate`), after `main.SetItem(...)`: +```csharp + main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; +``` + +(d) Add the handler methods (place near the other private helpers, e.g. after `SetCapacityBar`): +```csharp + // ── 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; + } +``` + +(e) Add the `using` if missing at the top of the file: `using AcDream.App.UI;` (for `IItemListDragHandler`/`ItemDragPayload`/`UiItemSlot` — likely already present since the file uses `UiItemSlot`). Confirm the file compiles. + +- [ ] **Step 4: Run to verify they pass** + +Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` +Expected: PASS (5 new + all existing). + +- [ ] **Step 5: Commit** + +```bash +git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +git commit -m "feat(D.2b): InventoryController drag-drop handler (optimistic move + green-arrow/red-circle) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: wire the send callback in `GameWindow` + +**Files:** +- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `InventoryController.Bind(...)` call ~line 2231-2242) + +**Test note:** integration-wired; verified by build + the visual gate (Task 7). + +- [ ] **Step 1: Add the callback** + +Append after `sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)`: +```csharp + sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g), + sendPutItemInContainer: (item, container, placement) => + _liveSession?.SendPutItemInContainer(item, container, placement)); +``` + +- [ ] **Step 2: Verify it builds** + +Run: `dotnet build src/AcDream.App/AcDream.App.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/AcDream.App/Rendering/GameWindow.cs +git commit -m "feat(D.2b): wire InventoryController drag-drop to the live session + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: divergence register + +**Files:** +- Modify: `docs/architecture/retail-divergence-register.md` (add rows after AP-59; use the next free numbers AP-60/AP-61) + +- [ ] **Step 1: Add the rows** + +- **AP-60** — Inventory drag `OnDragLift` is a no-op + the source cell is not dimmed: retail dims the lifted item's source cell; acdream leaves the item in place during the drag + the floating ghost. File `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: a drag in progress doesn't visually mark its origin (cosmetic). Anchor: `RecvNotice_ItemListBeginDrag`. +- **AP-61** — Drop on a CLOSED side bag is advisory-accept: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. File `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`). Risk: a drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Anchor: `UIElement_ItemList::InqDropIconInfo 0x004e26f0`. + +- [ ] **Step 2: Commit** + +```bash +git add docs/architecture/retail-divergence-register.md +git commit -m "docs(D.2b): divergence rows AP-60 (lift no-op) + AP-61 (closed-bag advisory accept) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: full-suite gate + visual verification + +**Files:** none. + +- [ ] **Step 1: Full build + full test** + +Run: `dotnet build` then `dotnet test` (use `dotnet test --no-build` if a client is running — avoids the App.dll lock; the implementer built above). Expected: all green, 0 failures (run the FULL suite — the B-Wire cross-file-regression lesson). + +- [ ] **Step 2: Launch (plain `dotnet run`, DO NOT pre-kill a running client)** + +If a rebuild is locked by a running client, ASK the user to close it. Launch (PowerShell, background): +```powershell +$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"; $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"; $env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"; $env:ACDREAM_RETAIL_UI="1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" +``` + +- [ ] **Step 3: Visual gate (the oracle — user confirms)** + +With F12 open, the user: drags an item onto an **empty slot** → it lands at the first empty slot the instant they release; onto an **item** → inserts before it; onto a **side bag** → goes in; hovering a **full bag** shows the **red circle** and the drop bounces (snaps back); a valid target shows the **green insert-arrow**. WireMCP (`127.0.0.1:9000`) confirms `PutItemInContainer 0x0019` out + the `0x0022` echo (and `0x00A0` on a full-bag bounce). Note any sprite/placement mismatch and iterate (the gate is the oracle). + +- [ ] **Step 4: Update SSOT + roadmap** + +Append a "B-Drag SHIPPED" entry to `claude-memory/project_d2b_retail_ui.md` (key facts + any DO-NOT-RETRY) + update the NEXT pointer. Commit. + +--- + +## Self-review + +**Spec coverage:** optimistic move + rollback (T1) ↔ §Components.3; SendPutItemInContainer (T2) ↔ §Components.1; confirm/rollback wiring (T3) ↔ §Components.3; the drag handler — OnDragLift/OnDragOver/HandleDropRelease + the 3 placements + overlays (T4) ↔ §Components.2 + §Scope goals 1–3; GameWindow wiring (T5) ↔ §Components; divergence (T6) ↔ §Divergence register; full suite + visual gate (T7) ↔ §Acceptance. All covered. + +**Placeholder scan:** every code step is complete; no TBD. The overlay ids are export-confirmed literals (`0x060011F7`/`0x060011F8`); the AP-60/61 numbers are mechanical (next free in the register). + +**Type consistency:** `MoveItemOptimistic(uint,uint,int)`/`ConfirmMove(uint)`/`RollbackMove(uint)` consistent T1↔T3↔T4. `SendPutItemInContainer(uint,uint,int)` consistent T2↔T5↔T4 (capture). `Action? sendPutItemInContainer` consistent T4 (ctor/Bind/test harness)↔T5. `OnDragLift`/`OnDragOver`/`HandleDropRelease`/`IsContainerFull` all defined in T4; `EffectiveOpen()` pre-exists. Drag sprites `0x060011F7`/`0x060011F8` consistent across T4.