diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index b789c11c..558d12f4 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -314,6 +314,52 @@ public sealed class ClientObjectTable _containerIndex.TryGetValue(containerId, out var l) ? l.ToArray() : System.Array.Empty(); + /// + /// Replace a container's entire membership with (in order) — the + /// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a + /// container. Members no longer present are detached (ContainerId → 0); new members are + /// recorded with ContainerSlot = list index. ACE writes ViewContents entries + /// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the + /// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints. + /// Retail consumer: ClientUISystem::OnViewContents. + /// + public void ReplaceContents(uint containerId, IReadOnlyList guids) + { + ArgumentNullException.ThrowIfNull(guids); + if (containerId == 0) return; + + var keep = new HashSet(guids); + + // Detach prior members no longer present (they left the container server-side). GetContents + // returns a snapshot, so mutating the index inside the loop is safe. + foreach (var old in GetContents(containerId)) + { + if (keep.Contains(old)) continue; + if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue; + o.ContainerId = 0; + Reindex(o, containerId); + ObjectMoved?.Invoke(o, containerId, 0); + } + + // Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition. + for (int i = 0; i < guids.Count; i++) + { + uint g = guids[i]; + bool existed = _objects.TryGetValue(g, out var obj); + if (!existed || obj is null) + { + obj = new ClientObject { ObjectId = g }; + _objects[g] = obj; + } + uint oldContainer = obj.ContainerId; + obj.ContainerId = containerId; + obj.ContainerSlot = i; + Reindex(obj, oldContainer); + if (!existed) ObjectAdded?.Invoke(obj); + else ObjectMoved?.Invoke(obj, oldContainer, containerId); + } + } + /// /// Σ Burden over every object carried by : items /// whose container chain roots at the owner (pack + side-bag contents, retail diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 3d873cca..d7d58232 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -319,6 +319,45 @@ public sealed class ClientObjectTableTests Assert.True((o.Type & ItemType.Creature) != 0); // LiveItemType(guid) & Creature drives creature targeting } + [Fact] + public void ReplaceContents_RecordsAllInListOrder() + { + var table = new ClientObjectTable(); + table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); + Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u)); + Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId); + } + + [Fact] + public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge) + { + var table = new ClientObjectTable(); + table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); + table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side + Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u)); + Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering + Assert.Equal(0u, table.Get(0xA03u)!.ContainerId); + Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId); + } + + [Fact] + public void ReplaceContents_ReordersToNewListOrder() + { + var table = new ClientObjectTable(); + table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u }); + table.ReplaceContents(0xC9u, new uint[] { 0xA02u, 0xA01u }); + Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u)); + } + + [Fact] + public void ReplaceContents_ZeroContainer_NoOp() + { + var table = new ClientObjectTable(); + table.ReplaceContents(0u, new uint[] { 0xA01u }); + Assert.Empty(table.GetContents(0u)); + Assert.Null(table.Get(0xA01u)); + } + [Fact] public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out {