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