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