refactor(interaction): add selection interaction owner

Introduce a tested controller for selection mutations, target-mode interception, outbound ordering, Use/PickUp lifetime, and live-incarnation cleanup, plus narrow movement and WorldSession transport adapters.
This commit is contained in:
Erik 2026-07-21 07:24:26 +02:00
parent e74f2ca99a
commit fa8d5232cc
5 changed files with 754 additions and 1 deletions

View file

@ -0,0 +1,57 @@
using AcDream.App.Input;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Interaction;
internal interface IPlayerInteractionMovementSink
{
bool BeginApproach(InteractionApproach approach);
}
/// <summary>
/// Installs retail's client-side TurnToObject/MoveToObject prediction through
/// the same MovementManager used by authoritative movement packets.
/// </summary>
internal sealed class PlayerInteractionMovementSink(
Func<PlayerMovementController?> player)
: IPlayerInteractionMovementSink
{
private readonly Func<PlayerMovementController?> _player = player
?? throw new ArgumentNullException(nameof(player));
public bool BeginApproach(InteractionApproach approach)
{
PlayerMovementController? controller = _player();
if (controller?.MoveTo is null)
return false;
var parameters = new MovementParameters
{
DistanceToObject = approach.UseRadius,
CanCharge = approach.CanCharge,
};
var movement = new MovementStruct
{
ObjectId = approach.Target.ServerGuid,
TopLevelId = approach.Target.ServerGuid,
Pos = new Position(
approach.Player.CellId,
approach.Target.Entity.Position,
System.Numerics.Quaternion.Identity),
Params = parameters,
Type = approach.IsCloseRange
? MovementType.TurnToObject
: MovementType.MoveToObject,
Radius = approach.TargetRadius,
Height = approach.TargetHeight,
};
// P1's wire MoveToObject store marks the command non-autonomous.
// The speculative local install must do the same or the next raw-input
// pump overwrites it before ACE's authoritative movement arrives.
controller.SetLastMoveWasAutonomous(false);
controller.Movement.PerformMovement(movement);
return true;
}
}

View file

@ -0,0 +1,296 @@
using AcDream.App.Input;
using AcDream.App.UI;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Ui;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Interaction;
/// <summary>
/// Owns world selection mutations and one-shot Use/PickUp interaction state.
/// Read-only classification stays in <see cref="WorldSelectionQuery"/>;
/// retained item policy stays in <see cref="ItemInteractionController"/>.
/// </summary>
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<string>? _toast;
private PendingPostArrivalAction? _pendingPostArrival;
private readonly record struct PendingPostArrivalAction(
uint ServerGuid,
uint LocalEntityId,
bool IsPickup,
uint DestinationContainerId,
int Placement);
public SelectionInteractionController(
SelectionState selection,
IWorldSelectionQuery query,
ItemInteractionController items,
ISelectionInteractionTransport transport,
IPlayerInteractionMovementSink movement,
Action<string>? 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)
{
_outbound.Enqueue(() =>
_items.PlaceWorldItemInBackpack(pickupTarget));
}
else
{
_toast?.Invoke("Nothing selected");
}
return true;
case InputAction.EscapeKey when _items.IsAnyTargetModeActive:
_items.CancelTargetMode();
return true;
default:
return false;
}
}
public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
=> _query.PickAt(mouseX, mouseY, includeSelf);
public uint? PickAtCursor(bool includeSelf)
=> _query.PickAtCursor(includeSelf);
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)
_outbound.Enqueue(() => _items.ActivateItem(guid));
}
public void UseCurrentSelection()
{
if (_selection.SelectedObjectId is not uint selected)
{
_toast?.Invoke("Nothing selected");
return;
}
_outbound.Enqueue(() => _items.UseSelectedOrEnterMode(selected));
}
public void SendUse(uint serverGuid)
{
if (!_transport.IsInWorld)
{
_toast?.Invoke("Not in world");
return;
}
if (!_query.IsUseable(serverGuid)
|| !_query.TryGetApproach(serverGuid, out InteractionApproach approach))
{
return;
}
_movement.BeginApproach(approach);
if (approach.IsCloseRange)
{
_pendingPostArrival = new PendingPostArrivalAction(
serverGuid,
approach.Target.LocalEntityId,
IsPickup: false,
DestinationContainerId: 0u,
Placement: 0);
return;
}
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)
{
if (!_transport.IsInWorld)
{
_toast?.Invoke("Not in world");
return;
}
if (_query.IsCreature(itemGuid))
{
_toast?.Invoke(RetailMessages.CannotPickUpCreatures);
return;
}
if (!_query.IsPickupable(itemGuid))
{
_toast?.Invoke(RetailMessages.CantBePickedUp(_query.Describe(itemGuid)));
return;
}
if (!_query.TryGetApproach(itemGuid, out InteractionApproach approach))
return;
_movement.BeginApproach(approach);
if (approach.IsCloseRange)
{
_pendingPostArrival = new PendingPostArrivalAction(
itemGuid,
approach.Target.LocalEntityId,
IsPickup: true,
destinationContainerId,
placement);
return;
}
if (_transport.TrySendPickup(
itemGuid,
destinationContainerId,
placement,
out uint sequence))
{
Console.WriteLine(
$"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={sequence}");
}
}
/// <summary>Fires only after natural MoveToComplete(None), never cancellation.</summary>
public void OnNaturalMoveToComplete()
{
if (_pendingPostArrival is not { } pending)
return;
_pendingPostArrival = null;
if (!_query.IsCurrent(pending.ServerGuid, pending.LocalEntityId))
return;
if (pending.IsPickup)
{
_transport.TrySendPickup(
pending.ServerGuid,
pending.DestinationContainerId,
pending.Placement,
out _);
}
else
{
_transport.TrySendUse(pending.ServerGuid, out _);
}
}
public void DrainOutbound() => _outbound.Drain();
public void OnEntityHidden(uint serverGuid)
{
if (_pendingPostArrival?.ServerGuid == serverGuid)
_pendingPostArrival = null;
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)
{
_pendingPostArrival = null;
}
if (!replacementExists && _selection.SelectedObjectId == record.ServerGuid)
{
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved);
}
}
public void ResetSession()
{
_items.ResetSession();
_selection.Reset();
_outbound.Clear();
_pendingPostArrival = null;
}
}

