fix(interaction): close selection lifecycle review gaps

Bind queued actions and pending inventory requests to exact live incarnations, separate optimistic placement from authoritative responses, and serialize retail-style inventory ownership across UI surfaces.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-21 09:01:02 +02:00
parent d2bb5af453
commit 5acc3f01cf
23 changed files with 2635 additions and 168 deletions

View file

@ -12,6 +12,31 @@ public enum ItemPrimaryClickResult
ConsumedRejected,
}
public readonly record struct PendingBackpackPlacement(
ulong Token,
uint ItemId,
uint ContainerId,
int Placement,
ClientObject? ItemIdentity);
public enum InventoryRequestKind
{
Pickup,
PutInContainer,
SplitToContainer,
Merge,
DropToWorld,
SplitToWorld,
Give,
}
public readonly record struct PendingInventoryRequest(
ulong Token,
InventoryRequestKind Kind,
uint ItemId,
ClientObject? ItemIdentity,
bool Dispatched);
/// <summary>
/// Shared retail item interaction orchestrator. UI widgets route item clicks,
/// target acquisition, and drag-out drops here instead of duplicating
@ -20,6 +45,8 @@ public enum ItemPrimaryClickResult
public sealed class ItemInteractionController : IDisposable
{
private const long RetailUseThrottleMs = 200;
internal const string InventoryRequestBusyMessage =
"You can only move or use one item at a time";
private const long RetailDoubleClickMs = 500;
private readonly ClientObjectTable _objects;
@ -56,6 +83,9 @@ public sealed class ItemInteractionController : IDisposable
private uint _consumedPrimaryClickTarget;
private long _consumedPrimaryClickMs = long.MinValue / 2;
private int _busyCount;
private ulong _nextInventoryRequestToken;
private PendingBackpackPlacement? _pendingBackpackPlacement;
private PendingInventoryRequest? _pendingInventoryRequest;
private bool _disposed;
public ItemInteractionController(
@ -119,6 +149,11 @@ public sealed class ItemInteractionController : IDisposable
_systemMessage = systemMessage;
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
_objects.ObjectMoved += OnInventoryObjectMoved;
_objects.MoveRequestFailed += OnInventoryMoveFailed;
_objects.ObjectRemoved += OnInventoryObjectRemoved;
_objects.StackSizeUpdated += OnInventoryStackSizeUpdated;
_objects.Cleared += OnInventoryObjectsCleared;
_autoWield = new AutoWieldController(
_objects,
_playerGuid,
@ -137,7 +172,20 @@ public sealed class ItemInteractionController : IDisposable
/// panel inserts a waiting projection before the pickup request is sent.
/// The destination and placement are the same values sent on the wire.
/// </summary>
public event Action<uint, uint, int>? PendingBackpackPlacementRequested;
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementRequested;
/// <summary>
/// Withdraws a waiting pickup projection when the approach is rejected or
/// cancelled before a request reaches the server.
/// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementCancelled;
/// <summary>
/// Resolves the exact waiting projection after an authoritative response.
/// The token prevents a late response for a recycled GUID from erasing a
/// newer incarnation's projection in the inventory panel.
/// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved;
public uint PlayerGuid => _playerGuid();
@ -146,8 +194,151 @@ public sealed class ItemInteractionController : IDisposable
public int BusyCount => _busyCount;
public bool CanMakeInventoryRequest =>
BaseCanMakeInventoryRequest && _pendingInventoryRequest is null;
private bool BaseCanMakeInventoryRequest =>
_readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy;
/// <summary>
/// Retail <c>ACCWeenieObject::IsPlayerReadyToMakeInventoryRequest</c>.
/// The destination ItemList's separate <c>m_pendingItem</c> rejection is
/// reported by <see cref="ReportPendingBackpackPlacementConflict"/>.
/// </summary>
public bool EnsureInventoryRequestReady()
{
if (CanMakeInventoryRequest)
return true;
if (_pendingInventoryRequest is not null
|| _busyCount != 0
|| _autoWield.IsBusy)
_systemMessage?.Invoke(InventoryRequestBusyMessage);
return false;
}
/// <summary>
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination
/// list which already displays the waiting projection.
/// </summary>
public void ReportPendingBackpackPlacementConflict()
{
if (_pendingBackpackPlacement is not { } pending)
return;
string? itemName = _objects.Get(pending.ItemId)?.Name;
string name = string.IsNullOrWhiteSpace(itemName) ? "that item" : itemName;
_systemMessage?.Invoke($"Already attempting to place {name} here");
}
/// <summary>
/// Dispatches one retail inventory request. A provisional global
/// <c>ACCWeenieObject::prevRequest</c> owner closes callback reentrancy,
/// then becomes dispatched immediately after a successful wire send.
/// The callback returns false when it could not dispatch anything.
/// Retail: <c>RecordRequest @ 0x0058C220</c>, UIAttempt family
/// <c>0x0058D590..0x0058D850</c>.
/// </summary>
public bool TryDispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func<bool> dispatch,
ulong reservationToken = 0u)
{
ArgumentNullException.ThrowIfNull(dispatch);
if (itemId == 0u)
return false;
if (reservationToken != 0u)
{
if (_pendingInventoryRequest is not { } reserved
|| reserved.Token != reservationToken
|| reserved.ItemId != itemId
|| reserved.Kind != kind
|| reserved.Dispatched)
{
return false;
}
bool reservedDispatch;
try
{
reservedDispatch = dispatch();
}
catch
{
CancelPendingBackpackPlacement(itemId, reservationToken);
throw;
}
if (!reservedDispatch)
{
CancelPendingBackpackPlacement(itemId, reservationToken);
return false;
}
// A synchronous test transport or reentrant table listener may
// already have completed this exact request. Never resurrect it or
// overwrite a newer transaction acquired from that response.
if (_pendingInventoryRequest is { } reservationCurrent
&& reservationCurrent.Token == reserved.Token)
{
_pendingInventoryRequest = reserved with { Dispatched = true };
StateChanged?.Invoke();
}
return true;
}
if (!EnsureInventoryRequestReady())
return false;
ulong token = ++_nextInventoryRequestToken;
if (token == 0u)
token = ++_nextInventoryRequestToken;
var provisional = new PendingInventoryRequest(
token,
kind,
itemId,
_objects.Get(itemId),
Dispatched: false);
// Provisional ownership closes the reentrancy window around optimistic
// table mutation and arbitrary send adapters. Retail writes prevRequest
// after its synchronous event call; our callback seam can publish local
// notices reentrantly, so the owner is reserved before entering it.
_pendingInventoryRequest = provisional;
StateChanged?.Invoke();
bool dispatched;
try
{
dispatched = dispatch();
}
catch
{
ClearPendingInventoryRequest(token);
throw;
}
if (!dispatched)
{
ClearPendingInventoryRequest(token);
return false;
}
if (_pendingInventoryRequest is { } current
&& current.Token == token)
{
_pendingInventoryRequest = provisional with { Dispatched = true };
StateChanged?.Invoke();
}
return true;
}
public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending)
{
if (_pendingInventoryRequest is { } current)
{
pending = current;
return true;
}
pending = default;
return false;
}
/// <summary>
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
/// request issued by another retained controller has been sent. The
@ -287,6 +478,8 @@ public sealed class ItemInteractionController : IDisposable
if (!ConsumeUseThrottle())
return true;
if (!EnsureInventoryRequestReady())
return false;
var input = new ItemUsePolicyInput(
Snapshot(item),
@ -317,11 +510,195 @@ public sealed class ItemInteractionController : IDisposable
if (containerId == 0u)
containerId = _playerGuid();
const int placement = 0;
PendingBackpackPlacementRequested?.Invoke(itemGuid, containerId, placement);
if (!TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
out _))
{
// Retail permits only one inventory request at a time. Consume the
// input without replacing a pending projection or issuing a wire
// request while another inventory transaction owns the gate.
return true;
}
// This callback may install a retail MoveToObject approach instead of
// sending immediately. SelectionInteractionController promotes the
// reservation to Dispatched only when it actually emits the wire move.
_placeInBackpack(itemGuid, containerId, placement);
return true;
}
public bool TryBeginPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
out PendingBackpackPlacement pending)
=> TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
InventoryRequestKind.Pickup,
out pending);
private bool TryBeginPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
InventoryRequestKind kind,
out PendingBackpackPlacement pending)
{
if (itemGuid == 0u || containerId == 0u)
{
pending = default;
return false;
}
if (_pendingBackpackPlacement is { } existing)
{
ReportPendingBackpackPlacementConflict();
pending = existing;
return false;
}
if (!EnsureInventoryRequestReady())
{
pending = default;
return false;
}
ulong token = ++_nextInventoryRequestToken;
if (token == 0u)
token = ++_nextInventoryRequestToken;
ClientObject? identity = _objects.Get(itemGuid);
pending = new PendingBackpackPlacement(
token,
itemGuid,
containerId,
placement,
identity);
_pendingBackpackPlacement = pending;
_pendingInventoryRequest = new PendingInventoryRequest(
token,
kind,
itemGuid,
identity,
Dispatched: false);
PendingBackpackPlacementRequested?.Invoke(pending);
if (_pendingBackpackPlacement != pending
|| _pendingInventoryRequest is not { } published
|| published.Token != token
|| published.ItemId != itemGuid
|| published.Kind != kind
|| published.Dispatched)
{
return false;
}
StateChanged?.Invoke();
return true;
}
/// <summary>
/// Publishes retail's destination waiting projection, dispatches the move,
/// then records the global request. Failed dispatch withdraws only the
/// projection created by this call.
/// </summary>
public bool TryDispatchPendingBackpackPlacement(
uint itemGuid,
uint containerId,
int placement,
InventoryRequestKind kind,
Func<bool> dispatch)
{
// Callers such as gmToolbarUI check the global prevRequest before they
// ask CPlayerSystem to publish ShowPendingInPlayer. This preserves the
// generic busy wording when both states happen to be present.
if (!EnsureInventoryRequestReady())
return false;
if (!TryBeginPendingBackpackPlacement(
itemGuid,
containerId,
placement,
kind,
out PendingBackpackPlacement pending))
{
return false;
}
if (_pendingBackpackPlacement != pending
|| _pendingInventoryRequest is not { } reserved
|| reserved.Token != pending.Token
|| reserved.ItemId != pending.ItemId
|| reserved.Kind != kind
|| reserved.Dispatched)
{
return false;
}
if (TryDispatchInventoryRequest(
kind,
itemGuid,
dispatch,
pending.Token))
return true;
CancelPendingBackpackPlacement(itemGuid, pending.Token);
return false;
}
public bool TryGetPendingBackpackPlacement(
uint itemGuid,
out PendingBackpackPlacement pending)
{
if (_pendingBackpackPlacement is { } current
&& current.ItemId == itemGuid)
{
pending = current;
return true;
}
pending = default;
return false;
}
public void CancelPendingBackpackPlacement(uint itemGuid = 0u, ulong token = 0u)
{
if (_pendingBackpackPlacement is not { } pending
|| (itemGuid != 0u && pending.ItemId != itemGuid)
|| (token != 0u && pending.Token != token))
{
return;
}
_pendingBackpackPlacement = null;
if (_pendingInventoryRequest is { } request
&& request.Token == pending.Token
&& !request.Dispatched)
{
_pendingInventoryRequest = null;
StateChanged?.Invoke();
}
PendingBackpackPlacementCancelled?.Invoke(pending);
}
private void ClearPendingInventoryRequest(ulong token)
{
if (_pendingInventoryRequest is not { } pending
|| pending.Token != token)
{
return;
}
_pendingInventoryRequest = null;
StateChanged?.Invoke();
}
internal bool TryCaptureObjectIdentity(uint objectId, out ClientObject item)
{
item = _objects.Get(objectId)!;
return item is not null;
}
internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item)
=> ReferenceEquals(_objects.Get(objectId), item);
/// <summary>
/// Retail paperdoll drop entry point. The paperdoll resolves the exact
/// target side/location; this shared interaction owner performs AutoWield's
@ -333,6 +710,8 @@ public sealed class ItemInteractionController : IDisposable
return false;
if (_objects.Get(itemGuid) is not { } item)
return false;
if (!EnsureInventoryRequestReady())
return false;
return _autoWield.TryWield(item, targetMask);
}
@ -360,7 +739,7 @@ public sealed class ItemInteractionController : IDisposable
if (!compatible)
return false;
if (!CanMakeInventoryRequest)
if (!EnsureInventoryRequestReady())
return false;
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
_busyCount++;
@ -380,6 +759,8 @@ public sealed class ItemInteractionController : IDisposable
{
if (objectId == 0u || _sendUse is null)
return false;
if (!EnsureInventoryRequestReady())
return false;
_sendUse(objectId);
_busyCount++;
return true;
@ -407,6 +788,8 @@ public sealed class ItemInteractionController : IDisposable
return false;
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
return false;
if (!EnsureInventoryRequestReady())
return false;
uint fullStack = (uint)Math.Max(1, item.StackSize);
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
@ -496,22 +879,54 @@ public sealed class ItemInteractionController : IDisposable
switch (action.Kind)
{
case ItemPolicyActionKind.DropToWorld:
_objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1);
_sendDrop?.Invoke(action.ObjectId);
TryDispatchInventoryRequest(
InventoryRequestKind.DropToWorld,
action.ObjectId,
() =>
{
if (_sendDrop is null
|| !_objects.MoveItemOptimistic(
action.ObjectId,
newContainerId: 0u,
newSlot: -1))
{
return false;
}
_sendDrop(action.ObjectId);
return true;
});
break;
case ItemPolicyActionKind.SplitToWorld:
// UIAttemptSplitTo3D leaves the original object in its container;
// the server assigns the split-off ground stack a new guid.
_sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount);
TryDispatchInventoryRequest(
InventoryRequestKind.SplitToWorld,
action.ObjectId,
() =>
{
if (_sendSplitToWorld is null)
return false;
_sendSplitToWorld(action.ObjectId, (uint)action.Amount);
return true;
});
break;
case ItemPolicyActionKind.GiveToTarget:
// UIAttemptGive @ 0x0058D620 sends the request and leaves
// the source untouched until the authoritative server
// InventoryRemoveObject / SetStackSize response arrives.
_sendGive?.Invoke(
action.TargetId,
TryDispatchInventoryRequest(
InventoryRequestKind.Give,
action.ObjectId,
(uint)Math.Max(1, action.Amount));
() =>
{
if (_sendGive is null)
return false;
_sendGive(
action.TargetId,
action.ObjectId,
(uint)Math.Max(1, action.Amount));
return true;
});
break;
case ItemPolicyActionKind.PlaceInContainer:
{
@ -519,10 +934,35 @@ public sealed class ItemInteractionController : IDisposable
1,
_objects.Get(action.ObjectId)?.StackSize ?? action.Amount);
uint amount = (uint)Math.Max(1, action.Amount);
if (amount < fullStack)
_sendSplitToContainer?.Invoke(action.ObjectId, action.TargetId, 0u, amount);
else
_sendPutItemInContainer?.Invoke(action.ObjectId, action.TargetId, 0);
InventoryRequestKind kind = amount < fullStack
? InventoryRequestKind.SplitToContainer
: InventoryRequestKind.PutInContainer;
TryDispatchInventoryRequest(
kind,
action.ObjectId,
() =>
{
if (amount < fullStack)
{
if (_sendSplitToContainer is null)
return false;
_sendSplitToContainer(
action.ObjectId,
action.TargetId,
0u,
amount);
}
else
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(
action.ObjectId,
action.TargetId,
0);
}
return true;
});
break;
}
case ItemPolicyActionKind.Reject:
@ -571,11 +1011,93 @@ public sealed class ItemInteractionController : IDisposable
private void OnInteractionModeChanged(InteractionModeTransition _)
=> StateChanged?.Invoke();
private void OnInventoryObjectMoved(ClientObjectMove move)
{
if (move.Origin != ClientObjectMoveOrigin.AuthoritativeResponse)
return;
CompleteInventoryResponse(move.ItemId, move.Item);
}
private void OnInventoryMoveFailed(MoveRequestFailure failure)
{
ClientObject? identity = _objects.Get(failure.ItemId);
CompleteInventoryResponse(failure.ItemId, identity);
}
private void OnInventoryObjectRemoved(ClientObject item)
{
CompleteInventoryResponse(item.ObjectId, item);
}
private void OnInventoryStackSizeUpdated(ClientObject item)
{
CompleteInventoryResponse(item.ObjectId, item);
}
private void CompleteInventoryResponse(uint itemId, ClientObject? identity)
{
PendingInventoryRequest? completedRequest = null;
PendingBackpackPlacement? completedPlacement = null;
if (_pendingInventoryRequest is { } request
&& request.ItemId == itemId
&& MatchesIdentity(request.ItemIdentity, itemId, identity))
{
completedRequest = request;
_pendingInventoryRequest = null;
}
if (_pendingBackpackPlacement is { } placement
&& placement.ItemId == itemId
&& MatchesIdentity(placement.ItemIdentity, itemId, identity))
{
completedPlacement = placement;
_pendingBackpackPlacement = null;
}
// ACCWeenieObject::RecordResponse @ 0x0058CAB0 clears prevRequest
// before ServerSaysMoveItem publishes the item-list notice. Clear the
// coupled global owner and local waiting projection atomically before
// either callback so observers never see a half-completed transaction.
if (completedPlacement is { } resolved)
PendingBackpackPlacementResolved?.Invoke(resolved);
if (completedRequest is not null)
StateChanged?.Invoke();
}
private bool MatchesIdentity(
ClientObject? expected,
uint itemId,
ClientObject? actual)
{
if (expected is null)
return true;
if (actual is not null)
return ReferenceEquals(expected, actual);
return _objects.Get(itemId) is not { } current
|| ReferenceEquals(expected, current);
}
private void OnInventoryObjectsCleared()
{
bool changed = _pendingInventoryRequest is not null;
_pendingInventoryRequest = null;
_pendingBackpackPlacement = null;
if (changed)
StateChanged?.Invoke();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_interactionState.Changed -= OnInteractionModeChanged;
_objects.ObjectMoved -= OnInventoryObjectMoved;
_objects.MoveRequestFailed -= OnInventoryMoveFailed;
_objects.ObjectRemoved -= OnInventoryObjectRemoved;
_objects.StackSizeUpdated -= OnInventoryStackSizeUpdated;
_objects.Cleared -= OnInventoryObjectsCleared;
_autoWield.Dispose();
}
@ -602,9 +1124,11 @@ public sealed class ItemInteractionController : IDisposable
/// </summary>
public void ResetSession()
{
bool busyChanged = _busyCount != 0;
CancelPendingBackpackPlacement();
bool busyChanged = _busyCount != 0 || _pendingInventoryRequest is not null;
bool modeChanged = IsAnyTargetModeActive;
_busyCount = 0;
_pendingInventoryRequest = null;
_lastUseMs = long.MinValue / 2;
_consumedPrimaryClickTarget = 0u;
_consumedPrimaryClickMs = long.MinValue / 2;

View file

@ -223,6 +223,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
{
if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept)
return;
if (!_itemInteraction.EnsureInventoryRequestReady())
return;
if (_objects.Get(payload.ObjId) is not { } item)
return;
@ -238,10 +240,20 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 and
// UIAttemptSplitToContainer @ 0x0058D7D0 are request-only. Do not
// optimistically rewrite external ownership.
if (amount < fullStack)
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
else
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
InventoryRequestKind kind = amount < fullStack
? InventoryRequestKind.SplitToContainer
: InventoryRequestKind.PutInContainer;
_itemInteraction.TryDispatchInventoryRequest(
kind,
item.ObjectId,
() =>
{
if (amount < fullStack)
_sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount);
else
_sendPutItemInContainer(item.ObjectId, _openContainer, placement);
return true;
});
}
private void OnExternalContainerChanged(ExternalContainerTransition transition)

