From 0c353185c3536ebac628ec43fa8f54f294cb6eb1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:03:47 +0200 Subject: [PATCH] feat(D.2b): WieldItemOptimistic + equip-aware rollback snapshot (Core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WieldItemOptimistic (instant optimistic wield — sets ContainerId=WielderId=player, CurrentlyEquippedLocation=equipMask, snapshots pre-wield position) and extend the _pendingMoves tuple to carry the pre-move EquipMask so RollbackMove restores EQUIPPED state faithfully on server rejection. Shared RecordPending helper replaces the inline snapshot block in MoveItemOptimistic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 38 +++++++++++---- .../Items/ClientObjectTableTests.cs | 46 +++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 2eb0e69f..2a310ea4 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -44,10 +44,10 @@ 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 + // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. - private readonly Dictionary _pendingMoves = new(); + private readonly Dictionary _pendingMoves = new(); /// Fires when an object is first added to the session. public event Action? ObjectAdded; @@ -135,6 +135,19 @@ public sealed class ClientObjectTable return true; } + /// Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and + /// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm + /// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/ + /// move) and WieldItemOptimistic (wield). + private void RecordPending(uint itemId, ClientObject item) + { + if (_pendingMoves.TryGetValue(itemId, out var p)) + _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1); + else + _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, + item.CurrentlyEquippedLocation, 1); + } + /// /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST /// time it moves, then (immediate repaint via ObjectMoved). The wire @@ -148,10 +161,7 @@ public sealed class ClientObjectTable // count stops an early confirm (0x0022) for the first of several in-flight moves of the SAME // item from clearing the snapshot while a later move is still unconfirmed — else a later // reject would find nothing pending and strand the item at the rejected position. - if (_pendingMoves.TryGetValue(itemId, out var p)) - _pendingMoves[itemId] = (p.container, p.slot, p.outstanding + 1); - else - _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, 1); + RecordPending(itemId, item); // Gapless INSERT — treat newSlot as the insert INDEX, shift the other items, and renumber both // containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with @@ -180,6 +190,18 @@ public sealed class ClientObjectTable return true; } + /// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set + /// ContainerId = WielderId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the + /// server's WieldObject 0x0023 confirm), firing ObjectMoved for an immediate repaint. The caller + /// sends GetAndWieldItem; ConfirmMove (on the 0x0023 echo) / RollbackMove (on 0x00A0) reconcile. + public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask) + { + if (!_objects.TryGetValue(itemId, out var item)) return false; + RecordPending(itemId, item); + item.WielderId = wielderGuid; // unambiguously the player's gear during the optimistic window + return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask); + } + /// Assign each item in a sequential ContainerSlot (0..N-1) matching /// its list position — keeps a container gapless + collision-free so the grid order is stable. private void RenumberContainer(List list) @@ -195,7 +217,7 @@ public sealed class ClientObjectTable { if (!_pendingMoves.TryGetValue(itemId, out var p)) return; if (p.outstanding <= 1) _pendingMoves.Remove(itemId); - else _pendingMoves[itemId] = (p.container, p.slot, p.outstanding - 1); + else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1); } /// The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its @@ -205,7 +227,7 @@ public sealed class ClientObjectTable { if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; _pendingMoves.Remove(itemId); - return MoveItem(itemId, pre.container, pre.slot); + return MoveItem(itemId, pre.container, pre.slot, pre.equip); } /// diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 833ebb9f..758e641a 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -453,4 +453,50 @@ public sealed class ClientObjectTableTests Assert.Equal(new[] { 0x541u, 0x540u }, table.GetContents(0xC4u)); Assert.Equal(2, table.GetContents(0xC4u).Count); // no duplicate from the same-container move } + + [Fact] + public void WieldItemOptimistic_equipsInstantly_andSetsWielderAndContainer() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u, pack = 0x40000005u; + table.AddOrUpdate(new ClientObject { ObjectId = 0x940u }); + table.MoveItem(0x940u, pack, newSlot: 2); // a pack item + table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear); + var o = table.Get(0x940u)!; + Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant) + Assert.Equal(player, o.WielderId); + Assert.Equal(player, o.ContainerId); + } + + [Fact] + public void WieldItemOptimistic_rollback_unequips_andReturnsToPack() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u, pack = 0x40000005u; + table.AddOrUpdate(new ClientObject { ObjectId = 0x941u }); + table.MoveItem(0x941u, pack, newSlot: 2); + table.WieldItemOptimistic(0x941u, player, EquipMask.HeadWear); + Assert.True(table.RollbackMove(0x941u)); // server rejected the wield + var o = table.Get(0x941u)!; + Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // un-equipped + Assert.Equal(pack, o.ContainerId); // back in the pack + Assert.Equal(2, o.ContainerSlot); // at its original slot + } + + [Fact] + public void RollbackMove_restoresPreMoveEquipLocation() // unwield-reject must snap back to EQUIPPED + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u; + table.AddOrUpdate(new ClientObject { ObjectId = 0x942u }); + table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped + table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip) + Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation); + Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield + Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped + } + + [Fact] + public void WieldItemOptimistic_unknownItem_false() + => Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear)); }