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>
29 KiB
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 afterMoveItem~line 131) -
Test:
tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs -
Step 1: Write the failing tests
[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):
// 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):
/// <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
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 afterSendUse, ~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:
/// <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
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(theInventoryPutObjInContainerhandler ~line 246-251 and theInventoryServerSaveFailedhandler ~line 280-285) -
Test:
tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs -
Step 1: Write the failing test
[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):
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:
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:
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
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(implementIItemListDragHandler; register on the 3 lists; set drag sprites +SlotIndexon cells; addsendPutItemInContainerctor/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:
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):
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
InventoryControllerthe drag handler
(a) Class declaration — add the interface:
public sealed class InventoryController : IItemListDragHandler
(b) Add the field + ctor/Bind param. Add field near _sendNoLongerViewing:
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
Append to the private ctor params (after sendNoLongerViewing):
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer)
Assign in the ctor body (after _sendNoLongerViewing = ...):
_sendPutItemInContainer = sendPutItemInContainer;
Register the handler on the three lists in the ctor (after the lists are resolved, near the other _contentsGrid setup):
_contentsGrid?.RegisterDragHandler(this);
_containerList?.RegisterDragHandler(this);
_topContainer?.RegisterDragHandler(this);
Append to the public Bind params (after sendNoLongerViewing = null) and thread it through:
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(...):
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:
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(...):
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
(d) Add the handler methods (place near the other private helpers, e.g. after SetCapacityBar):
// ── 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
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(theInventoryController.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):
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
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
OnDragLiftis 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. Filesrc/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
OnDragOvershows green + relies on the server'sInventoryServerSaveFailedreject + the rollback; retail knows the count when loaded. Filesrc/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
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):
$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.