Select the default combat mode from ordered equipped objects so bows request missile stance. Parse CreateObject parent metadata and ParentEvent, then render held objects as separate children composed from setup holding locations and placement frames each animation tick.
596 lines
28 KiB
C#
596 lines
28 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
|
|
namespace AcDream.Core.Items;
|
|
|
|
/// <summary>
|
|
/// Ordered container snapshot entry from retail ContentProfile:
|
|
/// guid plus m_uContainerProperties.
|
|
/// </summary>
|
|
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
|
|
|
|
/// <summary>
|
|
/// The client's table of every server object (retail <c>weenie_object_table</c> /
|
|
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
|
|
///
|
|
/// <para>
|
|
/// Retail semantics (r06):
|
|
/// <list type="bullet">
|
|
/// <item><description>
|
|
/// Every object is a <see cref="ClientObject"/> with a unique
|
|
/// <c>ObjectId</c>. CreateObject seeds it when the server tells us
|
|
/// the item exists (in our inventory, on the ground, in a
|
|
/// vendor's list, etc).
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// Moves happen via <see cref="GameEventType"/>-carrying messages:
|
|
/// <c>WieldObject</c>, <c>InventoryPutObjInContainer</c>,
|
|
/// <c>InventoryPutObjectIn3D</c>, <c>ViewContents</c>,
|
|
/// <c>CloseGroundContainer</c>.
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// <c>InventoryServerSaveFailed</c> reverts a speculative local
|
|
/// state change (e.g. when a drag-drop was rejected server-side).
|
|
/// </description></item>
|
|
/// </list>
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Thread safety: designed for single-threaded use from the render
|
|
/// thread; the event delegates run synchronously on the caller's
|
|
/// thread. A <see cref="ConcurrentDictionary{TKey,TValue}"/> backs the
|
|
/// map so plugin code can look up items from any thread without
|
|
/// corrupting state.
|
|
/// </para>
|
|
/// </summary>
|
|
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();
|
|
|
|
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
|
|
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
|
|
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
|
|
private readonly Dictionary<uint, (uint container, int slot, EquipMask equip, int outstanding)> _pendingMoves = new();
|
|
|
|
/// <summary>Fires when an object is first added to the session.</summary>
|
|
public event Action<ClientObject>? ObjectAdded;
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
public event Action<ClientObject, uint, uint>? ObjectMoved;
|
|
|
|
/// <summary>
|
|
/// Fires after an optimistic inventory/equipment move is rejected and
|
|
/// <see cref="RollbackMove"/> has restored the exact pre-move state.
|
|
/// Consumers with projections outside the item grid (for example a
|
|
/// parented 3-D weapon) can restore their prior projection without
|
|
/// treating every ordinary equip as a rollback.
|
|
/// </summary>
|
|
public event Action<ClientObject>? MoveRolledBack;
|
|
|
|
/// <summary>Fires when an object is removed from the session.</summary>
|
|
public event Action<ClientObject>? ObjectRemoved;
|
|
|
|
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
|
|
public event Action<ClientObject>? ObjectUpdated;
|
|
|
|
/// <summary>PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield;
|
|
/// the typed mirror <see cref="UpdateIntProperty"/> maintains on
|
|
/// <see cref="ClientObject.Effects"/>.</summary>
|
|
public const uint UiEffectsPropertyId = 18u;
|
|
public const uint CurrentWieldedLocationPropertyId = 10u;
|
|
|
|
public int ObjectCount => _objects.Count;
|
|
public int ContainerCount => _containers.Count;
|
|
|
|
public IEnumerable<ClientObject> Objects => _objects.Values;
|
|
public IEnumerable<Container> Containers => _containers.Values;
|
|
|
|
/// <summary>
|
|
/// Look up an object by its server-assigned <c>ObjectId</c>.
|
|
/// </summary>
|
|
public ClientObject? Get(uint objectId) =>
|
|
_objects.TryGetValue(objectId, out var item) ? item : null;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public Container? GetContainer(uint objectId) =>
|
|
_containers.TryGetValue(objectId, out var c) ? c : null;
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(item);
|
|
bool existed = _objects.ContainsKey(item.ObjectId);
|
|
_objects[item.ObjectId] = item;
|
|
if (!existed) ObjectAdded?.Invoke(item);
|
|
else ObjectUpdated?.Invoke(item);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Register a container. Idempotent.
|
|
/// </summary>
|
|
public void AddContainer(Container container)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(container);
|
|
_containers[container.ObjectId] = container;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle a server-driven move — called from
|
|
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023)
|
|
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation
|
|
/// and fires ObjectMoved. Does NOT touch WielderId — neither the wield nor the
|
|
/// unwield path manages it (a wielded item is modeled as contained-by-the-wielder),
|
|
/// so RollbackMove restores full pre-move state through this method alone.
|
|
/// </summary>
|
|
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
|
|
EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null)
|
|
{
|
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
|
|
|
uint oldContainer = item.ContainerId;
|
|
item.ContainerId = newContainerId;
|
|
item.ContainerSlot = newSlot;
|
|
item.CurrentlyEquippedLocation = newEquipLocation;
|
|
if (containerTypeHint is { } hint) item.ContainerTypeHint = hint;
|
|
Reindex(item, oldContainer);
|
|
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and
|
|
/// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm
|
|
/// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/
|
|
/// move) and WieldItemOptimistic (wield).</summary>
|
|
private void RecordPending(uint itemId, ClientObject item)
|
|
{
|
|
if (_pendingMoves.TryGetValue(itemId, out var p))
|
|
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1);
|
|
else
|
|
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot,
|
|
item.CurrentlyEquippedLocation, 1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST
|
|
/// time it moves, then <see cref="MoveItem"/> (immediate repaint via ObjectMoved). The wire
|
|
/// PutItemInContainer is sent by the caller; <see cref="ConfirmMove"/>/<see cref="RollbackMove"/>
|
|
/// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile.
|
|
/// </summary>
|
|
public bool MoveItemOptimistic(uint itemId, uint newContainerId, int newSlot)
|
|
{
|
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
|
// Snapshot the ORIGINAL position once + count OUTSTANDING optimistic moves of this item. The
|
|
// count stops an early confirm (0x0022) for the first of several in-flight moves of the SAME
|
|
// item from clearing the snapshot while a later move is still unconfirmed — else a later
|
|
// reject would find nothing pending and strand the item at the rejected position.
|
|
RecordPending(itemId, item);
|
|
|
|
// Gapless INSERT — treat newSlot as the insert INDEX, shift the other items, and renumber both
|
|
// containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with
|
|
// the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint
|
|
// (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list.
|
|
uint oldContainer = item.ContainerId;
|
|
item.ContainerId = newContainerId;
|
|
item.CurrentlyEquippedLocation = EquipMask.None;
|
|
|
|
if (oldContainer != 0 && oldContainer != newContainerId
|
|
&& _containerIndex.TryGetValue(oldContainer, out var srcList))
|
|
{ srcList.Remove(itemId); RenumberContainer(srcList); } // keep the source gapless too
|
|
|
|
if (newContainerId != 0)
|
|
{
|
|
if (!_containerIndex.TryGetValue(newContainerId, out var dstList))
|
|
_containerIndex[newContainerId] = dstList = new List<uint>();
|
|
dstList.Remove(itemId); // same-container move: pull out first
|
|
int idx = (newSlot < 0 || newSlot > dstList.Count) ? dstList.Count : newSlot;
|
|
dstList.Insert(idx, itemId);
|
|
RenumberContainer(dstList); // sequential ContainerSlots → no ties
|
|
}
|
|
else item.ContainerSlot = newSlot;
|
|
|
|
ObjectMoved?.Invoke(item, oldContainer, newContainerId);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
|
|
/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask — EXACTLY what the server's
|
|
/// WieldObject 0x0023 confirm does via <see cref="MoveItem"/> (acdream models a wielded item as
|
|
/// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the
|
|
/// confirmed state and <see cref="RollbackMove"/> via MoveItem fully restores pre-move state. Fires
|
|
/// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023
|
|
/// echo) / RollbackMove (on 0x00A0) reconcile.</summary>
|
|
public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask)
|
|
{
|
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
|
RecordPending(itemId, item);
|
|
return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask);
|
|
}
|
|
|
|
/// <summary>Assign each item in <paramref name="list"/> a sequential ContainerSlot (0..N-1) matching
|
|
/// its list position — keeps a container gapless + collision-free so the grid order is stable.</summary>
|
|
private void RenumberContainer(List<uint> list)
|
|
{
|
|
for (int i = 0; i < list.Count; i++)
|
|
if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i;
|
|
}
|
|
|
|
/// <summary>The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the
|
|
/// outstanding count; drop the snapshot only once no optimistic moves of this item remain. No-op
|
|
/// for a server-initiated move we never tracked.</summary>
|
|
public void ConfirmMove(uint itemId)
|
|
{
|
|
if (!_pendingMoves.TryGetValue(itemId, out var p)) return;
|
|
if (p.outstanding <= 1) _pendingMoves.Remove(itemId);
|
|
else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1);
|
|
}
|
|
|
|
/// <summary>The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its
|
|
/// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles
|
|
/// any still-outstanding moves). False if nothing was pending.</summary>
|
|
public bool RollbackMove(uint itemId)
|
|
{
|
|
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
|
|
_pendingMoves.Remove(itemId);
|
|
if (!MoveItem(itemId, pre.container, pre.slot, pre.equip))
|
|
return false;
|
|
MoveRolledBack?.Invoke(_objects[itemId]);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle a server-driven remove (destroyed item, dropped into 3D
|
|
/// space, stolen, etc).
|
|
/// </summary>
|
|
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);
|
|
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
|
|
ObjectRemoved?.Invoke(item);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply a <see cref="PropertyBundle"/> patch (e.g. from an
|
|
/// <c>IdentifyObjectResponse</c>) to an existing object. Individual
|
|
/// keys in the incoming bundle overwrite existing values; keys not
|
|
/// present are left untouched.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply a <see cref="PropertyBundle"/> 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
|
|
/// <see cref="UpdateProperties"/>, which no-ops on an unknown object. Fires
|
|
/// ObjectAdded on create, else ObjectUpdated.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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) → <see cref="ClientObject.Effects"/>. Fires
|
|
/// ObjectUpdated so bound widgets re-composite. Extensible hook for future
|
|
/// typed PropertyInts (StackSize, Structure, …). False if the object is unknown.
|
|
/// </summary>
|
|
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;
|
|
if (propertyId == CurrentWieldedLocationPropertyId)
|
|
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
|
|
ObjectUpdated?.Invoke(item);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply a single PropertyInt64 update to an object's bundle and fire
|
|
/// ObjectUpdated so bound widgets refresh. Int64 counterpart of
|
|
/// <see cref="UpdateIntProperty"/> (used by the character sheet's
|
|
/// optimistic unassigned-XP debit; also the hook for future
|
|
/// PrivateUpdatePropertyInt64 parsing). False if the object is unknown.
|
|
/// </summary>
|
|
public bool UpdateInt64Property(uint itemId, uint propertyId, long value)
|
|
{
|
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
|
item.Properties.Int64s[propertyId] = value;
|
|
ObjectUpdated?.Invoke(item);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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...).
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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 (!string.IsNullOrEmpty(d.PluralName)) obj.PluralName = d.PluralName!;
|
|
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.Useability is { } use) obj.Useability = use;
|
|
if (d.TargetType is { } targetType) obj.TargetType = targetType;
|
|
if (d.PublicWeenieBitfield is { } bitfield) obj.PublicWeenieBitfield = bitfield;
|
|
if (d.PetOwnerId is { } petOwnerId) obj.PetOwnerId = petOwnerId;
|
|
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
|
|
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
|
|
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// PlayerDescription manifest: record that this guid is the player's
|
|
/// (in inventory or equipped at <paramref name="equip"/>), creating an
|
|
/// empty entry if CreateObject hasn't arrived yet. Never touches
|
|
/// icon/name/type/effects — that data comes from CreateObject.
|
|
/// </summary>
|
|
public ClientObject RecordMembership(uint guid, uint containerId = 0,
|
|
EquipMask equip = EquipMask.None, uint? containerTypeHint = null)
|
|
{
|
|
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;
|
|
obj.CurrentlyEquippedLocation = EquipMask.None;
|
|
}
|
|
else
|
|
{
|
|
obj.CurrentlyEquippedLocation = equip;
|
|
}
|
|
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
|
|
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<uint>();
|
|
if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId);
|
|
var priorOrder = new Dictionary<uint, int>(list.Count);
|
|
for (int i = 0; i < list.Count; i++)
|
|
priorOrder[list[i]] = i;
|
|
list.Sort((a, b) =>
|
|
{
|
|
int c = SlotOf(a).CompareTo(SlotOf(b));
|
|
return c != 0 ? c : priorOrder[a].CompareTo(priorOrder[b]);
|
|
});
|
|
}
|
|
}
|
|
|
|
private int SlotOf(uint guid) =>
|
|
_objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0
|
|
? 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>
|
|
/// 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 entries = new ContainerContentEntry[guids.Count];
|
|
for (int i = 0; i < guids.Count; i++)
|
|
entries[i] = new ContainerContentEntry(guids[i], 0u);
|
|
ReplaceContents(containerId, entries);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replace a container's entire membership with <paramref name="entries"/> in retail order.
|
|
/// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the
|
|
/// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType
|
|
/// chooses the loose-item list versus the side-pack/container list.
|
|
/// </summary>
|
|
public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entries);
|
|
if (containerId == 0) return;
|
|
|
|
var keep = new HashSet<uint>();
|
|
foreach (var entry in entries)
|
|
keep.Add(entry.Guid);
|
|
|
|
// 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;
|
|
o.CurrentlyEquippedLocation = EquipMask.None;
|
|
Reindex(o, containerId);
|
|
ObjectMoved?.Invoke(o, containerId, 0);
|
|
}
|
|
|
|
// Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition.
|
|
for (int i = 0; i < entries.Count; i++)
|
|
{
|
|
var entry = entries[i];
|
|
bool existed = _objects.TryGetValue(entry.Guid, out var obj);
|
|
if (!existed || obj is null)
|
|
{
|
|
obj = new ClientObject { ObjectId = entry.Guid };
|
|
_objects[entry.Guid] = obj;
|
|
}
|
|
uint oldContainer = obj.ContainerId;
|
|
obj.ContainerId = containerId;
|
|
obj.ContainerSlot = i;
|
|
obj.CurrentlyEquippedLocation = EquipMask.None;
|
|
obj.ContainerTypeHint = entry.ContainerType;
|
|
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
|
|
/// hierarchy is 2-deep) plus items wielded by the owner. The client-side
|
|
/// equivalent of the server's <c>EncumbranceVal</c> (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.
|
|
/// </summary>
|
|
public int SumCarriedBurden(uint ownerGuid)
|
|
{
|
|
int total = 0;
|
|
foreach (var o in _objects.Values)
|
|
if (IsCarriedBy(o, ownerGuid))
|
|
total += o.Burden;
|
|
return total;
|
|
}
|
|
|
|
// NOTE: WielderId is populated from wire data only (CreateObject → Ingest). An
|
|
// optimistically-wielded item (WieldItemOptimistic, before the server confirm) has
|
|
// WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder).
|
|
// A future "is this wielded by me?" check must therefore use ContainerId, NOT WielderId alone.
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Flush the table — typically called on logoff or teleport
|
|
/// that drops the session's object state.
|
|
/// </summary>
|
|
public void Clear()
|
|
{
|
|
_objects.Clear();
|
|
_containers.Clear();
|
|
_containerIndex.Clear();
|
|
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
|
|
}
|
|
}
|