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