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>
121 lines
9.4 KiB
Markdown
121 lines
9.4 KiB
Markdown
# 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 1–3) → `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.
|