From f7f9ae052e816cd4679381708ae8d090f3b1521e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 19:49:25 +0200 Subject: [PATCH] feat(D.2b): ClientObjectTable optimistic move + confirm/rollback for inventory drag Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 32 ++++++++++++++++ .../Items/ClientObjectTableTests.cs | 38 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 558d12f4..238f0eb7 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -44,6 +44,11 @@ public sealed class ClientObjectTable private readonly ConcurrentDictionary _containers = new(); private readonly Dictionary> _containerIndex = new(); + // 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 _pendingMoves = new(); + /// Fires when an object is first added to the session. public event Action? ObjectAdded; @@ -130,6 +135,33 @@ public sealed class ClientObjectTable return true; } + /// + /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST + /// time it moves, then (immediate repaint via ObjectMoved). The wire + /// PutItemInContainer is sent by the caller; / + /// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile. + /// + 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); + } + + /// The server confirmed the move (InventoryPutObjInContainer 0x0022 echo) — drop the + /// pending snapshot. No-op for a server-initiated move we never tracked. + public void ConfirmMove(uint itemId) => _pendingMoves.Remove(itemId); + + /// 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. + public bool RollbackMove(uint itemId) + { + if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; + _pendingMoves.Remove(itemId); + return MoveItem(itemId, pre.container, pre.slot); + } + /// /// Handle a server-driven remove (destroyed item, dropped into 3D /// space, stolen, etc). diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index d7d58232..dc239fda 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -358,6 +358,44 @@ public sealed class ClientObjectTableTests Assert.Null(table.Get(0xA01u)); } + [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)); + [Fact] public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out {