diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 98d4f062..1be5f3ba 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -42,6 +42,7 @@ public sealed class ClientObjectTable { private readonly ConcurrentDictionary _objects = new(); private readonly ConcurrentDictionary _containers = new(); + private readonly Dictionary> _containerIndex = new(); /// Fires when an object is first added to the session. public event Action? ObjectAdded; @@ -89,6 +90,7 @@ public sealed class ClientObjectTable /// Register / refresh an object in the table. Called on /// CreateObject for item-typed weenies and on IdentifyObjectResponse /// to fill in detail properties. + /// Does NOT update the container index — use Ingest for container-tracked objects. /// public void AddOrUpdate(ClientObject item) { @@ -123,7 +125,7 @@ public sealed class ClientObjectTable item.ContainerId = newContainerId; item.ContainerSlot = newSlot; item.CurrentlyEquippedLocation = newEquipLocation; - + Reindex(item, oldContainer); ObjectMoved?.Invoke(item, oldContainer, newContainerId); return true; } @@ -135,6 +137,8 @@ public sealed class ClientObjectTable public bool Remove(uint itemId) { if (!_objects.TryRemove(itemId, out var item)) return false; + if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l)) + l.Remove(itemId); ObjectRemoved?.Invoke(item); return true; } @@ -275,8 +279,31 @@ public sealed class ClientObjectTable return obj; } - // Filled in Task 6 (container index). No-op until then. - private void Reindex(ClientObject obj, uint oldContainerId) { } + private void Reindex(ClientObject obj, uint oldContainerId) + { + if (oldContainerId != obj.ContainerId && oldContainerId != 0 + && _containerIndex.TryGetValue(oldContainerId, out var oldList)) + oldList.Remove(obj.ObjectId); + + if (obj.ContainerId != 0) + { + if (!_containerIndex.TryGetValue(obj.ContainerId, out var list)) + _containerIndex[obj.ContainerId] = list = new List(); + if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId); + list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b))); + } + } + + private int SlotOf(uint guid) => + _objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue; + + /// + /// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot. + /// Returns a SNAPSHOT (safe to hold / read off-thread); empty for an unknown container. + /// + public IReadOnlyList GetContents(uint containerId) => + _containerIndex.TryGetValue(containerId, out var l) + ? l.ToArray() : System.Array.Empty(); /// /// Flush the table — typically called on logoff or teleport @@ -286,5 +313,6 @@ public sealed class ClientObjectTable { _objects.Clear(); _containers.Clear(); + _containerIndex.Clear(); } } diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 04309bc6..2aed953f 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -317,4 +317,56 @@ public sealed class ClientObjectTableTests Assert.Equal(0x06001234u, table.Get(0x500000B6u)!.IconId); // data NOT clobbered by membership Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation); } + + [Fact] + public void ContainerIndex_IngestThenContents_OrderedBySlot() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x510u, container: 0xC0u)); + table.Ingest(FullWeenie(0x511u, container: 0xC0u)); + table.MoveItem(0x510u, 0xC0u, newSlot: 1); + table.MoveItem(0x511u, 0xC0u, newSlot: 0); + Assert.Equal(new[] { 0x511u, 0x510u }, table.GetContents(0xC0u)); + } + + [Fact] + public void ContainerIndex_Move_ReparentsBetweenContainers() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x520u, container: 0xC1u)); + table.MoveItem(0x520u, 0xC2u, newSlot: 0); + Assert.Empty(table.GetContents(0xC1u)); + Assert.Equal(new[] { 0x520u }, table.GetContents(0xC2u)); + } + + [Fact] + public void ContainerIndex_Remove_DropsFromContents() + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x530u, container: 0xC3u)); + table.Remove(0x530u); + Assert.Empty(table.GetContents(0xC3u)); + } + + [Fact] + public void GetContents_UnknownContainer_Empty() + { + var table = new ClientObjectTable(); + Assert.Empty(table.GetContents(0xDEADu)); + } + + [Fact] + public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out + { + var table = new ClientObjectTable(); + table.Ingest(FullWeenie(0x540u, container: 0xC4u)); + table.Ingest(FullWeenie(0x541u, container: 0xC4u)); + table.MoveItem(0x540u, 0xC4u, newSlot: 0); + table.MoveItem(0x541u, 0xC4u, newSlot: 1); + Assert.Equal(new[] { 0x540u, 0x541u }, table.GetContents(0xC4u)); + // move 0x540 to a later slot WITHIN THE SAME container — order must flip + table.MoveItem(0x540u, 0xC4u, newSlot: 5); + Assert.Equal(new[] { 0x541u, 0x540u }, table.GetContents(0xC4u)); + Assert.Equal(2, table.GetContents(0xC4u).Count); // no duplicate from the same-container move + } }