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>
This commit is contained in:
parent
3b66858893
commit
b657c82df5
1 changed files with 577 additions and 0 deletions
577
docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md
Normal file
577
docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md
Normal file
|
|
@ -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<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 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<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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue