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

@ -1,6 +1,7 @@
using AcDream.App.Input;
using AcDream.App.UI;
using AcDream.App.World;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Ui;
@ -24,12 +25,18 @@ internal sealed class SelectionInteractionController
private readonly Action<string>? _toast;
private PendingPostArrivalAction? _pendingPostArrival;
private readonly record struct QueuedInteractionIdentity(
uint ServerGuid,
uint? LocalEntityId,
ClientObject? ClientObject);
private readonly record struct PendingPostArrivalAction(
uint ServerGuid,
uint LocalEntityId,
bool IsPickup,
uint DestinationContainerId,
int Placement);
int Placement,
ulong PendingPlacementToken);
public SelectionInteractionController(
SelectionState selection,
@ -69,8 +76,14 @@ internal sealed class SelectionInteractionController
case InputAction.SelectionPickUp:
if (_selection.SelectedObjectId is uint pickupTarget)
{
_outbound.Enqueue(() =>
_items.PlaceWorldItemInBackpack(pickupTarget));
EnqueueIdentityBound(
pickupTarget,
requireLiveEntity: true,
guid =>
{
if (ValidatePickupTarget(guid, showToast: true))
_items.PlaceWorldItemInBackpack(guid);
});
}
else
{
@ -85,12 +98,18 @@ internal sealed class SelectionInteractionController
}
}
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
=> _query.PickAt(mouseX, mouseY, includeSelf);
public uint? PickAtCursor(bool includeSelf)
=> _query.PickAtCursor(includeSelf);
public void PlaceDraggedItem(ItemDragPayload payload, float mouseX, float mouseY)
{
ArgumentNullException.ThrowIfNull(payload);
uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u;
if (target != 0u)
_query.BeginLightingPulse(target);
_items.PlaceIn3D(payload, target);
}
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{
if (_selection.SelectedObjectId is { } selected
@ -147,7 +166,13 @@ internal sealed class SelectionInteractionController
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
_toast?.Invoke($"Selected: {label}");
if (useImmediately)
_outbound.Enqueue(() => _items.ActivateItem(guid));
EnqueueIdentityBound(
guid,
requireLiveEntity: true,
selected =>
{
_items.ActivateItem(selected);
});
}
public void UseCurrentSelection()
@ -157,11 +182,18 @@ internal sealed class SelectionInteractionController
_toast?.Invoke("Nothing selected");
return;
}
_outbound.Enqueue(() => _items.UseSelectedOrEnterMode(selected));
EnqueueIdentityBound(
selected,
requireLiveEntity: false,
guid =>
{
_items.UseSelectedOrEnterMode(guid);
});
}
public void SendUse(uint serverGuid)
{
CancelPendingApproach();
if (!_transport.IsInWorld)
{
_toast?.Invoke("Not in world");
@ -173,63 +205,104 @@ internal sealed class SelectionInteractionController
return;
}
_movement.BeginApproach(approach);
if (approach.IsCloseRange)
{
_pendingPostArrival = new PendingPostArrivalAction(
var pending = new PendingPostArrivalAction(
serverGuid,
approach.Target.LocalEntityId,
IsPickup: false,
DestinationContainerId: 0u,
Placement: 0);
Placement: 0,
PendingPlacementToken: 0u);
bool started = _movement.BeginApproach(
approach,
() => _pendingPostArrival = pending);
if (!started && _pendingPostArrival == pending)
CancelPendingApproach();
return;
}
_movement.BeginApproach(approach);
if (_transport.TrySendUse(serverGuid, out uint sequence))
Console.WriteLine($"[B.4b] use guid=0x{serverGuid:X8} seq={sequence}");
}
public void SendPickup(uint itemGuid, uint destinationContainerId, int placement)
{
CancelPendingApproach();
if (!_transport.IsInWorld)
{
_toast?.Invoke("Not in world");
CancelPickupPresentation(itemGuid);
return;
}
if (_query.IsCreature(itemGuid))
if (!ValidatePickupTarget(itemGuid, showToast: true)
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
{
CancelPickupPresentation(itemGuid);
return;
}
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
itemGuid,
out PendingBackpackPlacement pendingPlacement)
? pendingPlacement.Token
: 0u;
if (!IsCurrentPickupPresentation(
itemGuid,
destinationContainerId,
placement,
pendingPlacementToken))
{
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
return;
}
if (!_query.IsPickupable(itemGuid))
{
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(itemGuid)));
return;
}
if (!_query.TryGetApproach(itemGuid, out InteractionApproach approach))
return;
_movement.BeginApproach(approach);
if (approach.IsCloseRange)
{
_pendingPostArrival = new PendingPostArrivalAction(
var pending = new PendingPostArrivalAction(
itemGuid,
approach.Target.LocalEntityId,
IsPickup: true,
destinationContainerId,
placement);
placement,
pendingPlacementToken);
bool started = _movement.BeginApproach(
approach,
() => _pendingPostArrival = pending);
if (!started && _pendingPostArrival == pending)
CancelPendingApproach();
else if (!started)
CancelPickupPresentation(itemGuid, pendingPlacementToken);
return;
}
if (_transport.TrySendPickup(
_movement.BeginApproach(approach);
if (!IsCurrentPickupPresentation(
itemGuid,
destinationContainerId,
placement,
out uint sequence))
pendingPlacementToken))
{
return;
}
uint sequence = 0u;
if (_items.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
itemGuid,
() => _transport.TrySendPickup(
itemGuid,
destinationContainerId,
placement,
out sequence),
pendingPlacementToken))
{
Console.WriteLine(
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
}
else
{
CancelPickupPresentation(itemGuid, pendingPlacementToken);
}
}
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
@ -239,15 +312,38 @@ internal sealed class SelectionInteractionController
return;
_pendingPostArrival = null;
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
{
if (pending.IsPickup)
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
return;
}
if (pending.IsPickup)
{
_transport.TrySendPickup(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
out _);
if (!IsCurrentPickupPresentation(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
pending.PendingPlacementToken))
{
return;
}
if (!_items.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
pending.ServerGuid,
() => _transport.TrySendPickup(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
out _),
pending.PendingPlacementToken))
{
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
}
else
{
@ -257,10 +353,12 @@ internal sealed class SelectionInteractionController
public void DrainOutbound() => _outbound.Drain();
public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach();
public void OnEntityHidden(uint serverGuid)
{
if (_pendingPostArrival?.ServerGuid == serverGuid)
_pendingPostArrival = null;
CancelPendingApproach();
if (_selection.SelectedObjectId == serverGuid)
{
_selection.Clear(
@ -276,7 +374,7 @@ internal sealed class SelectionInteractionController
&& pending.ServerGuid == record.ServerGuid
&& pending.LocalEntityId == record.LocalEntityId)
{
_pendingPostArrival = null;
CancelPendingApproach();
}
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
{
@ -288,9 +386,85 @@ internal sealed class SelectionInteractionController
public void ResetSession()
{
CancelPendingApproach();
_items.ResetSession();
_selection.Reset();
_outbound.Clear();
_pendingPostArrival = null;
}
private bool ValidatePickupTarget(uint serverGuid, bool showToast)
{
if (_query.IsCreature(serverGuid))
{
if (showToast)
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
return false;
}
if (_query.IsPickupable(serverGuid))
return true;
if (showToast)
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid)));
return false;
}
private bool EnqueueIdentityBound(
uint serverGuid,
bool requireLiveEntity,
Action<uint> action)
{
ArgumentNullException.ThrowIfNull(action);
uint? localEntityId = _query.TryCaptureIdentity(serverGuid, out uint localId)
? localId
: null;
ClientObject? item = _items.TryCaptureObjectIdentity(serverGuid, out ClientObject captured)
? captured
: null;
if ((requireLiveEntity && localEntityId is null)
|| (localEntityId is null && item is null))
{
return false;
}
var identity = new QueuedInteractionIdentity(serverGuid, localEntityId, item);
_outbound.Enqueue(() =>
{
if (IsCurrent(identity))
action(identity.ServerGuid);
});
return true;
}
private bool IsCurrent(QueuedInteractionIdentity identity)
=> (identity.LocalEntityId is not uint localId
|| _query.IsCurrent(identity.ServerGuid, localId))
&& (identity.ClientObject is not { } item
|| _items.IsCurrentObjectIdentity(identity.ServerGuid, item));
private void CancelPendingApproach()
{
if (_pendingPostArrival is not { } pending)
return;
_pendingPostArrival = null;
if (pending.IsPickup)
CancelPickupPresentation(
pending.ServerGuid,
pending.PendingPlacementToken);
}
private void CancelPickupPresentation(uint itemGuid, ulong token = 0u)
=> _items.CancelPendingBackpackPlacement(itemGuid, token);
private bool IsCurrentPickupPresentation(
uint itemGuid,
uint destinationContainerId,
int placement,
ulong token)
=> token != 0u
&& _items.TryGetPendingBackpackPlacement(
itemGuid,
out PendingBackpackPlacement pending)
&& pending.Token == token
&& pending.ContainerId == destinationContainerId
&& pending.Placement == placement;
}