feat(D.5.4): live container membership index (object_inventory_table)

Reindex on Ingest/MoveItem/Remove; GetContents(containerId) ordered by slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-18 16:11:58 +02:00
parent d9c427cd6c
commit 2e3f209707
2 changed files with 83 additions and 3 deletions

View file

@ -42,6 +42,7 @@ public sealed class ClientObjectTable
{
private readonly ConcurrentDictionary<uint, ClientObject> _objects = new();
private readonly ConcurrentDictionary<uint, Container> _containers = new();
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
/// <summary>Fires when an object is first added to the session.</summary>
public event Action<ClientObject>? 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.
/// </summary>
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<uint>();
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;
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<uint> GetContents(uint containerId) =>
_containerIndex.TryGetValue(containerId, out var l)
? l.ToArray() : System.Array.Empty<uint>();
/// <summary>
/// Flush the table — typically called on logoff or teleport
@ -286,5 +313,6 @@ public sealed class ClientObjectTable
{
_objects.Clear();
_containers.Clear();
_containerIndex.Clear();
}
}

View file

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