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.Runtime.Gameplay; 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 const uint GroundContainer = 0x7000_0010u; private const uint Wielder = 0x7000_0100u; private const uint RemoteWeapon = 0x7000_0101u; private const uint OwnWeapon = 0x7000_0102u; private const uint GroundWeapon = 0x7000_0103u; 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 Attackable { get; set; } public bool Useable { get; set; } = true; public bool Pickupable { get; set; } = true; public bool WieldedByPlayer { get; set; } /// /// Guids whose ACCWeenieObject::DeterminePositionState @ /// 0x0058BE70 result is PositionState.WIELDED. Ownership is /// NOT faked — the controller resolves it through the real /// . /// public HashSet WieldedPositionStates { get; } = new(); public bool CaptureIdentity { get; set; } = true; public uint LocalEntityId { get; set; } = 101u; 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 TryCaptureIdentity(uint serverGuid, out uint localEntityId) { localEntityId = LocalEntityId; return CaptureIdentity; } 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 bool IsAttackableTarget(uint serverGuid) => Attackable; public ClosestCombatTarget? FindClosestHostileMonster() => Closest; public bool IsUseable(uint serverGuid) => Useable; public bool IsPickupable(uint serverGuid) => Pickupable; public bool IsWieldedByPlayer(uint serverGuid) => WieldedByPlayer; public bool IsWieldedPositionState(uint serverGuid) => WieldedPositionStates.Contains(serverGuid); public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null; public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) { if (Approach is { } found) { approach = found; return true; } approach = default; return false; } } private sealed class Transport : IRuntimeInteractionTransport { 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(IPlayerApproachTokenSource approachTokens) : IPlayerInteractionMovementSink { public List Approaches { get; } = new(); public bool Starts { get; set; } = true; public Action? AfterArm { get; set; } public bool BeginApproach( InteractionApproach approach, Action? armAfterCancel = null) { Approaches.Add(approach); if (!Starts || !approachTokens.TryBeginApproach(out PlayerApproachToken token)) return false; armAfterCancel?.Invoke(token); AfterArm?.Invoke(); return true; } } private sealed class Harness { public readonly Query Query = new(); public readonly Transport Transport = new(); public readonly PlayerApproachCompletionState Completions = new(); public readonly IPlayerApproachCompletionSink CompletionLifetime; public readonly Movement Movement; public readonly SelectionState Selection = new(); public readonly ClientObjectTable Objects = new(); public readonly List Toasts = new(); public readonly List Examines = new(); public readonly List PendingPlacements = new(); public readonly List CancelledPlacements = new(); public readonly ItemInteractionController Items; public readonly SelectionInteractionController Controller; public uint GroundObjectId { get; set; } public Harness() { CompletionLifetime = Completions.BeginControllerLifetime(); Movement = new Movement(Completions); SelectionInteractionController? controller = null; Objects.AddOrUpdate(new ClientObject { ObjectId = Player, Type = ItemType.Creature, }); Objects.AddOrUpdate(new ClientObject { ObjectId = Target, Name = "Target", Type = ItemType.Creature, Useability = ItemUseability.Remote, PublicWeenieBitfield = (uint)PublicWeenieFlags.Stuck, }); Items = new ItemInteractionController( Objects, new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)), new InteractionState(), () => Player, sendUse: null, sendUseWithTarget: null, sendWield: null, sendDrop: null, sendExamine: guid => { Query.Events.Add("examine"); Examines.Add(guid); }, groundObjectId: () => GroundObjectId, placeInBackpack: (item, container, placement) => controller!.SendPickup(item, container, placement), requestUse: (guid, reservation) => controller!.RequestUse(guid, reservation)); Controller = controller = new SelectionInteractionController( Selection, Query, Items, Transport, Movement, Toasts.Add, Completions); Items.PendingBackpackPlacementRequested += PendingPlacements.Add; Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add; } public void SetApproach( bool closeRange, uint localEntityId = 101u, uint serverGuid = Target) { var entity = new WorldEntity { Id = localEntityId, ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x0200_0001u, Position = new Vector3(5f, 0f, 0f), Rotation = Quaternion.Identity, MeshRefs = [], }; Query.Approach = new InteractionApproach( new WorldInteractionTarget(serverGuid, 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 RightClickPulsesSelectsAndExaminesPickedWorldObject() { var h = new Harness(); h.Query.Picked = Target; Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight)); Assert.False(h.Query.LastIncludeSelf); Assert.Equal(Target, h.Selection.SelectedObjectId); Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events); Assert.Equal(new[] { Target }, h.Examines); } [Fact] public void RightClickEmptyWorldIsANoOp() { var h = new Harness(); Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight)); Assert.Equal(new[] { "pick" }, h.Query.Events); Assert.Null(h.Selection.SelectedObjectId); Assert.Empty(h.Examines); Assert.Empty(h.Toasts); } [Fact] public void RightClickExaminesWithoutConsumingLeftClickTargetMode() { var h = new Harness(); h.Query.Picked = Target; h.Items.InteractionState.EnterUse(); h.Controller.HandleInputAction(InputAction.SelectRight); Assert.Equal(InteractionModeKind.Use, h.Items.InteractionState.Current.Kind); Assert.Equal(Target, h.Selection.SelectedObjectId); Assert.Equal(new[] { Target }, h.Examines); } [Fact] public void ExamineActionUsesSelectionOrEntersTargetMode() { var selected = new Harness(); selected.Selection.Select(Target, SelectionChangeSource.World); Assert.True(selected.Controller.HandleInputAction( InputAction.SelectionExamine)); Assert.Equal(new[] { Target }, selected.Examines); var empty = new Harness(); Assert.True(empty.Controller.HandleInputAction( InputAction.SelectionExamine)); Assert.Equal( InteractionModeKind.Examine, empty.Items.InteractionState.Current.Kind); } [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 CloseUseSendsImmediatelyWithoutSpeculativeMovement() { var h = new Harness(); h.SetApproach(closeRange: true); h.Controller.SendUse(Target); Assert.Empty(h.Movement.Approaches); Assert.Equal(new[] { Target }, h.Transport.Uses); h.Controller.OnNaturalMoveToComplete(); h.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, h.Transport.Uses); } [Fact] public void AcceptedUseRemainsBusyUntilAuthoritativeUseDone() { var h = new Harness(); h.SetApproach(closeRange: true); h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); Assert.Equal(1, h.Items.BusyCount); Assert.Equal(new[] { Target }, h.Transport.Uses); h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled); h.Controller.DrainOutbound(); Assert.Equal(1, h.Items.BusyCount); Assert.False(h.Items.CanMakeInventoryRequest); h.Items.CompleteUse(0u); Assert.Equal(0, h.Items.BusyCount); Assert.True(h.Items.CanMakeInventoryRequest); } [Fact] public void DispatchedWorldUseRemainsBusyUntilAuthoritativeUseDone() { var h = new Harness(); h.SetApproach(closeRange: true); h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); Assert.Equal(new[] { Target }, h.Transport.Uses); Assert.Equal(1, h.Items.BusyCount); h.Items.CompleteUse(0u); Assert.Equal(0, h.Items.BusyCount); Assert.True(h.Items.CanMakeInventoryRequest); } [Fact] public void WorldUseDoesNotDependOnLocalApproachInstallation() { var h = new Harness(); h.SetApproach(closeRange: true); h.Movement.Starts = false; h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); Assert.Equal(1, h.Items.BusyCount); Assert.Equal(new[] { Target }, h.Transport.Uses); Assert.Empty(h.Movement.Approaches); } [Fact] public void RejectedWorldUseReleasesItsBusyReservation() { var h = new Harness(); h.Query.Useable = false; h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); Assert.Equal(0, h.Items.BusyCount); Assert.Empty(h.Transport.Uses); } /// /// F11 (Slice 6b/6c review): a FAR target the eligibility gate will /// refuse (not owned by the player, not useable) must never kick off a /// speculative local approach at all — the prior RequestUse /// ordering called BeginApproach before the eligibility check, /// so an out-of-range unusable target still walked the player toward /// it even though the dispatch immediately below was always going to /// reject. The existing /// test never configured an approach at all (TryGetApproach /// returns false unconditionally), so it could not have caught this — /// this test explicitly combines "far" with "rejected." /// [Fact] public void FarUnusableTargetIsRejectedWithoutApproaching() { var h = new Harness(); h.Query.Useable = false; h.SetApproach(closeRange: false); h.Controller.SendUse(Target); Assert.Empty(h.Movement.Approaches); Assert.Empty(h.Transport.Uses); } [Fact] public void SynchronousMovementCallbackCannotDuplicateWorldUse() { var h = new Harness(); h.SetApproach(closeRange: true); h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete; h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); Assert.Equal(new[] { Target }, h.Transport.Uses); Assert.Equal(1, h.Items.BusyCount); h.Items.CompleteUse(0u); Assert.Equal(0, h.Items.BusyCount); } /// /// C1 (Slice 6b move-to-use, docs/research/2026-08-08-slice6b-vendor- /// completion-research.md Q2, REVISED for G3 — grand-gate finding /// 2026-08-08, register AP-170): an out-of-range Use kicks off the SAME /// local client-predicted MoveToObject approach Pickup's close-range /// branch already arms on, giving the walk immediate visual feel. Unlike /// the original (send-immediately) design, the wire send is now GATED on /// natural arrival — live testing against the user's local ACE server /// showed retail's own "no range check, send immediately" assumption /// does not hold there: ACE's Player.HandleActionUseItem polls /// for the player to actually reach use range before calling /// ActOnUse, and a Use that arrives too early is silently lost /// (the vendor's cosmetic greeting fires — a distance-only reaction /// independent of Use — but ApproachVendor never comes). Nothing /// dispatches until /// fires; a SECOND natural-completion call must not re-dispatch (the /// pending-use token is consumed on first resolve, exactly like Pickup's /// pending-pickup token). /// [Fact] public void FarUseApproachesThenDispatchesOnNaturalArrival() { var h = new Harness(); h.SetApproach(closeRange: false); h.Controller.SendUse(Target); PlayerInteractionMovementSinkAssertSingleApproach(h, Target); // Armed, not yet sent — the whole point of the fix. Assert.Empty(h.Transport.Uses); h.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, h.Transport.Uses); h.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, h.Transport.Uses); } /// /// C1/G3 cancellation coverage: a second far Use command (the player /// picked a new target, i.e. "moved on") supersedes the first local /// approach cleanly — the FIRST target's armed Use is cancelled (never /// sent), and only the SECOND dispatches, on ITS OWN natural arrival. /// [Fact] public void NewFarUseCommandSupersedesThePreviousApproachCleanly() { const uint otherTarget = 0x7000_0099u; var h = new Harness(); h.Objects.AddOrUpdate(new ClientObject { ObjectId = otherTarget, Name = "Other", Type = ItemType.Creature, Useability = ItemUseability.Remote, }); h.SetApproach(closeRange: false); h.Controller.SendUse(Target); h.SetApproach(closeRange: false, serverGuid: otherTarget); h.Controller.SendUse(otherTarget); Assert.Equal(2, h.Movement.Approaches.Count); Assert.Equal(Target, h.Movement.Approaches[0].Target.ServerGuid); Assert.Equal(otherTarget, h.Movement.Approaches[1].Target.ServerGuid); // Neither has sent yet — both are armed/superseded, not dispatched. Assert.Empty(h.Transport.Uses); h.Controller.OnNaturalMoveToComplete(); // Only the surviving (second) approach's Use goes out. Assert.Equal(new[] { otherTarget }, h.Transport.Uses); } /// /// C1/G3 cancellation coverage: the underlying MoveTo controller /// cancelling out from under a far Use's local approach (player moved /// away with WASD, or any other source of ) /// must cancel the ARMED (not-yet-sent) Use — retail's own server-side /// poll would never have seen the player arrive either, so nothing /// should reach the wire. /// [Fact] public void MovingAwayDuringAFarUseApproachCancelsTheArmedUse() { var h = new Harness(); h.SetApproach(closeRange: false); h.Controller.SendUse(Target); h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled); h.Controller.OnNaturalMoveToComplete(); Assert.Empty(h.Transport.Uses); } private static void PlayerInteractionMovementSinkAssertSingleApproach( Harness h, uint expectedTarget) { InteractionApproach approach = Assert.Single(h.Movement.Approaches); Assert.Equal(expectedTarget, approach.Target.ServerGuid); } [Fact] public void CarriedDirectUseBypassesWorldApproachAndWaitsForUseDone() { const uint favor = 0x8000_0606u; var h = new Harness(); h.Query.Useable = false; h.Objects.AddOrUpdate(new ClientObject { ObjectId = favor, Name = "Blackmoor's Favor", Type = ItemType.Gem, Useability = ItemUseability.Undef, }); Assert.True(h.Objects.MoveItem(favor, Player, newSlot: 1)); Assert.True(h.Items.ActivateItem(favor)); Assert.Empty(h.Movement.Approaches); Assert.Equal(new[] { favor }, h.Transport.Uses); Assert.Equal(1, h.Items.BusyCount); h.Items.CompleteUse(0u); Assert.Equal(0, h.Items.BusyCount); Assert.True(h.Items.CanMakeInventoryRequest); } [Fact] public void HiddenOrStaleNotificationsCannotRetractOrDuplicateDispatchedUse() { var hidden = new Harness(); hidden.SetApproach(closeRange: true); hidden.Controller.SendUse(Target); hidden.Controller.OnEntityHidden(Target); hidden.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, hidden.Transport.Uses); var stale = new Harness(); stale.SetApproach(closeRange: true); stale.Controller.SendUse(Target); stale.Query.Current = false; stale.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, stale.Transport.Uses); } [Fact] public void HiddenTargetCancelsQueuedUseBeforeFrameDrain() { var h = new Harness(); h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.OnEntityHidden(Target); h.Controller.DrainOutbound(); Assert.Null(h.Selection.SelectedObjectId); Assert.Empty(h.Transport.Uses); Assert.Equal(0, h.Items.BusyCount); } [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 CorpseChildDoubleClickDispatchesWithoutAWorldProjection() { const uint loot = 0x7000_0011u; var h = new Harness { GroundObjectId = GroundContainer, }; h.Objects.AddOrUpdate(new ClientObject { ObjectId = loot, Name = "Corpse loot", Type = ItemType.Misc, ContainerId = GroundContainer, }); h.Query.Pickupable = false; h.Query.Approach = null; Assert.True(h.Items.ActivateItem(loot)); Assert.Empty(h.Movement.Approaches); Assert.Equal(new[] { (loot, Player, 0) }, h.Transport.Pickups); Assert.Single(h.PendingPlacements); Assert.Empty(h.CancelledPlacements); } [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.HandleInputAction(InputAction.UseSelected); h.Items.InteractionState.EnterExamine(); h.Controller.ResetSession(); h.Controller.DrainOutbound(); h.Controller.OnNaturalMoveToComplete(); Assert.Null(h.Selection.SelectedObjectId); Assert.False(h.Items.IsAnyTargetModeActive); Assert.Empty(h.Transport.Pickups); Assert.Empty(h.Transport.Uses); } [Fact] public void OldRemovalClearsCapturedPickupButDoesNotClearReplacementSelection() { var h = new Harness(); var oldRecord = LiveEntityTestFixture.CreateExactProjectionRecord( Spawn(Target, instance: 1)); uint localEntityId = oldRecord.LocalEntityId!.Value; h.SetApproach(closeRange: true, localEntityId: localEntityId); h.Selection.Select(Target, SelectionChangeSource.World); Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); oldRecord.WorldEntity = new WorldEntity { Id = localEntityId, 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.Pickups); Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); } [Fact] public void SynchronousNaturalCompletionCannotDuplicateUse() { var h = new Harness(); h.SetApproach(closeRange: true); h.Movement.AfterArm = h.Controller.OnNaturalMoveToComplete; h.Controller.SendUse(Target); h.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, h.Transport.Uses); } [Fact] public void SynchronousResetCannotResurrectACancelledPickup() { var h = new Harness(); h.SetApproach(closeRange: true); h.Movement.AfterArm = h.Controller.ResetSession; Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); h.Controller.OnNaturalMoveToComplete(); Assert.Empty(h.Transport.Pickups); } [Fact] public void CancelledClosePickupWithdrawsPresentationAndNeverFiresLater() { var h = new Harness(); h.SetApproach(closeRange: true); Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); Assert.Single(h.PendingPlacements); Assert.Empty(h.Transport.Pickups); h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled); h.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); Assert.Empty(h.Transport.Pickups); } [Fact] public void ErrorCompletionCannotRetractOrDuplicateDispatchedUse() { var h = new Harness(); h.SetApproach(closeRange: true); h.Controller.SendUse(Target); h.Controller.OnMoveToCancelled(WeenieError.NoObject); h.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, h.Transport.Uses); } [Fact] public void CompletionFromPriorPickupCannotAffectReplacement() { var h = new Harness(); h.SetApproach(closeRange: true); Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); h.CompletionLifetime.PublishCancellation(WeenieError.ActionCancelled); h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.UseSelected); h.Controller.DrainOutbound(); Assert.Equal(new[] { Target }, h.Transport.Uses); Assert.Empty(h.Transport.Pickups); h.CompletionLifetime.PublishNaturalCompletion(); h.Controller.DrainOutbound(); Assert.Equal(new[] { Target }, h.Transport.Uses); Assert.Empty(h.Transport.Pickups); } [Fact] public void RetiringControllerLifetimeInvalidatesQueuedPickupArrival() { var h = new Harness(); h.SetApproach(closeRange: true); Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); h.CompletionLifetime.PublishNaturalCompletion(); h.Completions.RetireControllerLifetime(h.CompletionLifetime); h.Controller.DrainOutbound(); Assert.Empty(h.Transport.Pickups); h.Controller.OnNaturalMoveToComplete(); Assert.Empty(h.Transport.Pickups); } [Fact] public void FailedClosePickupStartWithdrawsPresentation() { var h = new Harness(); h.SetApproach(closeRange: true); h.Movement.Starts = false; Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); Assert.Single(h.PendingPlacements); Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); Assert.Empty(h.Transport.Pickups); } [Theory] [InlineData(false)] [InlineData(true)] public void InvalidKeyboardPickupNeverPublishesAWaitingSlot(bool creature) { var h = new Harness(); h.Query.Creature = creature; h.Query.Pickupable = false; h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.SelectionPickUp); h.Controller.DrainOutbound(); Assert.Empty(h.PendingPlacements); Assert.Empty(h.Transport.Pickups); } [Theory] [InlineData(InputAction.SelectDblLeft)] [InlineData(InputAction.UseSelected)] [InlineData(InputAction.SelectionPickUp)] public void QueuedWorldActionCannotCrossAnIncarnationBoundary(InputAction action) { var h = new Harness(); h.Query.Picked = Target; h.SetApproach(closeRange: false); h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(action); h.Query.Current = false; h.Controller.DrainOutbound(); Assert.Empty(h.Transport.Uses); Assert.Empty(h.Transport.Pickups); Assert.Empty(h.PendingPlacements); } /// /// The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 guards /// ItemHolder::UseObject with /// if (found->pwd._wielderID != SmartBox::player_id) at /// 0x004E5BE9. Selection and the click pulse still happen. /// [Fact] public void DoubleClickOnTheOwnWieldedObjectSelectsAndPulsesWithoutUse() { var control = new Harness(); control.Query.Picked = Target; control.Controller.HandleInputAction(InputAction.SelectDblLeft); control.Controller.DrainOutbound(); Assert.NotEmpty(control.Transport.Uses); var h = new Harness(); h.Query.Picked = Target; h.Query.WieldedByPlayer = true; h.Controller.HandleInputAction(InputAction.SelectDblLeft); h.Controller.DrainOutbound(); Assert.Equal(Target, h.Selection.SelectedObjectId); Assert.Contains("pulse", h.Query.Events); Assert.Empty(h.Transport.Uses); } /// /// Registers a wielded weapon exactly as ClientObjectTable.Ingest /// projects a CreateObject PublicWeenieDesc that carries Wielder plus /// CurrentWieldedLocation. Ownership is resolved by the real /// ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0 port, not a fake. /// private static void AddWieldedWeapon(Harness h, uint guid, uint wielderId) { h.Objects.AddOrUpdate(new ClientObject { ObjectId = guid, Name = $"Weapon {guid:X8}", Type = ItemType.MeleeWeapon, WielderId = wielderId, CurrentlyEquippedLocation = EquipMask.MeleeWeapon, }); h.Query.WieldedPositionStates.Add(guid); } /// /// ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 /// rejects !IsOwnedByPlayer(item) && item->pwd._location != 0 /// at 0x005872B7 with the notice at 0x005872DB, and it runs /// at 0x00588173 before every other stage of /// AttemptToPlaceInContainer @ 0x00588140. So: one local message, /// no approach, no wire request, no waiting slot. /// [Fact] public void PickupOfARemotesWieldedItemIsRejectedLocallyWithNoMovementOrRequest() { var h = new Harness(); h.Objects.AddOrUpdate(new ClientObject { ObjectId = Wielder, Name = "Remote", Type = ItemType.Creature, }); AddWieldedWeapon(h, RemoteWeapon, wielderId: Wielder); h.SetApproach(closeRange: false, serverGuid: RemoteWeapon); h.Selection.Select(RemoteWeapon, SelectionChangeSource.World); Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp)); h.Controller.DrainOutbound(); Assert.Contains( // Query.Describe is the fake's ACCWeenieObject::GetObjectNameWide. $"The Target {RemoteWeapon:X8} is being wielded by someone else!", h.Toasts); Assert.Empty(h.Transport.Pickups); Assert.Empty(h.Transport.Uses); Assert.Empty(h.Movement.Approaches); Assert.Empty(h.PendingPlacements); } /// /// The same gate must hold on the direct /// CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 entry point, whose /// published waiting slot retail withdraws at 0x0055D918 /// (SetWaitingState(obj, 0) + SendNotice_EndPendingInPlayer). /// [Fact] public void DirectBackpackPlacementOfARemotesWieldedItemWithdrawsItsWaitingSlot() { var h = new Harness(); AddWieldedWeapon(h, RemoteWeapon, wielderId: Wielder); h.SetApproach(closeRange: true, serverGuid: RemoteWeapon); Assert.True(h.Items.PlaceWorldItemInBackpack(RemoteWeapon)); Assert.Contains( // Query.Describe is the fake's ACCWeenieObject::GetObjectNameWide. $"The Target {RemoteWeapon:X8} is being wielded by someone else!", h.Toasts); Assert.Empty(h.Transport.Pickups); Assert.Empty(h.Movement.Approaches); Assert.Single(h.PendingPlacements); Assert.Equal( new[] { RemoteWeapon }, h.CancelledPlacements.Select(p => p.ItemId)); } /// /// Slice 4 non-regression: the rejected item stays fully selectable and /// examinable. Retail's sr_Select/sr_Examine branches of /// RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 never consult /// IsItemLegal. /// [Fact] public void ARemotesWieldedItemStaysSelectableAndExaminable() { var h = new Harness(); AddWieldedWeapon(h, RemoteWeapon, wielderId: Wielder); h.Query.Picked = RemoteWeapon; Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight)); Assert.Equal(RemoteWeapon, h.Selection.SelectedObjectId); Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events); Assert.Equal(new[] { RemoteWeapon }, h.Examines); } /// /// Positive control: the identical ItemType lying in the world is not in /// PositionState.WIELDED (DeterminePositionState @ 0x0058BE70 /// needs a nonzero pwd._location) and picks up normally. /// [Fact] public void AGroundItemOfTheSameTypeStillPicksUpNormally() { var h = new Harness(); h.Objects.AddOrUpdate(new ClientObject { ObjectId = GroundWeapon, Name = "Ground weapon", Type = ItemType.MeleeWeapon, }); h.SetApproach(closeRange: false, serverGuid: GroundWeapon); h.Selection.Select(GroundWeapon, SelectionChangeSource.World); Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp)); h.Controller.DrainOutbound(); Assert.Equal(new[] { (GroundWeapon, Player, 0) }, h.Transport.Pickups); Assert.Single(h.Movement.Approaches); Assert.Empty(h.CancelledPlacements); Assert.DoesNotContain(h.Toasts, text => text.Contains("wielded")); } /// /// The player's OWN wielded item is IsOwnedByPlayer, so /// IsItemLegal's 0x005872B7 arm passes it. Its /// PositionState is WIELDED rather than IN_3D_VIEW, so /// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records /// IR_PUT_IN_CONTAINER instead of IR_PICK_UP: the transfer /// goes out immediately, with no world approach. /// [Fact] public void PickupOfThePlayersOwnWieldedItemTransfersImmediatelyWithoutApproach() { var h = new Harness(); AddWieldedWeapon(h, OwnWeapon, wielderId: Player); h.SetApproach(closeRange: true, serverGuid: OwnWeapon); h.Selection.Select(OwnWeapon, SelectionChangeSource.World); Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp)); h.Controller.DrainOutbound(); Assert.Equal(new[] { (OwnWeapon, Player, 0) }, h.Transport.Pickups); Assert.Empty(h.Movement.Approaches); Assert.Empty(h.CancelledPlacements); Assert.DoesNotContain(h.Toasts, text => text.Contains("wielded")); } /// /// The WIELDED no-approach shortcut must never outrun the legality gate: /// ownership alone decides which of the two wielded items may transfer. /// [Fact] public void TheWieldedShortcutIsGatedOnOwnershipNotOnWieldedStateAlone() { var h = new Harness(); AddWieldedWeapon(h, RemoteWeapon, wielderId: Wielder); AddWieldedWeapon(h, OwnWeapon, wielderId: Player); h.SetApproach(closeRange: true, serverGuid: RemoteWeapon); Assert.True(h.Items.PlaceWorldItemInBackpack(RemoteWeapon)); Assert.Empty(h.Transport.Pickups); h.SetApproach(closeRange: true, serverGuid: OwnWeapon); Assert.True(h.Items.PlaceWorldItemInBackpack(OwnWeapon)); Assert.Equal(new[] { (OwnWeapon, Player, 0) }, h.Transport.Pickups); Assert.Empty(h.Movement.Approaches); } [Fact] public void DragReleasePulsesTheDropTargetWithoutChangingSelection() { var h = new Harness(); h.Query.Picked = Target; const uint item = 0x7000_0002u; h.Objects.AddOrUpdate(new ClientObject { ObjectId = item, Name = "Dragged", Type = ItemType.Misc, }); var payload = new ItemDragPayload( item, ItemDragSource.Inventory, SourceSlot: 0, new UiItemSlot()); h.Controller.PlaceDraggedItem(payload, 10f, 20f); Assert.Contains("pulse", h.Query.Events); Assert.Null(h.Selection.SelectedObjectId); } [Fact] public void RejectedTargetModeClickConsumesTheFollowingDoubleClick() { var h = new Harness(); h.Query.Picked = Target; h.Objects.Remove(Target); h.Items.InteractionState.EnterUse(); h.Controller.HandleInputAction(InputAction.SelectLeft); h.Controller.HandleInputAction(InputAction.SelectDblLeft); h.Controller.DrainOutbound(); Assert.Null(h.Selection.SelectedObjectId); Assert.Empty(h.Transport.Uses); Assert.Empty(h.Transport.Pickups); } [Fact] public void TransportLossAtClosePickupCompletionWithdrawsPresentation() { var h = new Harness(); h.SetApproach(closeRange: true); Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); h.Transport.IsInWorld = false; h.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { Target }, h.CancelledPlacements.Select(p => p.ItemId)); Assert.Empty(h.Transport.Pickups); } [Fact] public void RepeatedPickupKeepsTheOriginalReservationAndDeferredAction() { var h = new Harness(); h.SetApproach(closeRange: true); Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); h.Selection.Select(Target, SelectionChangeSource.World); h.Controller.HandleInputAction(InputAction.SelectionPickUp); h.Controller.DrainOutbound(); Assert.Single(h.PendingPlacements); Assert.Empty(h.CancelledPlacements); h.Controller.OnNaturalMoveToComplete(); Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups); } [Fact] public void WithdrawnPickupReservationInvalidatesDeferredWireAction() { const uint replacement = 0x7000_0002u; var h = new Harness(); h.SetApproach(closeRange: true); h.Objects.AddOrUpdate(new ClientObject { ObjectId = replacement, Name = "Replacement loot", Type = ItemType.Misc, }); Assert.True(h.Items.PlaceWorldItemInBackpack(Target)); Assert.Empty(h.Transport.Pickups); PendingBackpackPlacement original = Assert.Single(h.PendingPlacements); h.Items.CancelPendingBackpackPlacement(Target, original.Token); Assert.True(h.Items.TryBeginPendingBackpackPlacement( replacement, Player, placement: 0, out _)); h.Controller.OnNaturalMoveToComplete(); Assert.Empty(h.Transport.Pickups); Assert.True(h.Items.TryGetPendingBackpackPlacement(replacement, out _)); Assert.False(h.Items.TryGetPendingBackpackPlacement(Target, out _)); } /// /// #298: explicit-target admission routes through the wider /// IsAttackableTarget query (retail ObjectIsAttackable, /// which admits a compatible-PK player), not the narrower /// IsHostileMonster that auto-acquisition uses. /// [Fact] public void GetSelectedOrClosestCombatTargetAcceptsExplicitAttackableEvenWhenNotHostileMonster() { var h = new Harness(); h.Query.Attackable = true; h.Query.Hostile = false; h.Selection.Select(Target, SelectionChangeSource.Keyboard); Assert.Equal( Target, h.Controller.GetSelectedOrClosestCombatTarget(autoTarget: false)); } /// /// #298 / IA-19 guard: when the explicit selection is not an admitted /// attack target, auto-acquisition falls back to the narrower /// FindClosestHostileMonster query unchanged. /// [Fact] public void GetSelectedOrClosestCombatTargetFallsBackToAutoAcquisitionWhenExplicitTargetIsNotAttackable() { var h = new Harness(); const uint monster = 0x7000_0099u; h.Query.Attackable = false; h.Query.Closest = new ClosestCombatTarget(monster, DistanceSquared: 4f); h.Selection.Select(Target, SelectionChangeSource.Keyboard); Assert.Equal( monster, h.Controller.GetSelectedOrClosestCombatTarget(autoTarget: true)); } 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); }