From 48bf752cf1cf8553c9257def1acdb61fea40e42f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 14:27:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.2b):=20ClientObjectTable.ReplaceContents?= =?UTF-8?q?=20=E2=80=94=20authoritative=20full-replace=20for=20ViewContent?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Items/ClientObjectTable.cs | 46 +++++++++++++++++++ .../Items/ClientObjectTableTests.cs | 39 ++++++++++++++++ 2 files changed, 85 insertions(+) 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 {