using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
namespace AcDream.Core.Items;
///
/// The client's table of every server object (retail weenie_object_table /
/// CObjectMaint). Resolve by guid via Get.
///
///
/// Retail semantics (r06):
///
/// -
/// Every object is a with a unique
/// ObjectId. CreateObject seeds it when the server tells us
/// the item exists (in our inventory, on the ground, in a
/// vendor's list, etc).
///
/// -
/// Moves happen via -carrying messages:
/// WieldObject, InventoryPutObjInContainer,
/// InventoryPutObjectIn3D, ViewContents,
/// CloseGroundContainer.
///
/// -
/// InventoryServerSaveFailed reverts a speculative local
/// state change (e.g. when a drag-drop was rejected server-side).
///
///
///
///
///
/// Thread safety: designed for single-threaded use from the render
/// thread; the event delegates run synchronously on the caller's
/// thread. A backs the
/// map so plugin code can look up items from any thread without
/// corrupting state.
///
///
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;
///
/// Fires when an object's container / slot changes (moved between
/// packs, equipped, unequipped, dropped on ground). Old and new
/// container ids are 0 if origin or destination is "world" / "nowhere".
///
public event Action? ObjectMoved;
/// Fires when an object is removed from the session.
public event Action? ObjectRemoved;
/// Fires when an object's properties are updated (typically after Appraise).
public event Action? ObjectUpdated;
/// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield;
/// the typed mirror maintains on
/// .
public const uint UiEffectsPropertyId = 18u;
public int ObjectCount => _objects.Count;
public int ContainerCount => _containers.Count;
public IEnumerable Objects => _objects.Values;
public IEnumerable Containers => _containers.Values;
///
/// Look up an object by its server-assigned ObjectId.
///
public ClientObject? Get(uint objectId) =>
_objects.TryGetValue(objectId, out var item) ? item : null;
///
/// Look up a container by object id, creating a lightweight stub if
/// the id doesn't match any known container (defensive — avoids losing
/// references when the server announces a move into a container it
/// hasn't described yet).
///
public Container? GetContainer(uint objectId) =>
_containers.TryGetValue(objectId, out var c) ? c : null;
///
/// 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)
{
ArgumentNullException.ThrowIfNull(item);
bool existed = _objects.ContainsKey(item.ObjectId);
_objects[item.ObjectId] = item;
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
}
///
/// Register a container. Idempotent.
///
public void AddContainer(Container container)
{
ArgumentNullException.ThrowIfNull(container);
_containers[container.ObjectId] = container;
}
///
/// Handle a server-driven move — called from
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
/// and fires ObjectMoved.
///
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
uint oldContainer = item.ContainerId;
item.ContainerId = newContainerId;
item.ContainerSlot = newSlot;
item.CurrentlyEquippedLocation = newEquipLocation;
Reindex(item, oldContainer);
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
return true;
}
///
/// Handle a server-driven remove (destroyed item, dropped into 3D
/// space, stolen, etc).
///
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;
}
///
/// Apply a patch (e.g. from an
/// IdentifyObjectResponse) to an existing object. Individual
/// keys in the incoming bundle overwrite existing values; keys not
/// present are left untouched.
///
public bool UpdateProperties(uint itemId, PropertyBundle incoming)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
ObjectUpdated?.Invoke(item);
return true;
}
///
/// Apply a patch, creating the object if it does not
/// exist yet. Used for the local player's own properties from PlayerDescription
/// (0x0013), which may arrive BEFORE the player's CreateObject — unlike
/// , which no-ops on an unknown object. Fires
/// ObjectAdded on create, else ObjectUpdated.
///
public void UpsertProperties(uint guid, PropertyBundle incoming)
{
ArgumentNullException.ThrowIfNull(incoming);
bool existed = _objects.TryGetValue(guid, out var item);
if (!existed || item is null)
{
item = new ClientObject { ObjectId = guid };
_objects[guid] = item;
}
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value;
foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value;
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item);
}
///
/// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an
/// object: store it in the bundle and, for known typed ints, mirror to the typed
/// field. Today: UiEffects (18) → . Fires
/// ObjectUpdated so bound widgets re-composite. Extensible hook for future
/// typed PropertyInts (StackSize, Structure, …). False if the object is unknown.
///
public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
{
if (!_objects.TryGetValue(itemId, out var item)) return false;
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
ObjectUpdated?.Invoke(item);
return true;
}
///
/// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and
/// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the
/// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...).
///
public bool UpdateStackSize(uint guid, int stackSize, int value)
{
if (!_objects.TryGetValue(guid, out var item)) return false;
item.StackSize = stackSize;
item.Value = value;
ObjectUpdated?.Invoke(item);
return true;
}
///
/// Canonical CreateObject ingestion: create-if-absent, else patch the
/// wire-carried fields in place (retail SetWeenieDesc). Preserves the
/// PropertyBundle (appraise) and any field the wire didn't carry.
/// Effects is assigned unconditionally (0 clears) — the D.5.2 icon contract.
///
public ClientObject Ingest(WeenieData d)
{
bool existed = _objects.TryGetValue(d.Guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis
{
obj = new ClientObject { ObjectId = d.Guid };
_objects[d.Guid] = obj;
}
uint oldContainer = obj.ContainerId;
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
if (d.Type is { } t) obj.Type = t;
// WeenieClassId arrives on every CreateObject (fixed prefix) and is never
// legitimately 0 for a real weenie; the != 0 guard avoids clobbering a known
// class id with a spurious 0 (and leaves a PD stub's 0 until CreateObject fills it).
if (d.WeenieClassId != 0) obj.WeenieClassId = d.WeenieClassId;
if (d.IconId != 0) obj.IconId = d.IconId;
if (d.IconOverlayId != 0) obj.IconOverlayId = d.IconOverlayId;
if (d.IconUnderlayId != 0) obj.IconUnderlayId = d.IconUnderlayId;
obj.Effects = d.Effects; // D.5.2 contract
if (d.Value is { } v) obj.Value = v;
if (d.StackSize is { } s) obj.StackSize = s;
if (d.StackSizeMax is { } sm) obj.StackSizeMax = sm;
if (d.Burden is { } b) obj.Burden = b;
if (d.ContainerId is { } c) obj.ContainerId = c;
if (d.WielderId is { } w) obj.WielderId = w;
if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl;
if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl;
if (d.Priority is { } pr) obj.Priority = pr;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc;
if (d.Structure is { } st) obj.Structure = st;
if (d.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj;
}
///
/// PlayerDescription manifest: record that this guid is the player's
/// (in inventory or equipped at ), creating an
/// empty entry if CreateObject hasn't arrived yet. Never touches
/// icon/name/type/effects — that data comes from CreateObject.
///
public ClientObject RecordMembership(uint guid, uint containerId = 0,
EquipMask equip = EquipMask.None)
{
bool existed = _objects.TryGetValue(guid, out var obj);
if (!existed || obj is null) // keep: satisfies nullable flow analysis
{
obj = new ClientObject { ObjectId = guid };
_objects[guid] = obj;
}
uint oldContainer = obj.ContainerId;
if (containerId != 0) obj.ContainerId = containerId;
if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip;
Reindex(obj, oldContainer);
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
return obj;
}
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();
///
/// 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
/// hierarchy is 2-deep) plus items wielded by the owner. The client-side
/// equivalent of the server's EncumbranceVal (PropertyInt 5) — used by
/// the inventory burden bar until the wire value is parsed (B-Wire). The hop
/// cap (8) is a cycle guard; real chains are ≤2.
///
public int SumCarriedBurden(uint ownerGuid)
{
int total = 0;
foreach (var o in _objects.Values)
if (IsCarriedBy(o, ownerGuid))
total += o.Burden;
return total;
}
private bool IsCarriedBy(ClientObject o, uint ownerGuid)
{
if (o.WielderId == ownerGuid) return true;
uint c = o.ContainerId;
for (int hops = 0; c != 0 && hops < 8; hops++)
{
if (c == ownerGuid) return true;
c = _objects.TryGetValue(c, out var parent) ? parent.ContainerId : 0u;
}
return false;
}
///
/// Flush the table — typically called on logoff or teleport
/// that drops the session's object state.
///
public void Clear()
{
_objects.Clear();
_containers.Clear();
_containerIndex.Clear();
}
}