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); } public uint? GetSelectedOrClosestCombatTarget(bool autoTarget) { if (_selection.SelectedObjectId is { } selected && _query.IsHostileMonster(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)) 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; } EnqueueIdentityBound( RuntimeQueuedInteractionKind.Use, selected, requireLiveEntity: false); } public void SendUse(uint serverGuid) => RequestUse(serverGuid, reservation: null); public void RequestUse( uint serverGuid, ItemUseRequestReservation? reservation) { CancelPendingApproach(); bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); RuntimeInteractionDispatchResult result = _transactions.TryDispatchUse( serverGuid, ownedByPlayer, ownedByPlayer || _query.IsUseable(serverGuid), 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{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 pending)) HandleApproachCompletion(pending.ApproachToken, natural: true); } private void HandleApproachCompletion( RuntimeInteractionApproachToken approachToken, bool natural) { bool accepted = _transactions.TryResolveApproachCompletion( approachToken, natural, out RuntimePendingPickup pending); if (pending.Token == 0u) return; 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); } } 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); } 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); } 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)) return; CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); } 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; }