diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
new file mode 100644
index 00000000..2890aedb
--- /dev/null
+++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
@@ -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);
+}
+
+///
+/// Installs retail's client-side TurnToObject/MoveToObject prediction through
+/// the same MovementManager used by authoritative movement packets.
+///
+internal sealed class PlayerInteractionMovementSink(
+ Func player)
+ : IPlayerInteractionMovementSink
+{
+ private readonly Func _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;
+ }
+}
diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs
new file mode 100644
index 00000000..67e37dde
--- /dev/null
+++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs
@@ -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;
+
+///
+/// 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 PendingPostArrivalAction(
+ uint ServerGuid,
+ uint LocalEntityId,
+ bool IsPickup,
+ uint DestinationContainerId,
+ int Placement);
+
+ 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)
+ {
+ _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}");
+ }
+ }
+
+ /// 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))
+ 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;
+ }
+}
diff --git a/src/AcDream.App/Interaction/SelectionInteractionTransport.cs b/src/AcDream.App/Interaction/SelectionInteractionTransport.cs
new file mode 100644
index 00000000..7af08489
--- /dev/null
+++ b/src/AcDream.App/Interaction/SelectionInteractionTransport.cs
@@ -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 session)
+ : ISelectionInteractionTransport
+{
+ private readonly Func _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;
+ }
+}
diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs
index f6a5cae1..f5548a81 100644
--- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs
+++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs
@@ -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);
+}
+
///
/// 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.
///
-internal sealed class WorldSelectionQuery
+internal sealed class WorldSelectionQuery : IWorldSelectionQuery
{
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
private const uint StuckObjectFlag = 0x0004u;
diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
new file mode 100644
index 00000000..c9fa956f
--- /dev/null
+++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
@@ -0,0 +1,323 @@
+using System.Numerics;
+using AcDream.App.Interaction;
+using AcDream.App.UI;
+using AcDream.App.World;
+using AcDream.Core.Items;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.Selection;
+using AcDream.Core.World;
+using AcDream.UI.Abstractions.Input;
+
+namespace AcDream.App.Tests.Interaction;
+
+public sealed class SelectionInteractionControllerTests
+{
+ private const uint Player = 0x5000_0001u;
+ private const uint Target = 0x7000_0001u;
+
+ private sealed class Query : IWorldSelectionQuery
+ {
+ public uint? Picked { get; set; }
+ public bool LastIncludeSelf { get; private set; }
+ public bool Current { get; set; } = true;
+ public bool Creature { get; set; }
+ public bool Hostile { get; set; }
+ public bool Useable { get; set; } = true;
+ public bool Pickupable { get; set; } = true;
+ public ClosestCombatTarget? Closest { get; set; }
+ public InteractionApproach? Approach { get; set; }
+ public List Events { get; } = new();
+
+ public uint? PickAtCursor(bool includeSelf)
+ {
+ LastIncludeSelf = includeSelf;
+ Events.Add("pick");
+ return Picked;
+ }
+
+ public uint? PickAt(float mouseX, float mouseY, bool includeSelf)
+ => PickAtCursor(includeSelf);
+
+ public void BeginLightingPulse(uint serverGuid) => Events.Add("pulse");
+ public bool IsCurrent(uint serverGuid, uint localEntityId) => Current;
+ public string Describe(uint serverGuid) => $"Target {serverGuid:X8}";
+ public bool IsCreature(uint serverGuid) => Creature;
+ public bool IsHostileMonster(uint serverGuid) => Hostile;
+ public ClosestCombatTarget? FindClosestHostileMonster() => Closest;
+ public bool IsUseable(uint serverGuid) => Useable;
+ public bool IsPickupable(uint serverGuid) => Pickupable;
+
+ public bool TryGetApproach(uint serverGuid, out InteractionApproach approach)
+ {
+ if (Approach is { } found)
+ {
+ approach = found;
+ return true;
+ }
+ approach = default;
+ return false;
+ }
+ }
+
+ private sealed class Transport : ISelectionInteractionTransport
+ {
+ private uint _sequence;
+ public bool IsInWorld { get; set; } = true;
+ public List Uses { get; } = new();
+ public List<(uint Item, uint Container, int Placement)> Pickups { get; } = new();
+
+ public bool TrySendUse(uint serverGuid, out uint sequence)
+ {
+ if (!IsInWorld)
+ {
+ sequence = 0u;
+ return false;
+ }
+ sequence = ++_sequence;
+ Uses.Add(serverGuid);
+ return true;
+ }
+
+ public bool TrySendPickup(
+ uint itemGuid,
+ uint destinationContainerId,
+ int placement,
+ out uint sequence)
+ {
+ if (!IsInWorld)
+ {
+ sequence = 0u;
+ return false;
+ }
+ sequence = ++_sequence;
+ Pickups.Add((itemGuid, destinationContainerId, placement));
+ return true;
+ }
+ }
+
+ private sealed class Movement : IPlayerInteractionMovementSink
+ {
+ public List Approaches { get; } = new();
+ public bool BeginApproach(InteractionApproach approach)
+ {
+ Approaches.Add(approach);
+ return true;
+ }
+ }
+
+ private sealed class Harness
+ {
+ public readonly Query Query = new();
+ public readonly Transport Transport = new();
+ public readonly Movement Movement = new();
+ public readonly SelectionState Selection = new();
+ public readonly ClientObjectTable Objects = new();
+ public readonly List Toasts = new();
+ public readonly List Examines = new();
+ public readonly ItemInteractionController Items;
+ public readonly SelectionInteractionController Controller;
+
+ public Harness()
+ {
+ SelectionInteractionController? controller = null;
+ Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = Player,
+ Type = ItemType.Creature,
+ });
+ Items = new ItemInteractionController(
+ Objects,
+ () => Player,
+ sendUse: guid => controller!.SendUse(guid),
+ sendUseWithTarget: null,
+ sendWield: null,
+ sendDrop: null,
+ sendExamine: guid =>
+ {
+ Query.Events.Add("examine");
+ Examines.Add(guid);
+ },
+ placeInBackpack: (item, container, placement) =>
+ controller!.SendPickup(item, container, placement));
+ Controller = controller = new SelectionInteractionController(
+ Selection,
+ Query,
+ Items,
+ Transport,
+ Movement,
+ Toasts.Add);
+ }
+
+ public void SetApproach(bool closeRange, uint localEntityId = 101u)
+ {
+ var entity = new WorldEntity
+ {
+ Id = localEntityId,
+ ServerGuid = Target,
+ SourceGfxObjOrSetupId = 0x0200_0001u,
+ Position = new Vector3(5f, 0f, 0f),
+ Rotation = Quaternion.Identity,
+ MeshRefs = [],
+ };
+ Query.Approach = new InteractionApproach(
+ new WorldInteractionTarget(Target, localEntityId, entity),
+ new PlayerInteractionPose(0x0101_0001u, Vector3.Zero),
+ 0.6f,
+ closeRange,
+ CanCharge: !closeRange,
+ TargetRadius: 0.5f,
+ TargetHeight: 2f);
+ }
+ }
+
+ [Fact]
+ public void TargetModeClickPulsesBeforeItIsConsumedAndIncludesSelf()
+ {
+ var h = new Harness();
+ h.Query.Picked = Target;
+ h.Items.InteractionState.EnterExamine();
+
+ Assert.True(h.Controller.HandleInputAction(InputAction.SelectLeft));
+
+ Assert.True(h.Query.LastIncludeSelf);
+ Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events);
+ Assert.Equal(new[] { Target }, h.Examines);
+ Assert.Null(h.Selection.SelectedObjectId);
+ }
+
+ [Fact]
+ public void ClosestTargetInputMutatesSelectionOnce()
+ {
+ var h = new Harness();
+ h.Query.Closest = new ClosestCombatTarget(Target, 25f);
+
+ Assert.True(h.Controller.HandleInputAction(InputAction.SelectionClosestMonster));
+
+ Assert.Equal(Target, h.Selection.SelectedObjectId);
+ Assert.Contains(h.Toasts, text => text.Contains("Target 70000001"));
+ }
+
+ [Fact]
+ public void CloseUseTurnsFirstThenSendsExactlyOnceOnNaturalCompletion()
+ {
+ var h = new Harness();
+ h.SetApproach(closeRange: true);
+
+ h.Controller.SendUse(Target);
+
+ Assert.Single(h.Movement.Approaches);
+ Assert.Empty(h.Transport.Uses);
+
+ h.Controller.OnNaturalMoveToComplete();
+ h.Controller.OnNaturalMoveToComplete();
+
+ Assert.Equal(new[] { Target }, h.Transport.Uses);
+ }
+
+ [Fact]
+ public void FarUseSendsImmediatelyAndNaturalCompletionDoesNotRetry()
+ {
+ var h = new Harness();
+ h.SetApproach(closeRange: false);
+
+ h.Controller.SendUse(Target);
+ h.Controller.OnNaturalMoveToComplete();
+
+ Assert.Single(h.Movement.Approaches);
+ Assert.Equal(new[] { Target }, h.Transport.Uses);
+ }
+
+ [Fact]
+ public void HiddenOrStaleIncarnationCancelsDeferredAction()
+ {
+ var hidden = new Harness();
+ hidden.SetApproach(closeRange: true);
+ hidden.Controller.SendUse(Target);
+ hidden.Controller.OnEntityHidden(Target);
+ hidden.Controller.OnNaturalMoveToComplete();
+ Assert.Empty(hidden.Transport.Uses);
+
+ var stale = new Harness();
+ stale.SetApproach(closeRange: true);
+ stale.Controller.SendUse(Target);
+ stale.Query.Current = false;
+ stale.Controller.OnNaturalMoveToComplete();
+ Assert.Empty(stale.Transport.Uses);
+ }
+
+ [Fact]
+ public void PickupInputWaitsForFrameDrainThenUsesPendingDestination()
+ {
+ var h = new Harness();
+ h.SetApproach(closeRange: false);
+ h.Selection.Select(Target, SelectionChangeSource.World);
+
+ Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp));
+ Assert.Empty(h.Transport.Pickups);
+
+ h.Controller.DrainOutbound();
+
+ Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups);
+ }
+
+ [Fact]
+ public void SessionResetClearsQueuedAndDeferredWork()
+ {
+ var h = new Harness();
+ h.SetApproach(closeRange: true);
+ h.Selection.Select(Target, SelectionChangeSource.World);
+ h.Controller.HandleInputAction(InputAction.SelectionPickUp);
+ h.Controller.SendUse(Target);
+
+ h.Controller.ResetSession();
+ h.Controller.DrainOutbound();
+ h.Controller.OnNaturalMoveToComplete();
+
+ Assert.Null(h.Selection.SelectedObjectId);
+ Assert.Empty(h.Transport.Pickups);
+ Assert.Empty(h.Transport.Uses);
+ }
+
+ [Fact]
+ public void OldRemovalClearsCapturedActionButDoesNotClearReplacementSelection()
+ {
+ var h = new Harness();
+ h.SetApproach(closeRange: true, localEntityId: 101u);
+ h.Selection.Select(Target, SelectionChangeSource.World);
+ h.Controller.SendUse(Target);
+ var oldRecord = new LiveEntityRecord(Spawn(Target, instance: 1));
+ oldRecord.WorldEntity = new WorldEntity
+ {
+ Id = 101u,
+ ServerGuid = Target,
+ SourceGfxObjOrSetupId = 0x0200_0001u,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = [],
+ };
+
+ h.Controller.OnEntityRemoved(oldRecord, replacementExists: true);
+ h.Controller.OnNaturalMoveToComplete();
+
+ Assert.Equal(Target, h.Selection.SelectedObjectId);
+ Assert.Empty(h.Transport.Uses);
+ }
+
+ private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
+ => new(
+ guid,
+ Position: null,
+ SetupTableId: null,
+ AnimPartChanges: [],
+ TextureChanges: [],
+ SubPalettes: [],
+ BasePaletteId: null,
+ ObjScale: null,
+ Name: null,
+ ItemType: null,
+ MotionState: null,
+ MotionTableId: null,
+ InstanceSequence: instance);
+}