View file

@ -0,0 +1,62 @@
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Interaction;
internal interface ISelectionInteractionTransport
{
bool IsInWorld { get; }
bool TrySendUse(uint serverGuid, out uint sequence);
bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence);
}
internal sealed class WorldSessionSelectionInteractionTransport(
Func<WorldSession?> session)
: ISelectionInteractionTransport
{
private readonly Func<WorldSession?> _session = session
?? throw new ArgumentNullException(nameof(session));
public bool IsInWorld =>
_session()?.CurrentState == WorldSession.State.InWorld;
public bool TrySendUse(uint serverGuid, out uint sequence)
{
WorldSession? live = _session();
if (live?.CurrentState != WorldSession.State.InWorld)
{
sequence = 0u;
return false;
}
sequence = live.NextGameActionSequence();
live.SendGameAction(InteractRequests.BuildUse(sequence, serverGuid));
return true;
}
public bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence)
{
WorldSession? live = _session();
if (live?.CurrentState != WorldSession.State.InWorld)
{
sequence = 0u;
return false;
}
sequence = live.NextGameActionSequence();
live.SendGameAction(InteractRequests.BuildPickUp(
sequence,
itemGuid,
destinationContainerId,
placement));
return true;
}
}

View file

@ -34,12 +34,27 @@ internal readonly record struct InteractionApproach(
float TargetRadius,
float TargetHeight);
internal interface IWorldSelectionQuery
{
uint? PickAtCursor(bool includeSelf);
uint? PickAt(float mouseX, float mouseY, bool includeSelf);
void BeginLightingPulse(uint serverGuid);
bool IsCurrent(uint serverGuid, uint localEntityId);
string Describe(uint serverGuid);
bool IsCreature(uint serverGuid);
bool IsHostileMonster(uint serverGuid);
ClosestCombatTarget? FindClosestHostileMonster();
bool IsUseable(uint serverGuid);
bool IsPickupable(uint serverGuid);
bool TryGetApproach(uint serverGuid, out InteractionApproach approach);
}
/// <summary>
/// Read-only owner for retail world selection and interaction classification.
/// It consumes the canonical live-entity runtime but never mutates selection,
/// movement, inventory, or network state.
/// </summary>
internal sealed class WorldSelectionQuery
internal sealed class WorldSelectionQuery : IWorldSelectionQuery
{
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
private const uint StuckObjectFlag = 0x0004u;