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