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

29 KiB
Raw Blame History

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

[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 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:

    /// <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 (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

[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 (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:

    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 InventoryController the 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 (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):

                    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 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

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 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.