fix(D.2b): gapless insert on optimistic move — stop the inventory reshuffle

Visual gate: moving an item within a bag reshuffled every item ("the order is
not set"). Root cause: insert-before set ONLY the dragged item's ContainerSlot
to N, colliding with the item already at N; the sort-by-slot tie then reordered
the whole grid on every repaint. Fix: MoveItemOptimistic now does a proper
index-based INSERT (shift the others, renumber 0..N-1 gapless) like retail's
ItemList_InsertItem, and HandleDropRelease uses the target's GRID INDEX
(SlotIndex) as the placement rather than its raw ContainerSlot. Regression test
pins the shifted, gapless order. Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-22 20:29:22 +02:00
parent 975f89c870
commit 82ab0e045a
3 changed files with 52 additions and 2 deletions

View file

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

View file

@ -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<uint>();
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;
}
/// <summary>Assign each item in <paramref name="list"/> a sequential ContainerSlot (0..N-1) matching
/// its list position — keeps a container gapless + collision-free so the grid order is stable.</summary>
private void RenumberContainer(List<uint> list)
{
for (int i = 0; i < list.Count; i++)
if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i;
}
/// <summary>The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the

View file

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