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(); } }