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; 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 ISelectionInteractionTransport _transport; private readonly IPlayerInteractionMovementSink _movement; private readonly OutboundInteractionQueue _outbound = new(); private readonly Action? _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, ulong PendingPlacementToken); public SelectionInteractionController( SelectionState selection, IWorldSelectionQuery query, ItemInteractionController items, ISelectionInteractionTransport transport, IPlayerInteractionMovementSink movement, Action? toast = null) { _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _query = query ?? throw new ArgumentNullException(nameof(query)); _items = items ?? throw new ArgumentNullException(nameof(items)); _transport = transport ?? throw new ArgumentNullException(nameof(transport)); _movement = movement ?? throw new ArgumentNullException(nameof(movement)); _toast = toast; } 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.SelectDblLeft: PickAndStoreSelection(useImmediately: true); return true; case InputAction.UseSelected: UseCurrentSelection(); return true; case InputAction.SelectionPickUp: if (_selection.SelectedObjectId is uint pickupTarget) { EnqueueIdentityBound( pickupTarget, requireLiveEntity: true, guid => { if (ValidatePickupTarget(guid, showToast: true)) _items.PlaceWorldItemInBackpack(guid); }); } 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}"); if (useImmediately) EnqueueIdentityBound( guid, requireLiveEntity: true, selected => { _items.ActivateItem(selected); }); } public void UseCurrentSelection() { if (_selection.SelectedObjectId is not uint selected) { _toast?.Invoke("Nothing selected"); return; } EnqueueIdentityBound( selected, requireLiveEntity: false, guid => { _items.UseSelectedOrEnterMode(guid); }); } public void SendUse(uint serverGuid) { CancelPendingApproach(); if (!_transport.IsInWorld) { _toast?.Invoke("Not in world"); return; } if (!_query.IsUseable(serverGuid) || !_query.TryGetApproach(serverGuid, out InteractionApproach approach)) { return; } if (approach.IsCloseRange) { var pending = new PendingPostArrivalAction( serverGuid, approach.Target.LocalEntityId, IsPickup: false, DestinationContainerId: 0u, 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 (!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)) { return; } if (approach.IsCloseRange) { var pending = new PendingPostArrivalAction( itemGuid, approach.Target.LocalEntityId, IsPickup: true, destinationContainerId, placement, pendingPlacementToken); bool started = _movement.BeginApproach( approach, () => _pendingPostArrival = pending); if (!started && _pendingPostArrival == pending) CancelPendingApproach(); else if (!started) CancelPickupPresentation(itemGuid, pendingPlacementToken); return; } _movement.BeginApproach(approach); if (!IsCurrentPickupPresentation( itemGuid, destinationContainerId, placement, 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); } } /// Fires only after natural MoveToComplete(None), never cancellation. public void OnNaturalMoveToComplete() { if (_pendingPostArrival is not { } pending) return; _pendingPostArrival = null; if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId)) { if (pending.IsPickup) CancelPickupPresentation( pending.ServerGuid, pending.PendingPlacementToken); return; } if (pending.IsPickup) { 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 { _transport.TrySendUse(pending.ServerGuid, out _); } } public void DrainOutbound() => _outbound.Drain(); public void OnMoveToCancelled(WeenieError _) => CancelPendingApproach(); public void OnEntityHidden(uint serverGuid) { if (_pendingPostArrival?.ServerGuid == serverGuid) CancelPendingApproach(); if (_selection.SelectedObjectId == serverGuid) { _selection.Clear( SelectionChangeSource.System, SelectionChangeReason.Cleared); } } public void OnEntityRemoved(LiveEntityRecord record, bool replacementExists) { ArgumentNullException.ThrowIfNull(record); if (_pendingPostArrival is { } pending && pending.ServerGuid == record.ServerGuid && pending.LocalEntityId == record.LocalEntityId) { CancelPendingApproach(); } 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 { _outbound.Clear(); } catch (Exception error) { failures.Add(error); } _pendingPostArrival = null; if (failures.Count != 0) throw new AggregateException( "One or more selection-interaction reset stages failed.", failures); } 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 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; }