acdream/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md
Erik b657c82df5 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) <noreply@anthropic.com>
2026-06-22 19:45:47 +02:00

577 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<uint, (uint container, int slot)> _pendingMoves = new();
```
Add the methods after `MoveItem` (~line 131):
```csharp
/// <summary>
/// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST
/// time it moves, then <see cref="MoveItem"/> (immediate repaint via ObjectMoved). The wire
/// PutItemInContainer is sent by the caller; <see cref="ConfirmMove"/>/<see cref="RollbackMove"/>
/// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile.
/// </summary>
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);
}
/// <summary>The server confirmed the move (InventoryPutObjInContainer 0x0022 echo) — drop the
/// pending snapshot. No-op for a server-initiated move we never tracked.</summary>
public void ConfirmMove(uint itemId) => _pendingMoves.Remove(itemId);
/// <summary>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.</summary>
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) <noreply@anthropic.com>"
```
---
## 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
/// <summary>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.</summary>
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<uint>? uses = null, List<uint>? 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<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
```
Append to the private ctor params (after `sendNoLongerViewing`):
```csharp
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? 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<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? 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) ──────────────
/// <summary>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.</summary>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { }
/// <summary>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.</summary>
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;
}
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
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);
}
/// <summary>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.</summary>
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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 13; 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<uint,uint,int>? 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.