acdream/docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md
Erik 3b66858893 docs(D.2b): inventory drag-drop (item moving) design spec
Brainstorm output for B-Drag. Drop an inventory item: empty grid slot ->
first empty; on an item -> insert before; on a side-bag cell -> into that
container. Green insert-arrow (valid) / red circle (full). Movement is
OPTIMISTIC/instant per the user — local MoveItem on drop + repaint, server
reconciles via 0x0022 echo, rolls back via 0x00A0 (the rollback the B-Wire
note reserved). InventoryController : IItemListDragHandler; pending-move
tracking in ClientObjectTable (Core, reachable from the Core.Net handlers);
SendPutItemInContainer wraps BuildPickUp 0x0019. Retail anchors: InqDropIconInfo
0x004e26f0 / ItemList_InsertItem / HandleDropRelease / ServerSaysMoveItem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:28:05 +02:00

121 lines
9.4 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) — design
**Date:** 2026-06-22
**Branch:** `claude/hopeful-maxwell-214a12`
**Phase:** D.2b retail-UI inventory arc — B-Drag (inventory drag SOURCE + drop placement).
**Brainstorm:** this doc. Builds on the shipped drag-drop spine (B.1) + container-switching.
---
## Goal
Drag an inventory item and drop it to move it, **instantly**:
1. **on an empty grid slot** → the item goes to the **first empty slot** of the open pack (pack-to-front),
2. **on an occupied item****insert before** it (the rest shift down),
3. **on a side-bag cell** → the item goes **into that container**,
with a **green insert-arrow** overlay while a valid drop is hovered and a **red circle** when the target container is **full**.
The move **feels instant**: the grid updates locally the moment you drop, with the server reconciling in the background (and snapping the item back only if it bounces the move).
## Scope
**In:**
- Inventory cells as drag **sources** (already: `UiItemSlot.IsDragSource` when occupied, `SourceKind = Inventory`).
- `InventoryController` as the drop **handler** (`IItemListDragHandler`) on the contents grid + the side-bag list.
- The three drop placements (goal 13) → `PutItemInContainer 0x0019`.
- **Optimistic local move** on drop (instant repaint) + **rollback** on `InventoryServerSaveFailed`.
- **Accept/reject overlays**: green insert-arrow (valid) / red circle (target bag full).
**Out (separate work, with reasons):**
- **Equipping via the paperdoll** (`GetAndWieldItem 0x001A`) — Sub-phase C / its own drop path.
- **Drop-to-ground** (`DropItem 0x001B`) — separate interaction.
- **Dragging the side-bag cells themselves** (reordering bags) — bags are drop *targets* here, not sources.
- **Stack split/merge** on drop — deferred (the Selected-Target-Bar split is its own feature).
## Retail anchors (the spec is a faithful port)
| Concern | Retail | Note |
|---|---|---|
| Drop placement + accept/reject flags | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | reads the cell's drop-info properties → placement + `DropItemFlags` |
| Insert-before | `UIElement_ItemList::ItemList_InsertItem` | the dragged item takes the target slot; rest shift |
| Commit the drop | `UIElement_ItemList::HandleDropRelease` | issues the move |
| Local move + server reconcile | `ItemList_InsertItem` (local) + `RecvNotice_ServerSaysMoveItem`/`ServerSaysMoveItem` | retail moves locally then reconciles — this is the "instant + rollback" model |
| Wire | `PutItemInContainer 0x0019` (`item, container, placement`) → ACE `Player.HandleActionPutItemInContainer` | placement = the target slot index (server packs/shifts) |
## Components
### 1. `WorldSession.SendPutItemInContainer` (Core.Net — new wrapper)
Thin wrapper over the existing `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` (opcode `0x0019`), mirroring `SendUse`/`SendNoLongerViewingContents`:
```
public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement)
```
### 2. `InventoryController : IItemListDragHandler` (App)
Registered on the contents grid + the side-bag list in `Populate` (`list.RegisterDragHandler(this)`), like `ToolbarController`. Cells already default `SourceKind = Inventory`; set each cell's `SlotIndex` so the drop target knows its position.
- **`OnDragLift` → no-op.** The item **stays in its slot** during the drag (unlike the toolbar's remove-on-lift); it moves only on drop. (Retail dims the source; we leave it in place + the floating ghost — a minor approximation.)
- **`OnDragOver(targetList, targetCell, payload)` → accept/reject overlay (advisory):**
- target is a **grid slot** (the open pack) → **accept** (reorder/insert is always valid in the open container).
- target is a **side-bag cell****accept** unless that bag is **known-full** (`GetContents(bag).Count >= ItemsCapacity` when we know the count) → **reject**. A *closed* bag whose count we don't know → **accept** (advisory; the server is authoritative).
- Sets `targetCell.DragAcceptSprite` = the green insert-arrow / `DragRejectSprite` = the red circle (ids §Overlays).
- **`HandleDropRelease(targetList, targetCell, payload)` → optimistic move + wire:**
1. Resolve `(targetContainer, placement)`:
- grid empty slot → `targetContainer = EffectiveOpen()`, `placement = append` (first empty = end of the packed list).
- grid occupied slot N → `targetContainer = EffectiveOpen()`, `placement = targetCell.SlotIndex` (insert-before).
- side-bag cell → `targetContainer = targetCell.ItemId` (the bag guid), `placement = append`.
2. If the target is **known-full** (the reject case) → no-op (the red overlay already showed; nothing moves).
3. Else: record the item's **pre-move** `(ContainerId, ContainerSlot)` in a pending-move map; **`_objects.MoveItem(item, targetContainer, placement)` locally** (instant repaint via `ObjectMoved``Concerns``Populate`); **`SendPutItemInContainer(item, targetContainer, placement)`**.
### 3. Optimistic reconcile + rollback — `ClientObjectTable` (Core)
The pending-move tracking lives in **`ClientObjectTable`** (Core), so BOTH the App drop handler and the Core.Net wire handlers reach it (Core.Net can't depend on App):
- `MoveItemOptimistic(itemId, newContainer, newSlot)` — snapshot the item's current `(ContainerId, ContainerSlot)` into a small pending map, then `MoveItem` (fires `ObjectMoved` → instant repaint). Called by `HandleDropRelease`.
- `ConfirmMove(itemId)` — clear the pending entry (the move stuck).
- `RollbackMove(itemId)``MoveItem` back to the snapshotted `(container, slot)`, then clear. Returns false if no pending entry.
Wiring (`GameEventWiring`):
- **Reconcile (success):** the `InventoryPutObjInContainer 0x0022` echo already routes to `MoveItem` (no-op/correction for a move we initiated) — add `items.ConfirmMove(itemGuid)` after it.
- **Rollback (failure):** `InventoryServerSaveFailed 0x00A0` (today it only logs) → `items.RollbackMove(itemGuid)` (snap back). B-Drag is what the B-Wire note reserved this handler for.
### 4. Overlays (sprites)
Green insert-arrow + red circle, **dat-exported + visual-gate-confirmed** before pinning (per the empty-slot-art / backpack-icon lesson). Candidates from the shipped spine: green move-arrow `0x060011F7`, red ∅ `0x060011F8`, green ring `0x060011F9` (inventory accept). `UiItemSlot` already draws `DragAcceptSprite`/`DragRejectSprite` overlays on DragEnter/Reject — the controller sets the inventory ids.
## Data flow
```
drop item on a target cell
→ OnDragOver already showed green (valid) / red (full)
→ HandleDropRelease: resolve (container, placement)
known-full? → no-op (red, nothing moves)
else → record pre-move pos
→ MoveItem(item, container, placement) LOCALLY → instant repaint
→ SendPutItemInContainer(item, container, placement)
─────────── server ───────────
→ InventoryPutObjInContainer 0x0022 (confirm) → MoveItem (no-op/correct) → clear pending
OR
→ InventoryServerSaveFailed 0x00A0 (reject) → MoveItem back to pre-move → clear pending
```
## Divergence register
- **`OnDragLift` no-op / source not dimmed** — retail dims the lifted item's source cell; we leave it in place + the floating ghost (minor visual).
- **Closed-bag drop is advisory-accept** — the client can't know a closed bag's fullness (contents aren't indexed until opened), so it shows green + relies on the server's reject + rollback. Retail knows the count if loaded.
- Accept/reject overlay ids set by the controller (procedural), pinned by dat-export (cf. AP-55/57).
- Retire/relax the B-Wire note on `InventoryServerSaveFailed` ("B-Drag wires the rollback") — now wired.
## Test plan
- **Core.Net:** `SendPutItemInContainer` wire (opcode `0x0019`, item/container/placement) — extend `InteractRequestsTests` (the builder is already covered; assert the wrapper path if a seam exists, else rely on the builder test).
- **App (`InventoryControllerTests`):** the handler — extend the fake-layout harness with a `SendPutItemInContainer` capture.
- drop on empty grid slot → `MoveItem(item, openContainer, append)` locally + wire fired with the open container.
- drop on occupied slot N → wire `placement == N` (insert-before); local grid shows the item at N.
- drop on a side-bag cell → wire `container == bagGuid`.
- `OnDragOver`: grid → accept; full side bag → reject; closed bag → accept.
- rollback: simulate `InventoryServerSaveFailed` → item returns to its pre-move container/slot.
- **Core:** a pending-move rollback unit (record pre-move, restore on failure) if the mover lives in Core.
## Acceptance
- [ ] Decomp anchors cited; divergence rows added same-commit.
- [ ] `dotnet build` + full `dotnet test` green.
- [ ] **Visual gate (instant):** drag an item onto an empty slot → it lands (first empty) the instant you release; onto an item → inserts before it; onto a side bag → goes in; a full bag shows the red circle and bounces; a valid target shows the green insert-arrow. WireMCP confirms `PutItemInContainer 0x0019` + the `0x0022` echo (and a `0x00A0` bounce on a full bag).
- [ ] SSOT + roadmap updated.
## Open verification (at the gate)
- Pin the green-arrow / red-circle sprite ids by dat-export + the visual gate.
- Confirm ACE's `placement` interpretation (insert-before-N vs append) against a live capture; adjust the placement mapping if ACE packs differently.