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:
parent
e74f2ca99a
commit
fa8d5232cc
5 changed files with 754 additions and 1 deletions
57
src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
Normal file
57
src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
296
src/AcDream.App/Interaction/SelectionInteractionController.cs
Normal file
296
src/AcDream.App/Interaction/SelectionInteractionController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/AcDream.App/Interaction/SelectionInteractionTransport.cs
Normal file
62
src/AcDream.App/Interaction/SelectionInteractionTransport.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,12 +34,27 @@ internal readonly record struct InteractionApproach(
|
||||||
float TargetRadius,
|
float TargetRadius,
|
||||||
float TargetHeight);
|
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>
|
/// <summary>
|
||||||
/// Read-only owner for retail world selection and interaction classification.
|
/// Read-only owner for retail world selection and interaction classification.
|
||||||
/// It consumes the canonical live-entity runtime but never mutates selection,
|
/// It consumes the canonical live-entity runtime but never mutates selection,
|
||||||
/// movement, inventory, or network state.
|
/// movement, inventory, or network state.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class WorldSelectionQuery
|
internal sealed class WorldSelectionQuery : IWorldSelectionQuery
|
||||||
{
|
{
|
||||||
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
private const uint LargeUseObjectFlags = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||||
private const uint StuckObjectFlag = 0x0004u;
|
private const uint StuckObjectFlag = 0x0004u;
|
||||||
|
|
|
||||||
|
|
@ -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<string> 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<uint> 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<InteractionApproach> 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<string> Toasts = new();
|
||||||
|
public readonly List<uint> 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);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue