using AcDream.App.UI; using AcDream.App.World; using AcDream.Core.Items; using AcDream.Core.Physics; using AcDream.Core.Selection; using AcDream.Core.Ui; using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; namespace AcDream.App.Interaction; /// /// Owns world selection mutations and one-shot Use/PickUp interaction state. /// Read-only classification stays in ; /// retained item policy stays in . /// internal sealed class SelectionInteractionController { private readonly SelectionState _selection; private readonly IWorldSelectionQuery _query; private readonly ItemInteractionController _items; private readonly RuntimeInteractionTransactionState _transactions; private readonly IRuntimeInteractionTransport _transport; private readonly IPlayerInteractionMovementSink _movement; private readonly PlayerApproachCompletionState _approachCompletions; private readonly Action? _toast; public SelectionInteractionController( SelectionState selection, IWorldSelectionQuery query, ItemInteractionController items, IRuntimeInteractionTransport transport, IPlayerInteractionMovementSink movement, Action? toast = null, PlayerApproachCompletionState? approachCompletions = null) { _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _query = query ?? throw new ArgumentNullException(nameof(query)); _items = items ?? throw new ArgumentNullException(nameof(items)); _transactions = _items.RuntimeTransactions; _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _movement = movement ?? throw new ArgumentNullException(nameof(movement)); _toast = toast; _approachCompletions = approachCompletions ?? new PlayerApproachCompletionState(); } public bool HandleInputAction(InputAction action) { switch (action) { case InputAction.SelectionClosestMonster: SelectClosestCombatTarget(showToast: true); return true; case InputAction.SelectionPreviousSelection: _selection.SelectPrevious(); return true; case InputAction.SelectLeft: PickAndStoreSelection(useImmediately: false); return true; case InputAction.SelectRight: PickSelectAndExamine(); return true; case InputAction.SelectDblLeft: PickAndStoreSelection(useImmediately: true); return true; case InputAction.SelectionExamine: _items.ExamineSelectedOrEnterMode( _selection.SelectedObjectId ?? 0u); return true; case InputAction.UseSelected: UseCurrentSelection(); return true; case InputAction.SelectionPickUp: if (_selection.SelectedObjectId is uint pickupTarget) { EnqueueIdentityBound( RuntimeQueuedInteractionKind.Pickup, pickupTarget, requireLiveEntity: true); } else { _toast?.Invoke("Nothing selected"); } return true; case InputAction.EscapeKey when _items.IsAnyTargetModeActive: _items.CancelTargetMode(); return true; default: return false; } } 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); } /// /// Explicit selection uses the wider ObjectIsAttackable-backed /// admission (#298) so a compatible-PK player is a valid attack target; /// falling back to auto-acquisition still uses the narrower /// monster-only /// policy (register row IA-19). /// public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) { if (_selection.SelectedObjectId is { } selected && _query.IsAttackableTarget(selected)) { return selected; } return autoTarget ? SelectClosestCombatTarget(showToast: false) : null; } public uint? SelectClosestCombatTarget(bool showToast) { ClosestCombatTarget? closest = _query.FindClosestHostileMonster(); uint? bestGuid = closest?.ServerGuid; if (bestGuid is { } selected) _selection.Select(selected, SelectionChangeSource.Keyboard); else _selection.Clear(SelectionChangeSource.Keyboard); if (bestGuid is { } guid) { string label = _query.Describe(guid); float distance = MathF.Sqrt(closest!.Value.DistanceSquared); Console.WriteLine($"combat: selected target 0x{guid:X8} {label} dist={distance:F1}"); if (showToast) _toast?.Invoke($"Target {label}"); } else if (showToast) { _toast?.Invoke("No monster target"); Console.WriteLine("combat: no creature target found"); } return bestGuid; } public void PickAndStoreSelection(bool useImmediately) { uint? picked = _query.PickAtCursor(_items.IsAnyTargetModeActive); if (picked is not uint guid) { if (!_items.IsAnyTargetModeActive) _toast?.Invoke("Nothing to select"); return; } // UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound // @ 0x004E5AD0 pulses before select/use/target-mode branching. _query.BeginLightingPulse(guid); if (_items.OfferPrimaryClick(guid) is not ItemPrimaryClickResult.NotActive) return; _selection.Select(guid, SelectionChangeSource.World); string label = _query.Describe(guid); Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); _toast?.Invoke($"Selected: {label}"); // The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 // guards ItemHolder::UseObject with // `if (found->pwd._wielderID != SmartBox::player_id)` at 0x004E5BE9. // Clicking your own wielded weapon selects and flashes it but sends no // Use. Equipped-child picking makes that click reachable, so the gate // ships with it. if (useImmediately && !_query.IsWieldedByPlayer(guid)) { if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] route=dblclick-world PickAndStoreSelection guid=0x{guid:X8} enqueue=Activate"); } EnqueueIdentityBound( RuntimeQueuedInteractionKind.Activate, guid, requireLiveEntity: true); } } /// /// Retail SmartBox right-click path: /// UIElement_SmartBoxWrapper::MouseUp @ 0x004E5820 chooses /// sr_Examine, then /// RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 pulses, selects, /// and calls ClientUISystem::ExamineObject. Empty space is a no-op, /// and this path does not consume a left-click target mode. /// public void PickSelectAndExamine() { uint? picked = _query.PickAtCursor(includeSelf: false); if (picked is not uint guid) return; _query.BeginLightingPulse(guid); _selection.Select(guid, SelectionChangeSource.World); _items.ExamineSelectedOrEnterMode(guid); } public void UseCurrentSelection() { if (_selection.SelectedObjectId is not uint selected) { _toast?.Invoke("Nothing selected"); return; } if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] route=keyboard-use UseCurrentSelection guid=0x{selected:X8} enqueue=Use"); } EnqueueIdentityBound( RuntimeQueuedInteractionKind.Use, selected, requireLiveEntity: false); } public void SendUse(uint serverGuid) => RequestUse(serverGuid, reservation: null); /// /// G3 (grand-gate finding, 2026-08-08, register AP-170): an out-of-range /// Use no longer sends the wire request immediately — it arms on the /// SAME arrival-gated shape 's close-range /// (turn-only) branch already uses, dispatching only once the approach /// naturally completes. /// /// Why this deviates from retail's own literal /// ItemHolder::UseObject @ 0x00588A80 send-immediately shape. /// Retail's REAL server walks the player itself before the target's /// ActOnUse handler ever sees the request — the client is free to /// fire immediately because the server-side arrival gate is invisible to /// it. ACE does not do this for a player-initiated Use: live testing /// against the user's local ACE server (2026-08-08) showed a vendor /// approached from out of range plays its cosmetic greeting (a /// distance-only reaction independent of the Use action) but never opens /// the shop panel — ApproachVendor never arrives. ACE's own /// Player.HandleActionUseItem (Player_Use.cs:176-215) /// confirms why: an out-of-range target routes through /// CreateMoveToChain(item, (success) => TryUseItem(item, success)) /// (Player_Move.cs:37-96), which POLLS every 0.1s for the player /// to reach WithinUseRadius and only then calls /// TryUseItem/ActOnUse — it does not teleport or /// server-move the player; it waits for the client's own walk to land. /// Sending the wire Use before OUR client has actually arrived races /// that poll and can lose. Retail's client-side immediacy assumption /// (this method's ORIGINAL design, see the register) does not hold /// against this server; arming on arrival closes the gap by construction /// instead of racing it. /// /// /// F11 (Slice 6b/6c review, preserved): the eligibility test /// (ownedByPlayer || useable) is still computed ONCE, up front, /// before any approach or arm — an ineligible target never kicks off a /// wasted walk. /// /// public void RequestUse( uint serverGuid, ItemUseRequestReservation? reservation) { CancelPendingApproach(); bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); bool useable = ownedByPlayer || _query.IsUseable(serverGuid); if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] RequestUse entry guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable}"); } if (useable && _query.TryGetApproach(serverGuid, out InteractionApproach approach) && !approach.IsCloseRange) { if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=approach-armed useRadius={approach.UseRadius} isCloseRange={approach.IsCloseRange}"); } // Genuinely out of range (a real walk, not just a turn) — // mirror SendPickup's arrival-gated shape: arm the transaction // on the approach token BEFORE the movement starts (so a // synchronously-completing approach can't race the arm), then // let HandleApproachCompletion dispatch on natural arrival. bool armed = false; bool started = _movement.BeginApproach( approach, token => { armed = _transactions.TryArmPostArrivalUse( serverGuid, ownedByPlayer, useable, reservation, new RuntimeInteractionApproachToken( token.ControllerLifetime, token.ApproachGeneration), out _); if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] TryArmPostArrivalUse guid=0x{serverGuid:X8} armed={armed} approachToken=({token.ControllerLifetime},{token.ApproachGeneration})"); } }); if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] BeginApproach-result guid=0x{serverGuid:X8} started={started} armed={armed} stopDistance={approach.UseRadius} target=0x{approach.Target.ServerGuid:X8}"); } if (!started || !armed) { // Release whatever got captured (or the caller's own // reservation, if arming never stored it) — mirrors // SendPickup's !started/!armed cleanup shape. if (_transactions.TryCancelPendingUse( serverGuid, out RuntimePendingUse cancelled)) { cancelled.Reservation?.CancelBeforeDispatch(); } else { reservation?.CancelBeforeDispatch(); } } return; } // Already in range (a turn at most, or no approach concept applies) // — keep retail's immediate send; ACE's own "already within use // distance" branch (Player_Move.cs:65-87) calls back synchronously, // so there is no arrival gap to race here. if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=immediate-dispatch ownedByPlayer={ownedByPlayer} useable={useable}"); } RuntimeInteractionDispatchResult result = _transactions.TryDispatchUse( serverGuid, ownedByPlayer, useable, reservation, _transport, out uint sequence); if (VendorDiagnostics.DumpVendorEnabled) { Console.WriteLine( $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} TryDispatchUse verdict={result} seq={sequence}"); } if (result == RuntimeInteractionDispatchResult.NotInWorld) _toast?.Invoke("Not in world"); if (result == RuntimeInteractionDispatchResult.Dispatched) 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; } ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement( itemGuid, out PendingBackpackPlacement pendingPlacement) ? pendingPlacement.Token : 0u; if (!IsCurrentPickupPresentation( itemGuid, destinationContainerId, placement, pendingPlacementToken)) { return; } // Retail CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 delegates // current-ground-object contents straight to // ItemHolder::AttemptToPlaceInContainer @ 0x00588140. A corpse or // chest child has no independent 3-D projection to approach. // // ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records the // IR_PICK_UP world request only for PositionState.IN_3D_VIEW and // treats WIELDED as a plain IR_PUT_IN_CONTAINER transfer, so the // player's own wielded item is unwielded in place with no approach. // The ownership conjunct keeps this shortcut behind IsItemLegal's // 0x005872B7 arm, which ValidatePickupTarget enforces below: a wielded // item that is not the player's must never reach a wire request. if (_items.IsInCurrentGroundObject(itemGuid) || (_query.IsWieldedPositionState(itemGuid) && _items.IsOwnedByPlayer(itemGuid))) { var contained = new RuntimePendingPickup( Token: 0u, itemGuid, LocalEntityId: 0u, destinationContainerId, placement, pendingPlacementToken, ApproachToken: default); if (_transactions.TryDispatchPickup( contained, _transport, out uint containedSequence)) { Console.WriteLine( $"[B.5] contained pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={containedSequence}"); } else { CancelPickupPresentation(itemGuid, pendingPlacementToken); } return; } if (!ValidatePickupTarget(itemGuid, showToast: true) || !_query.TryGetApproach(itemGuid, out InteractionApproach approach)) { CancelPickupPresentation(itemGuid, pendingPlacementToken); return; } if (approach.IsCloseRange) { bool armed = false; bool started = _movement.BeginApproach( approach, token => { armed = _transactions.TryArmPostArrivalPickup( itemGuid, approach.Target.LocalEntityId, destinationContainerId, placement, pendingPlacementToken, new RuntimeInteractionApproachToken( token.ControllerLifetime, token.ApproachGeneration), out _); }); if (!started || !armed) { if (_transactions.TryCancelPendingPickup( itemGuid, approach.Target.LocalEntityId, out RuntimePendingPickup cancelled)) { CancelPickupPresentation( cancelled.ServerGuid, cancelled.PendingPlacementToken); } else { CancelPickupPresentation(itemGuid, pendingPlacementToken); } } return; } _movement.BeginApproach(approach); if (!IsCurrentPickupPresentation( itemGuid, destinationContainerId, placement, pendingPlacementToken)) { return; } var immediate = new RuntimePendingPickup( Token: 0u, itemGuid, approach.Target.LocalEntityId, destinationContainerId, placement, pendingPlacementToken, ApproachToken: default); if (_transactions.TryDispatchPickup( immediate, _transport, out uint sequence)) { Console.WriteLine( $"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}"); } else { CancelPickupPresentation(itemGuid, pendingPlacementToken); } } /// Fires only after natural MoveToComplete(None), never cancellation. public void OnNaturalMoveToComplete() { if (_transactions.TryGetPendingPickup(out RuntimePendingPickup pendingPickup)) { HandleApproachCompletion(pendingPickup.ApproachToken, natural: true); return; } // G3: at most one of {pendingPickup, pendingUse} is ever armed — // CancelPendingApproach() clears any prior one before a new // SendPickup/RequestUse arms another. if (_transactions.TryGetPendingUse(out RuntimePendingUse pendingUse)) HandleApproachCompletion(pendingUse.ApproachToken, natural: true); } private void HandleApproachCompletion( RuntimeInteractionApproachToken approachToken, bool natural) { bool pickupAccepted = _transactions.TryResolveApproachCompletion( approachToken, natural, out RuntimePendingPickup pendingPickup); if (pendingPickup.Token != 0u) { HandlePickupApproachCompletion(pendingPickup, pickupAccepted); return; } bool useAccepted = _transactions.TryResolveUseApproachCompletion( approachToken, natural, out RuntimePendingUse pendingUse); if (pendingUse.Token != 0u) HandleUseApproachCompletion(pendingUse, useAccepted); } private void HandlePickupApproachCompletion( RuntimePendingPickup pending, bool accepted) { if (!accepted) { CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); return; } if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) { CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); return; } if (!IsCurrentPickupPresentation( pending.ServerGuid, pending.DestinationContainerId, pending.Placement, pending.PendingPlacementToken)) { return; } if (!_transactions.TryDispatchPickup( pending, _transport, out _)) { CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); } } /// /// G3: dispatches an armed Use on natural arrival. A cancelled approach /// ( false — supersede/move-away) releases /// the reservation directly; /// already resolves the reservation on every one of its own outcomes /// (dispatched or rejected), so no separate release is needed past that /// point. /// private void HandleUseApproachCompletion( RuntimePendingUse pending, bool accepted) { if (VendorDiagnostics.DumpVendorEnabled) { // Diagnostic-only re-query — TryGetApproach is a pure read with // no side effects, so an extra call here (gated off in // production) cannot change RequestUse's own dispatch outcome. string distanceText = "n/a"; if (_query.TryGetApproach(pending.ServerGuid, out InteractionApproach diagApproach)) { float dx = diagApproach.Target.Entity.Position.X - diagApproach.Player.Position.X; float dy = diagApproach.Target.Entity.Position.Y - diagApproach.Player.Position.Y; distanceText = MathF.Sqrt(dx * dx + dy * dy).ToString("F2"); } Console.WriteLine( $"[vendor-diag] HandleUseApproachCompletion guid=0x{pending.ServerGuid:X8} accepted={accepted} playerToTargetDist={distanceText}"); } if (!accepted) { pending.Reservation?.CancelBeforeDispatch(); return; } RuntimeInteractionDispatchResult result = _transactions.TryDispatchUse( pending.ServerGuid, pending.OwnedByPlayer, pending.Useable, pending.Reservation, _transport, out uint sequence); if (result == RuntimeInteractionDispatchResult.NotInWorld) _toast?.Invoke("Not in world"); if (result == RuntimeInteractionDispatchResult.Dispatched) { Console.WriteLine( $"[B.4b] use guid=0x{pending.ServerGuid:X8} seq={sequence} (arrival-gated)"); } } public void DrainOutbound() { while (_approachCompletions.TryTake(out PlayerApproachCompletion completion)) { HandleApproachCompletion( new RuntimeInteractionApproachToken( completion.Token.ControllerLifetime, completion.Token.ApproachGeneration), completion.IsNatural); } _transactions.DrainOutbound(DispatchQueuedInteraction); } public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach(); public void OnEntityHidden(uint serverGuid) { _transactions.CancelQueuedInteractions(serverGuid); if (_transactions.TryCancelPendingPickup( serverGuid, localEntityId: null, out RuntimePendingPickup cancelled)) { CancelPickupPresentation( cancelled.ServerGuid, cancelled.PendingPlacementToken); } // G3: an armed out-of-range Use whose target vanished must release // its reservation too — the approach it was waiting on will never // naturally complete against a hidden target. if (_transactions.TryCancelPendingUse(serverGuid, out RuntimePendingUse cancelledUse)) cancelledUse.Reservation?.CancelBeforeDispatch(); if (_selection.SelectedObjectId == serverGuid) { _selection.Clear( SelectionChangeSource.System, SelectionChangeReason.Cleared); } } public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists) { ArgumentNullException.ThrowIfNull(record); _transactions.CancelQueuedInteractions( record.ServerGuid, record.LocalEntityId); if (_transactions.TryCancelPendingPickup( record.ServerGuid, record.LocalEntityId, out RuntimePendingPickup cancelled)) { CancelPickupPresentation( cancelled.ServerGuid, cancelled.PendingPlacementToken); } // G3: same as OnEntityHidden — a removed target's armed Use must // not linger waiting for an approach that can never complete. if (_transactions.TryCancelPendingUse(record.ServerGuid, out RuntimePendingUse cancelledUse)) cancelledUse.Reservation?.CancelBeforeDispatch(); if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid) { _selection.Clear( SelectionChangeSource.System, SelectionChangeReason.SelectedObjectRemoved); } } public void ResetSession() { List failures = []; try { CancelPendingApproach(); } catch (Exception error) { failures.Add(error); } try { _items.ResetSession(); } catch (Exception error) { failures.Add(error); } try { _selection.Reset(); } catch (Exception error) { failures.Add(error); } try { _approachCompletions.Clear(); } catch (Exception error) { failures.Add(error); } if (failures.Count != 0) throw new AggregateException( "One or more selection-interaction reset stages failed.", failures); } /// /// Retires only App approach and retained item-interaction presentation. /// Runtime resets the canonical interaction and selection owners once at /// the shared generation boundary. /// internal void ResetGenerationPresentation() { List failures = []; try { CancelPendingApproach(); } catch (Exception error) { failures.Add(error); } try { _items.ResetGenerationPresentation(); } catch (Exception error) { failures.Add(error); } try { _approachCompletions.Clear(); } catch (Exception error) { failures.Add(error); } if (failures.Count != 0) { throw new AggregateException( "One or more selection-interaction presentation reset stages failed.", failures); } } /// /// ItemHolder::AttemptToPlaceInContainer @ 0x00588140 runs /// AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 first, at /// 0x00588173 — ahead of container legality, auto-merge, the /// container walk, and the only CM_Inventory::Event_PutItemInContainer /// emitter (ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). /// A rejection is therefore one local /// ECM_UI::SendNotice_DisplayStringInfo(0x1a, …) and nothing else: /// no wire request and no movement. /// CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 then withdraws the /// waiting slot it had published (SetWaitingState(obj, 0) plus /// CM_Item::SendNotice_EndPendingInPlayer at 0x0055D918), /// which is what a false return drives here. /// private bool ValidatePickupTarget(uint serverGuid, bool showToast) { if (_query.IsCreature(serverGuid)) { if (showToast) _toast?.Invoke(RetailMessages.CannotPickUpCreatures); return false; } // IsItemLegal's arm at 0x005872B7 rejects // `!ACCWeenieObject::IsOwnedByPlayer(item) && item->pwd._location != 0` // with the notice at 0x005872DB. Retail runs it after the stuck arm at // 0x00587240; the two are disjoint, because the stuck arm fires only // when _containerID and _wielderID are both zero while this one needs a // wielded _location, so acdream's fused stuck/type predicate may follow. // Equipped-child picking made a remote character's wielded item // selectable, which is what makes this arm reachable. if (_query.IsWieldedPositionState(serverGuid) && !_items.IsOwnedByPlayer(serverGuid)) { if (showToast) { _toast?.Invoke(RetailMessages.BeingWieldedBySomeoneElse( _query.Describe(serverGuid))); } return false; } if (_query.IsPickupable(serverGuid)) return true; if (showToast) _toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(serverGuid))); return false; } private bool EnqueueIdentityBound( RuntimeQueuedInteractionKind kind, uint serverGuid, bool requireLiveEntity) { 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 RuntimeInteractionIdentity( serverGuid, localEntityId, item); _transactions.Enqueue(new RuntimeQueuedInteraction(kind, identity)); return true; } private bool IsCurrent(RuntimeInteractionIdentity 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 (_transactions.TryCancelPendingPickup( out RuntimePendingPickup pending)) { CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); } // G3: a new SendPickup/RequestUse supersedes whatever approach was // previously armed — release an in-flight Use's reservation too, not // just pickup's presentation token. if (_transactions.TryCancelPendingUse(out RuntimePendingUse pendingUse)) pendingUse.Reservation?.CancelBeforeDispatch(); } private void DispatchQueuedInteraction( RuntimeQueuedInteraction interaction) { RuntimeInteractionIdentity identity = interaction.Identity; if (!IsCurrent(identity)) return; switch (interaction.Kind) { case RuntimeQueuedInteractionKind.Activate: _items.ActivateItem(identity.ServerGuid); break; case RuntimeQueuedInteractionKind.Use: _items.UseSelectedOrEnterMode(identity.ServerGuid); break; case RuntimeQueuedInteractionKind.Pickup: if (ValidatePickupTarget(identity.ServerGuid, showToast: true)) _items.PlaceWorldItemInBackpack(identity.ServerGuid); break; default: throw new InvalidOperationException( $"Unknown queued interaction kind {interaction.Kind}."); } } 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; }