Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage. Co-Authored-By: Codex <noreply@openai.com>
1151 lines
48 KiB
C#
1151 lines
48 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>One login-time PlayerDescription equipment placement.</summary>
|
|
public readonly record struct EquipmentManifestEntry(
|
|
uint Guid,
|
|
EquipMask EquipLocation,
|
|
uint Priority);
|
|
|
|
/// <summary>
|
|
/// Server rejection of an inventory move request. <paramref name="RolledBack"/>
|
|
/// is true when the table restored a locally optimistic move; false for
|
|
/// server-confirmed transactions which deliberately left the local projection
|
|
/// unchanged while waiting.
|
|
/// </summary>
|
|
public readonly record struct MoveRequestFailure(
|
|
uint ItemId,
|
|
uint WeenieError,
|
|
bool RolledBack);
|
|
|
|
/// <summary>
|
|
/// One side of retail's <c>ServerSaysMoveItem_s</c> placement transition.
|
|
/// Container, slot, wielder, and equip location are captured before listeners
|
|
/// run so they never have to infer the old state from the already-mutated item.
|
|
/// </summary>
|
|
public readonly record struct ClientObjectPlacement(
|
|
uint ContainerId,
|
|
int ContainerSlot,
|
|
uint WielderId,
|
|
EquipMask EquipLocation)
|
|
{
|
|
public static ClientObjectPlacement From(ClientObject item) => new(
|
|
item.ContainerId,
|
|
item.ContainerSlot,
|
|
item.WielderId,
|
|
item.CurrentlyEquippedLocation);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail-shaped inventory placement notice. Named retail
|
|
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and
|
|
/// publishes both complete placements after applying the new state.
|
|
/// </summary>
|
|
public readonly record struct ClientObjectMove(
|
|
uint ItemId,
|
|
ClientObject? Item,
|
|
ClientObjectPlacement Previous,
|
|
ClientObjectPlacement Current);
|
|
|
|
public enum ClientObjectRemovalReason
|
|
{
|
|
Ordinary,
|
|
LogicalDelete,
|
|
GenerationReplacement,
|
|
}
|
|
|
|
public readonly record struct ClientObjectRemoval(
|
|
ClientObject Object,
|
|
ClientObjectRemovalReason Reason,
|
|
ushort Generation);
|
|
|
|
/// <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();
|
|
private readonly Dictionary<uint, List<uint>> _equipmentIndex = 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, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new();
|
|
|
|
/// <summary>Fires when an object is first added to the session.</summary>
|
|
public event Action<ClientObject>? ObjectAdded;
|
|
|
|
/// <summary>
|
|
/// Fires after a complete placement notice (moved between packs,
|
|
/// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Item"/>
|
|
/// is null when retail publishes the notice before CreateObject resolves
|
|
/// the GUID; the old/new placement remains authoritative notice data.
|
|
/// </summary>
|
|
public event Action<ClientObjectMove>? ObjectMoved;
|
|
|
|
/// <summary>
|
|
/// Fires after retail ViewContents replaces a container's ordered member
|
|
/// lists. This is a list-projection change, not an item ownership move.
|
|
/// </summary>
|
|
public event Action<uint>? ContainerContentsReplaced;
|
|
|
|
/// <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 for an authoritative WieldObject response after its object state
|
|
/// has been applied. This is deliberately independent of optimistic-move
|
|
/// rollback bookkeeping: retail clears IR_WIELD on the matching server
|
|
/// response even when separate reconciliation state exists for the item.
|
|
/// </summary>
|
|
public event Action<uint>? WieldConfirmed;
|
|
|
|
/// <summary>
|
|
/// Fires for every InventoryServerSaveFailed response, including requests
|
|
/// which did not mutate the table optimistically. Transaction coordinators
|
|
/// use this to abandon server-confirmed multi-step moves without timers.
|
|
/// </summary>
|
|
public event Action<MoveRequestFailure>? MoveRequestFailed;
|
|
|
|
/// <summary>Fires when an object is removed from the session.</summary>
|
|
public event Action<ClientObject>? ObjectRemoved;
|
|
|
|
/// <summary>
|
|
/// Generation-aware removal stream for runtime owners. UI consumers keep
|
|
/// using <see cref="ObjectRemoved"/>; runtime owners use the reason and
|
|
/// generation to preserve only packets addressed to future incarnations.
|
|
/// </summary>
|
|
public event Action<ClientObjectRemoval>? ObjectRemovalClassified;
|
|
|
|
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
|
|
public event Action<ClientObject>? ObjectUpdated;
|
|
|
|
/// <summary>Fires after all session object and pending-move state is flushed.</summary>
|
|
public event Action? Cleared;
|
|
|
|
/// <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.TryGetValue(item.ObjectId, out ClientObject? prior);
|
|
ClientObjectPlacement previous = prior is null
|
|
? default
|
|
: ClientObjectPlacement.From(prior);
|
|
_objects[item.ObjectId] = item;
|
|
UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(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>
|
|
/// Move the local projection of an item without changing authoritative
|
|
/// WielderId. Optimistic inventory requests use this path because retail
|
|
/// UIAttemptWield sends the request without rewriting public weenie fields.
|
|
/// </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;
|
|
return ApplyPlacement(
|
|
item,
|
|
new ClientObjectPlacement(
|
|
newContainerId,
|
|
newSlot,
|
|
item.WielderId,
|
|
newEquipLocation),
|
|
containerTypeHint);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply an authoritative server move, including the item's wielder
|
|
/// ownership. Retail <c>ACCWeenieObject::ServerSaysMoveItem @
|
|
/// 0x0058DBB0</c> updates <c>_containerID</c>, <c>_wielderID</c>, and
|
|
/// <c>_location</c> in one callback. Keeping the old wielder after an
|
|
/// unwield makes <c>DetermineUseResult @ 0x00588460</c> classify a bow in
|
|
/// the backpack as already wielded, so a later double click sends no
|
|
/// request.
|
|
/// </summary>
|
|
public bool ApplyServerMove(
|
|
uint itemId,
|
|
uint newContainerId,
|
|
uint newWielderId,
|
|
int newSlot = -1,
|
|
EquipMask newEquipLocation = EquipMask.None,
|
|
uint? containerTypeHint = null)
|
|
{
|
|
if (!_objects.TryGetValue(itemId, out var item)) return false;
|
|
return ApplyPlacement(
|
|
item,
|
|
new ClientObjectPlacement(
|
|
newContainerId,
|
|
newSlot,
|
|
newWielderId,
|
|
newEquipLocation),
|
|
containerTypeHint);
|
|
}
|
|
|
|
private bool ApplyPlacement(
|
|
ClientObject item,
|
|
ClientObjectPlacement current,
|
|
uint? containerTypeHint = null)
|
|
{
|
|
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
|
|
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
|
item.ObjectId,
|
|
current.ContainerId);
|
|
item.ContainerId = current.ContainerId;
|
|
item.ContainerSlot = current.ContainerSlot;
|
|
item.WielderId = current.WielderId;
|
|
item.CurrentlyEquippedLocation = current.EquipLocation;
|
|
if (containerTypeHint is { } hint)
|
|
item.ContainerTypeHint = hint;
|
|
Reindex(item, previous.ContainerId);
|
|
PublishPlacementChange(item, previous);
|
|
PublishContainerContentsChanges(changedContainers);
|
|
return true;
|
|
}
|
|
|
|
private void PublishPlacementChange(
|
|
ClientObject item,
|
|
ClientObjectPlacement previous)
|
|
{
|
|
ClientObjectPlacement current = ClientObjectPlacement.From(item);
|
|
UpdateEquipmentIndex(item.ObjectId, previous, current);
|
|
ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies a server-confirmed container/world move. The matching pending
|
|
/// request is reconciled before <see cref="ObjectMoved"/> is published,
|
|
/// matching retail <c>ServerSaysMoveItem</c>: a reentrant listener may start
|
|
/// a new request without the old confirmation consuming it afterward.
|
|
/// </summary>
|
|
public bool ApplyConfirmedServerMove(
|
|
uint itemId,
|
|
uint newContainerId,
|
|
uint newWielderId,
|
|
int newSlot = -1,
|
|
EquipMask newEquipLocation = EquipMask.None,
|
|
uint? containerTypeHint = null)
|
|
{
|
|
ConfirmMove(itemId);
|
|
if (ApplyServerMove(
|
|
itemId,
|
|
newContainerId,
|
|
newWielderId,
|
|
newSlot,
|
|
newEquipLocation,
|
|
containerTypeHint))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
ObjectMoved?.Invoke(new ClientObjectMove(
|
|
itemId,
|
|
Item: null,
|
|
Previous: default,
|
|
Current: new ClientObjectPlacement(
|
|
newContainerId,
|
|
newSlot,
|
|
newWielderId,
|
|
newEquipLocation)));
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies retail WieldObject (0x0023). The packet has only item and
|
|
/// location; the local player is the wielder, while ContainerId is zero.
|
|
/// Pending reconciliation precedes notifications as in
|
|
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c>.
|
|
/// </summary>
|
|
public bool ApplyConfirmedServerWield(
|
|
uint itemId,
|
|
uint wielderId,
|
|
EquipMask equipLocation)
|
|
{
|
|
ConfirmMove(itemId);
|
|
var placement = new ClientObjectPlacement(
|
|
ContainerId: 0u,
|
|
ContainerSlot: 0,
|
|
WielderId: wielderId,
|
|
EquipLocation: equipLocation);
|
|
if (!_objects.TryGetValue(itemId, out ClientObject? item))
|
|
{
|
|
ObjectMoved?.Invoke(new ClientObjectMove(
|
|
itemId,
|
|
Item: null,
|
|
Previous: default,
|
|
Current: placement));
|
|
WieldConfirmed?.Invoke(itemId);
|
|
return false;
|
|
}
|
|
|
|
if (!ApplyPlacement(
|
|
item,
|
|
placement))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
WieldConfirmed?.Invoke(itemId);
|
|
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.placement, p.outstanding + 1);
|
|
else
|
|
_pendingMoves[itemId] = (ClientObjectPlacement.From(item), 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.
|
|
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
|
|
uint oldContainer = previous.ContainerId;
|
|
item.ContainerId = newContainerId;
|
|
item.CurrentlyEquippedLocation = EquipMask.None;
|
|
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
|
itemId,
|
|
newContainerId);
|
|
|
|
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;
|
|
|
|
PublishPlacementChange(item, previous);
|
|
PublishContainerContentsChanges(changedContainers);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
|
|
/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask. WielderId remains unchanged
|
|
/// until <see cref="ApplyConfirmedServerWield"/> receives the authoritative WieldObject, matching retail's
|
|
/// UIAttemptWield request/confirmation split. The confirmation converts this temporary projection to
|
|
/// ContainerId zero + authoritative WielderId. <see cref="RollbackMove"/> restores the local projection. 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.placement, 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);
|
|
ClientObjectPlacement placement = pre.placement;
|
|
if (!ApplyServerMove(
|
|
itemId,
|
|
placement.ContainerId,
|
|
placement.WielderId,
|
|
placement.ContainerSlot,
|
|
placement.EquipLocation))
|
|
return false;
|
|
MoveRolledBack?.Invoke(_objects[itemId]);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reconcile an InventoryServerSaveFailed response and publish the failure
|
|
/// even when no optimistic snapshot exists. Retail's blocked AutoWield path
|
|
/// waits for the server before changing its local item lists, so failure is
|
|
/// still a meaningful transaction event in that case.
|
|
/// </summary>
|
|
public bool RejectMove(uint itemId, uint weenieError)
|
|
{
|
|
bool rolledBack = RollbackMove(itemId);
|
|
MoveRequestFailed?.Invoke(new MoveRequestFailure(itemId, weenieError, rolledBack));
|
|
return rolledBack;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle a server-driven remove (destroyed item, dropped into 3D
|
|
/// space, stolen, etc).
|
|
/// </summary>
|
|
public bool Remove(uint itemId)
|
|
=> RemoveCore(itemId, ClientObjectRemovalReason.Ordinary, 0, notifyObjectRemoved: true);
|
|
|
|
/// <summary>Removes an exact accepted server object incarnation.</summary>
|
|
public bool RemoveLogicalGeneration(uint itemId, ushort generation)
|
|
=> RemoveCore(
|
|
itemId,
|
|
ClientObjectRemovalReason.LogicalDelete,
|
|
generation,
|
|
notifyObjectRemoved: true);
|
|
|
|
private bool RemoveCore(
|
|
uint itemId,
|
|
ClientObjectRemovalReason reason,
|
|
ushort generation,
|
|
bool notifyObjectRemoved)
|
|
{
|
|
if (!_objects.TryRemove(itemId, out var item)) return false;
|
|
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
|
itemId,
|
|
exceptContainerId: 0u);
|
|
UpdateEquipmentIndex(
|
|
itemId,
|
|
ClientObjectPlacement.From(item),
|
|
default);
|
|
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
|
|
if (notifyObjectRemoved)
|
|
ObjectRemoved?.Invoke(item);
|
|
ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation));
|
|
PublishContainerContentsChanges(changedContainers);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Atomically replaces the retained qualities for a newer server object
|
|
/// incarnation while publishing a generation-specific teardown event.
|
|
/// </summary>
|
|
public ClientObject ReplaceGeneration(WeenieData data, ushort generation)
|
|
{
|
|
RemoveCore(
|
|
data.Guid,
|
|
ClientObjectRemovalReason.GenerationReplacement,
|
|
generation,
|
|
notifyObjectRemoved: true);
|
|
|
|
return Ingest(data);
|
|
}
|
|
|
|
/// <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;
|
|
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
|
|
item.Properties.Ints[propertyId] = value;
|
|
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
|
|
if (propertyId == CurrentWieldedLocationPropertyId)
|
|
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
|
|
if (propertyId == CurrentWieldedLocationPropertyId)
|
|
UpdateEquipmentIndex(itemId, previous, ClientObjectPlacement.From(item));
|
|
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;
|
|
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
|
|
|
|
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.AmmoType is { } ammoType) obj.AmmoType = ammoType;
|
|
if (d.SpellId is { } spellId) obj.SpellId = spellId;
|
|
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;
|
|
|
|
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
|
obj.ObjectId,
|
|
obj.ContainerId);
|
|
Reindex(obj, oldContainer);
|
|
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
|
|
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
|
PublishContainerContentsChanges(changedContainers);
|
|
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. PlayerDescription may
|
|
/// use the player as <paramref name="containerId"/> for its login-time
|
|
/// placement projection; live WieldObject confirmation later establishes
|
|
/// canonical ContainerId zero plus WielderId player.
|
|
/// 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,
|
|
uint? priority = 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;
|
|
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
|
|
if (containerId != 0)
|
|
obj.ContainerId = containerId;
|
|
obj.CurrentlyEquippedLocation = equip;
|
|
if (equip != EquipMask.None)
|
|
obj.ContainerSlot = -1;
|
|
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
|
|
if (priority is { } p) obj.Priority = p;
|
|
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
|
obj.ObjectId,
|
|
obj.ContainerId);
|
|
Reindex(obj, oldContainer);
|
|
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
|
|
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
|
|
PublishContainerContentsChanges(changedContainers);
|
|
return obj;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialize PlayerDescription equipment in canonical retail form while
|
|
/// preserving the packed InventoryPlacement list order exactly. Retail
|
|
/// <c>UpdateObjectInventory @ 0x00559550</c> assigns the complete list; it
|
|
/// does not replay live head-insertion for each login entry.
|
|
/// </summary>
|
|
public void InitializeEquipmentManifest(
|
|
uint wielderId,
|
|
IReadOnlyList<EquipmentManifestEntry> entries)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entries);
|
|
if (wielderId == 0u) return;
|
|
|
|
var ordered = new List<uint>(entries.Count);
|
|
var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count);
|
|
var changedContainers = new List<uint>();
|
|
var incoming = new HashSet<uint>();
|
|
for (int i = 0; i < entries.Count; i++)
|
|
incoming.Add(entries[i].Guid);
|
|
|
|
// UpdateObjectInventory assigns a complete authoritative list. Clear
|
|
// canonical placement on members omitted by a replay/replacement
|
|
// manifest before installing the new order; replacing only the index
|
|
// leaves ghost equipment visible to AutoWield and burden queries.
|
|
if (_equipmentIndex.TryGetValue(wielderId, out List<uint>? priorEquipment))
|
|
{
|
|
foreach (uint priorId in priorEquipment.ToArray())
|
|
{
|
|
if (incoming.Contains(priorId)
|
|
|| !_objects.TryGetValue(priorId, out ClientObject? prior)
|
|
|| prior is null
|
|
|| (prior.WielderId != wielderId
|
|
&& !(prior.ContainerId == wielderId
|
|
&& prior.CurrentlyEquippedLocation != EquipMask.None)))
|
|
continue;
|
|
|
|
prior.ContainerId = 0u;
|
|
prior.ContainerSlot = -1;
|
|
prior.WielderId = 0u;
|
|
prior.CurrentlyEquippedLocation = EquipMask.None;
|
|
prior.Priority = 0u;
|
|
if (RemoveFromOtherContainerIndexes(priorId, exceptContainerId: 0u)
|
|
is { } removedFrom)
|
|
{
|
|
changedContainers.AddRange(removedFrom);
|
|
}
|
|
notifications.Add((prior, true));
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < entries.Count; i++)
|
|
{
|
|
EquipmentManifestEntry entry = entries[i];
|
|
ordered.Add(entry.Guid);
|
|
bool existed = _objects.TryGetValue(entry.Guid, out ClientObject? obj);
|
|
if (!existed || obj is null)
|
|
{
|
|
obj = new ClientObject { ObjectId = entry.Guid };
|
|
_objects[entry.Guid] = obj;
|
|
}
|
|
|
|
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
|
|
obj.ContainerId = 0u;
|
|
obj.ContainerSlot = -1;
|
|
obj.WielderId = wielderId;
|
|
obj.CurrentlyEquippedLocation = entry.EquipLocation;
|
|
obj.Priority = entry.Priority;
|
|
if (RemoveFromOtherContainerIndexes(entry.Guid, exceptContainerId: 0u)
|
|
is { } changed)
|
|
{
|
|
changedContainers.AddRange(changed);
|
|
}
|
|
Reindex(obj, previous.ContainerId);
|
|
RemoveEquipmentIndexMembership(entry.Guid);
|
|
notifications.Add((obj, existed));
|
|
}
|
|
|
|
_equipmentIndex[wielderId] = ordered;
|
|
foreach ((ClientObject item, bool existed) in notifications)
|
|
{
|
|
if (!existed) ObjectAdded?.Invoke(item);
|
|
else ObjectUpdated?.Invoke(item);
|
|
}
|
|
PublishContainerContentsChanges(changedContainers);
|
|
}
|
|
|
|
private List<uint>? RemoveFromOtherContainerIndexes(
|
|
uint itemId,
|
|
uint exceptContainerId)
|
|
{
|
|
List<uint>? changed = null;
|
|
foreach ((uint containerId, List<uint> members) in _containerIndex)
|
|
{
|
|
if (containerId == exceptContainerId || !members.Remove(itemId))
|
|
continue;
|
|
(changed ??= new List<uint>()).Add(containerId);
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
private void PublishContainerContentsChanges(List<uint>? changed)
|
|
{
|
|
if (changed is null || changed.Count == 0)
|
|
return;
|
|
var published = new HashSet<uint>();
|
|
foreach (uint containerId in changed)
|
|
{
|
|
if (!published.Add(containerId))
|
|
continue;
|
|
ContainerContentsReplaced?.Invoke(containerId);
|
|
}
|
|
}
|
|
|
|
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 static uint EquipmentOwner(ClientObjectPlacement placement)
|
|
{
|
|
if (placement.EquipLocation == EquipMask.None)
|
|
return 0u;
|
|
return placement.WielderId != 0u
|
|
? placement.WielderId
|
|
: placement.ContainerId;
|
|
}
|
|
|
|
private void RemoveEquipmentIndexMembership(uint itemId)
|
|
{
|
|
List<uint>? emptyOwners = null;
|
|
foreach ((uint ownerId, List<uint> placements) in _equipmentIndex)
|
|
{
|
|
placements.Remove(itemId);
|
|
if (placements.Count == 0)
|
|
(emptyOwners ??= new List<uint>()).Add(ownerId);
|
|
}
|
|
|
|
if (emptyOwners is null)
|
|
return;
|
|
foreach (uint ownerId in emptyOwners)
|
|
_equipmentIndex.Remove(ownerId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maintain retail's <c>_invPlacement</c> order. Named retail
|
|
/// <c>SetPlayerWieldLocation @ 0x0058D8C0</c> inserts a placement at the
|
|
/// head; <c>GetObjectAtLocation @ 0x0058CE00</c> scans head-to-tail.
|
|
/// </summary>
|
|
private void UpdateEquipmentIndex(
|
|
uint itemId,
|
|
ClientObjectPlacement previous,
|
|
ClientObjectPlacement current)
|
|
{
|
|
if (previous == current)
|
|
return;
|
|
|
|
uint previousOwner = EquipmentOwner(previous);
|
|
if (previousOwner != 0u
|
|
&& _equipmentIndex.TryGetValue(previousOwner, out List<uint>? oldList))
|
|
{
|
|
oldList.Remove(itemId);
|
|
if (oldList.Count == 0)
|
|
_equipmentIndex.Remove(previousOwner);
|
|
}
|
|
|
|
uint currentOwner = EquipmentOwner(current);
|
|
if (currentOwner == 0u)
|
|
return;
|
|
if (!_equipmentIndex.TryGetValue(currentOwner, out List<uint>? currentList))
|
|
_equipmentIndex[currentOwner] = currentList = new List<uint>();
|
|
currentList.Remove(itemId);
|
|
currentList.Insert(0, itemId);
|
|
}
|
|
|
|
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>
|
|
/// Snapshot of items equipped by <paramref name="wielderId"/>. Confirmed
|
|
/// retail equipment has ContainerId zero and WielderId set; the
|
|
/// ContainerId arm keeps the optimistic pre-confirm projection visible.
|
|
/// </summary>
|
|
public IReadOnlyList<ClientObject> GetEquippedBy(uint wielderId) =>
|
|
_equipmentIndex.TryGetValue(wielderId, out List<uint>? placements)
|
|
? placements
|
|
.Select(Get)
|
|
.Where(item => item is not null
|
|
&& item.CurrentlyEquippedLocation != EquipMask.None
|
|
&& (item.WielderId == wielderId || item.ContainerId == wielderId))
|
|
.Cast<ClientObject>()
|
|
.ToArray()
|
|
: Array.Empty<ClientObject>();
|
|
|
|
/// <summary>
|
|
/// Replace only a viewed container's ordered membership projection.
|
|
/// Retail <c>ACCObjectMaint::ViewObjectContents @ 0x00558A70</c> rebuilds
|
|
/// the container lists without mutating child ownership or equip state.
|
|
/// </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 only a viewed container's ordered membership projection.
|
|
/// ContentProfile order is retained in <see cref="GetContents"/> while the
|
|
/// child weenie's canonical placement remains owned by move/wield events.
|
|
/// </summary>
|
|
public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entries);
|
|
if (containerId == 0) return;
|
|
|
|
var ordered = new List<uint>(entries.Count);
|
|
var added = new List<ClientObject>();
|
|
for (int i = 0; i < entries.Count; i++)
|
|
{
|
|
var entry = entries[i];
|
|
ordered.Add(entry.Guid);
|
|
bool existed = _objects.TryGetValue(entry.Guid, out var obj);
|
|
if (!existed || obj is null)
|
|
{
|
|
obj = new ClientObject { ObjectId = entry.Guid };
|
|
_objects[entry.Guid] = obj;
|
|
}
|
|
obj.ContainerTypeHint = entry.ContainerType;
|
|
if (!existed) added.Add(obj);
|
|
}
|
|
|
|
_containerIndex[containerId] = ordered;
|
|
foreach (ClientObject item in added)
|
|
ObjectAdded?.Invoke(item);
|
|
ContainerContentsReplaced?.Invoke(containerId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialize canonical inventory ownership from PlayerDescription. This
|
|
/// login manifest is object-state initialization, not a move transition.
|
|
/// </summary>
|
|
public void InitializeInventoryManifest(
|
|
uint ownerId,
|
|
IReadOnlyList<ContainerContentEntry> entries)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(entries);
|
|
if (ownerId == 0u) return;
|
|
|
|
var ordered = new List<uint>(entries.Count);
|
|
var changedContainers = new List<uint>();
|
|
var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count);
|
|
var incoming = new HashSet<uint>();
|
|
for (int i = 0; i < entries.Count; i++)
|
|
incoming.Add(entries[i].Guid);
|
|
|
|
if (_containerIndex.TryGetValue(ownerId, out List<uint>? priorInventory))
|
|
{
|
|
foreach (uint priorId in priorInventory.ToArray())
|
|
{
|
|
if (incoming.Contains(priorId)
|
|
|| !_objects.TryGetValue(priorId, out ClientObject? prior)
|
|
|| prior is null
|
|
|| prior.ContainerId != ownerId)
|
|
continue;
|
|
|
|
ClientObjectPlacement previous = ClientObjectPlacement.From(prior);
|
|
prior.ContainerId = 0u;
|
|
prior.ContainerSlot = -1;
|
|
prior.WielderId = 0u;
|
|
prior.CurrentlyEquippedLocation = EquipMask.None;
|
|
prior.Priority = 0u;
|
|
UpdateEquipmentIndex(
|
|
prior.ObjectId,
|
|
previous,
|
|
ClientObjectPlacement.From(prior));
|
|
notifications.Add((prior, true));
|
|
}
|
|
}
|
|
|
|
for (int i = 0; i < entries.Count; i++)
|
|
{
|
|
ContainerContentEntry entry = entries[i];
|
|
ordered.Add(entry.Guid);
|
|
bool existed = _objects.TryGetValue(entry.Guid, out ClientObject? obj);
|
|
if (!existed || obj is null)
|
|
{
|
|
obj = new ClientObject { ObjectId = entry.Guid };
|
|
_objects[entry.Guid] = obj;
|
|
}
|
|
|
|
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
|
|
obj.ContainerId = ownerId;
|
|
obj.ContainerSlot = i;
|
|
obj.WielderId = 0u;
|
|
obj.CurrentlyEquippedLocation = EquipMask.None;
|
|
obj.ContainerTypeHint = entry.ContainerType;
|
|
if (RemoveFromOtherContainerIndexes(entry.Guid, ownerId) is { } changed)
|
|
changedContainers.AddRange(changed);
|
|
UpdateEquipmentIndex(entry.Guid, previous, ClientObjectPlacement.From(obj));
|
|
notifications.Add((obj, existed));
|
|
}
|
|
|
|
_containerIndex[ownerId] = ordered;
|
|
foreach ((ClientObject item, bool existed) in notifications)
|
|
{
|
|
if (!existed) ObjectAdded?.Invoke(item);
|
|
else ObjectUpdated?.Invoke(item);
|
|
}
|
|
ContainerContentsReplaced?.Invoke(ownerId);
|
|
PublishContainerContentsChanges(changedContainers);
|
|
}
|
|
|
|
/// <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 authoritative wire data only (CreateObject →
|
|
// Ingest or ApplyServerMove). An
|
|
// optimistically-wielded item (WieldItemOptimistic, before the server confirm) has
|
|
// WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder).
|
|
// Equipment queries must therefore accept authoritative WielderId OR the optimistic ContainerId.
|
|
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();
|
|
_equipmentIndex.Clear();
|
|
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
|
|
Cleared?.Invoke();
|
|
}
|
|
}
|