diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 7d43c24e..89bf6b1c 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -333,7 +333,7 @@ public sealed class InventoryController : IItemListDragHandler { container = EffectiveOpen(); placement = targetCell.ItemId != 0 - ? (_objects.Get(targetCell.ItemId)?.ContainerSlot ?? targetCell.SlotIndex) // insert-before + ? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot : _objects.GetContents(container).Count; // first empty = append } else if (targetList == _containerList || targetList == _topContainer) diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 73a40315..2eb0e69f 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -152,7 +152,40 @@ public sealed class ClientObjectTable _pendingMoves[itemId] = (p.container, p.slot, p.outstanding + 1); else _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, 1); - return MoveItem(itemId, newContainerId, newSlot); + + // 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 + // the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint + // (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list. + uint oldContainer = item.ContainerId; + item.ContainerId = newContainerId; + item.CurrentlyEquippedLocation = EquipMask.None; + + if (oldContainer != 0 && oldContainer != newContainerId + && _containerIndex.TryGetValue(oldContainer, out var srcList)) + { srcList.Remove(itemId); RenumberContainer(srcList); } // keep the source gapless too + + if (newContainerId != 0) + { + if (!_containerIndex.TryGetValue(newContainerId, out var dstList)) + _containerIndex[newContainerId] = dstList = new List(); + dstList.Remove(itemId); // same-container move: pull out first + int idx = (newSlot < 0 || newSlot > dstList.Count) ? dstList.Count : newSlot; + dstList.Insert(idx, itemId); + RenumberContainer(dstList); // sequential ContainerSlots → no ties + } + else item.ContainerSlot = newSlot; + + ObjectMoved?.Invoke(item, oldContainer, newContainerId); + return true; + } + + /// 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) + { + for (int i = 0; i < list.Count; i++) + if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i; } /// The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 188370c6..833ebb9f 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -422,6 +422,23 @@ public sealed class ClientObjectTableTests Assert.Equal(0xC5u, table.Get(0x911u)!.ContainerId); } + [Fact] + public void MoveItemOptimistic_insertBefore_shiftsOthers_keepsGaplessOrder() // the "items change position" bug + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x920u, container: 0xC0u)); table.MoveItem(0x920u, 0xC0u, 0); + table.Ingest(FullWeenie(0x921u, container: 0xC0u)); table.MoveItem(0x921u, 0xC0u, 1); + table.Ingest(FullWeenie(0x922u, container: 0xC0u)); table.MoveItem(0x922u, 0xC0u, 2); + + // Move 0x922 (index 2) to index 0 → order [0x922, 0x920, 0x921], slots renumbered 0,1,2 (no ties, + // no reshuffle). A raw slot-set would leave 0x922 + 0x920 both at slot 0 → unstable sort. + table.MoveItemOptimistic(0x922u, 0xC0u, 0); + Assert.Equal(new[] { 0x922u, 0x920u, 0x921u }, table.GetContents(0xC0u)); + Assert.Equal(0, table.Get(0x922u)!.ContainerSlot); + Assert.Equal(1, table.Get(0x920u)!.ContainerSlot); + Assert.Equal(2, table.Get(0x921u)!.ContainerSlot); + } + [Fact] public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out {