feat(D.2b): ClientObjectTable.ReplaceContents — authoritative full-replace for ViewContents

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-22 14:27:13 +02:00
parent 24a0b84162
commit 48bf752cf1
2 changed files with 85 additions and 0 deletions

View file

@ -314,6 +314,52 @@ public sealed class ClientObjectTable
_containerIndex.TryGetValue(containerId, out var l)
? l.ToArray() : System.Array.Empty<uint>();
/// <summary>
/// Replace a container's entire membership with <paramref name="guids"/> (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.
/// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
{
ArgumentNullException.ThrowIfNull(guids);
if (containerId == 0) return;
var keep = new HashSet<uint>(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);
}
}
/// <summary>
/// Σ Burden over every object carried by <paramref name="ownerGuid"/>: items
/// whose container chain roots at the owner (pack + side-bag contents, retail

View file

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