View file

@ -67,6 +67,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private bool _disposed;
private readonly record struct PendingListPlacement(
ulong Token,
uint ItemId,
uint ContainerId,
int Placement);
@ -183,6 +184,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
_itemInteraction.StateChanged += OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested += OnPendingBackpackPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled += OnPendingBackpackPlacementCancelled;
_itemInteraction.PendingBackpackPlacementResolved += OnPendingBackpackPlacementResolved;
}
Populate();
@ -242,7 +245,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
private void OnObjectRemoved(ClientObject o)
{
if (_pendingListPlacement?.ItemId == o.ObjectId)
bool fallbackResolved = _itemInteraction is null
&& _pendingListPlacement?.ItemId == o.ObjectId;
if (fallbackResolved)
_pendingListPlacement = null;
if (_selection.SelectedObjectId == o.ObjectId)
{
@ -250,15 +255,16 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved);
}
if (Concerns(o)) Populate();
if (fallbackResolved || Concerns(o)) Populate();
}
private void OnObjectMoved(ClientObjectMove move)
{
bool resolvedPending = _pendingListPlacement?.ItemId == move.ItemId;
if (resolvedPending)
bool fallbackResolved = _itemInteraction is null
&& _pendingListPlacement?.ItemId == move.ItemId;
if (fallbackResolved)
_pendingListPlacement = null;
uint player = _playerGuid();
if (resolvedPending
if (fallbackResolved
|| (move.Item is { } item && Concerns(item))
|| move.Previous.ContainerId == player
|| move.Current.ContainerId == player
@ -272,30 +278,45 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Populate();
}
private void OnInteractionStateChanged() => ApplyIndicators();
private void OnPendingBackpackPlacementRequested(
uint itemId,
uint containerId,
int placement)
private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending)
{
if (_pendingListPlacement is not null
|| itemId == 0u
|| containerId != EffectiveOpen()
|| _objects.Get(itemId) is not { } item
|| pending.ItemId == 0u
|| pending.ContainerId != EffectiveOpen()
|| _objects.Get(pending.ItemId) is not { } item
|| IsBag(item))
{
return;
}
_pendingListPlacement = new PendingListPlacement(
itemId,
containerId,
placement);
pending.Token,
pending.ItemId,
pending.ContainerId,
pending.Placement);
Populate();
}
private void OnPendingBackpackPlacementCancelled(PendingBackpackPlacement pending)
{
if (_pendingListPlacement is not { } projection
|| projection.Token != pending.Token)
return;
_pendingListPlacement = null;
Populate();
}
private void OnPendingBackpackPlacementResolved(PendingBackpackPlacement pending)
{
if (_pendingListPlacement is not { } projection
|| projection.Token != pending.Token)
return;
_pendingListPlacement = null;
Populate();
}
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnMoveRequestFailed(MoveRequestFailure failure)
{
if (_pendingListPlacement?.ItemId != failure.ItemId)
if (_itemInteraction is not null
|| _pendingListPlacement?.ItemId != failure.ItemId)
return;
_pendingListPlacement = null;
Populate();
@ -531,9 +552,6 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
return ItemDragAcceptance.None;
if (payload.ObjId == 0)
return ItemDragAcceptance.Reject;
if (payload.SourceKind == ItemDragSource.Ground
&& _pendingListPlacement is not null)
return ItemDragAcceptance.Reject;
if (targetList == _contentsGrid)
return ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer)
@ -561,6 +579,23 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint item = payload.ObjId;
if (item == 0) return;
// UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every
// release while m_pendingItem exists, before merge, split, or ordinary
// placement. ItemList_DragOver has no equivalent gate, so hover may
// still show the authored accept cursor immediately before this no-op.
if (targetList == _contentsGrid
&& _pendingListPlacement is { } localPending
&& localPending.ContainerId == EffectiveOpen())
{
_itemInteraction?.ReportPendingBackpackPlacementConflict();
return;
}
if (_itemInteraction is not null
&& !_itemInteraction.EnsureInventoryRequestReady())
{
return;
}
// UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first
// when a physical item is released on an occupied inventory cell. A legal
// merge consumes the drop; an illegal merge falls through to insert/move.
@ -597,8 +632,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will
// publish the reduced source plus a newly-guided destination stack.
_sendStackableSplitToContainer?.Invoke(
item, container, (uint)placement, splitSize);
DispatchInventoryRequest(
InventoryRequestKind.SplitToContainer,
item,
() =>
{
if (_sendStackableSplitToContainer is null)
return false;
_sendStackableSplitToContainer(
item,
container,
(uint)placement,
splitSize);
return true;
});
return;
}
}
@ -610,13 +657,52 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
if (payload.SourceKind == ItemDragSource.Ground)
{
if (_pendingListPlacement is not null)
if (_itemInteraction is not null)
{
if (!_itemInteraction.TryDispatchPendingBackpackPlacement(
item,
container,
placement,
InventoryRequestKind.Pickup,
() =>
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(item, container, placement);
return true;
}))
{
return;
}
return;
_pendingListPlacement = new PendingListPlacement(item, container, placement);
Populate();
}
else
{
if (_pendingListPlacement is not null)
return;
_pendingListPlacement = new PendingListPlacement(0u, item, container, placement);
Populate();
}
}
else
{
if (_itemInteraction is not null)
{
DispatchInventoryRequest(
InventoryRequestKind.PutInContainer,
item,
() =>
{
if (_sendPutItemInContainer is null
|| !_objects.MoveItemOptimistic(item, container, placement))
{
return false;
}
_sendPutItemInContainer(item, container, placement);
return true;
});
return;
}
_objects.MoveItemOptimistic(item, container, placement);
}
_sendPutItemInContainer?.Invoke(item, container, placement);
@ -651,14 +737,30 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
if (plan is not { } merge)
return false;
_sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount);
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
// it is an attempt notice, not a later server-confirm event.
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
return true;
return DispatchInventoryRequest(
InventoryRequestKind.Merge,
merge.SourceObjectId,
() =>
{
_sendStackableMerge(
merge.SourceObjectId,
merge.TargetObjectId,
merge.Amount);
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
// it is an attempt notice, not a later server-confirm event.
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
return true;
});
}
private bool DispatchInventoryRequest(
InventoryRequestKind kind,
uint itemId,
Func<bool> dispatch)
=> _itemInteraction?.TryDispatchInventoryRequest(kind, itemId, dispatch)
?? dispatch();
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
private bool IsContainerFull(uint container)
@ -691,7 +793,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint p = _playerGuid();
_openContainer = guid;
if (guid != p)
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
{
if (_itemInteraction is not null)
_itemInteraction.ActivateItem(guid);
else
_sendUse?.Invoke(guid); // open the side bag (ViewContents will land)
}
Populate(); // repaint the grid for the new open container
}
@ -814,6 +921,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
{
_itemInteraction.StateChanged -= OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested;
_itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled;
_itemInteraction.PendingBackpackPlacementResolved -= OnPendingBackpackPlacementResolved;
}
}
}

View file

@ -519,6 +519,29 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
uint player = _playerGuid?.Invoke() ?? 0u;
if (player == 0 || _repo.Get(payload.ObjId) is null)
return;
if (_itemInteraction is not null)
{
Func<bool> dispatch = () =>
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(payload.ObjId, player, 0);
return true;
};
if (_itemInteraction.IsOwnedByPlayer(payload.ObjId))
_itemInteraction.TryDispatchPendingBackpackPlacement(
payload.ObjId,
player,
0,
InventoryRequestKind.PutInContainer,
dispatch);
else
_itemInteraction.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
payload.ObjId,
dispatch);
return;
}
_sendPutItemInContainer?.Invoke(payload.ObjId, player, 0);
}