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

@ -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;
}
}
}