acdream/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
Erik 0ccbb4e52c fix(interaction): port retail's wielded-item pickup rejection (Slice 4 F1)
Slice 4 made a remote character's wielded weapon selectable, which made the
pickup chain reachable end to end for the first time: SelectionPickUp on
another player's weapon captured identity, passed ValidatePickupTarget (which
checked only the Stuck flag and the small-item mask, and a MeleeWeapon clears
both), installed a real non-autonomous approach through
PlayerInteractionMovementSink, and then sent a pickup request the server
rejects. Retail does none of that.

ItemHolder::AttemptToPlaceInContainer @ 0x00588140 runs
AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 first, at 0x00588173 --
ahead of container legality, auto-merge, the container walk, and the only
CM_Inventory::Event_PutItemInContainer emitter
(ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). IsItemLegal's arm at
0x005872B7 rejects `!ACCWeenieObject::IsOwnedByPlayer(item) &&
item->pwd._location != 0` with one local
ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...), and
CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 then withdraws the waiting slot it
had published (SetWaitingState(obj, 0) + SendNotice_EndPendingInPlayer at
0x0055D918). No request, no movement. acdream had never ported that arm; it
was harmless while wielded children were unpickable and stopped being harmless
at f6db964f.

The notice is data_7e2228, "The %s is being wielded by someone else!" -- WITH
the exclamation mark. IsItemLegal's six strings occupy one contiguous literal
block, 0x007e21f0 through 0x007e234c, one per arm in reverse code order, and
the two neighbours already ported here (0x007e227c "The %s cannot be picked
up!" at 0x00587264, 0x007e22b4 "You cannot pick up creatures!" at 0x005871f4)
pin it. The punctuation-free 0x007cd350 variant belongs to the wield/wear
block and is emitted from a different function at 0x00560aef.

pwd._location is the PublicWeenieDesc CurrentWieldedLocation field
(acclient.h:37175), which acdream projects as
ClientObject.CurrentlyEquippedLocation, and ACCWeenieObject::IsOwnedByPlayer
@ 0x0058D160 is IsOwnedByObject(this, player_id) -- already ported as
ClientObjectTable.IsOwnedByObject @ 0x0058CEB0 and reached here through the
existing ItemInteractionController.IsOwnedByPlayer. The arm reads pwd._location
verbatim rather than adding a WielderId belt-and-braces test, because retail's
predicate is the thing being ported.

The player's OWN wielded item is IsOwnedByPlayer, so retail passes it and takes
a different route. ACCWeenieObject::DeterminePositionState @ 0x0058BE70 gives
it PositionState.WIELDED (acclient.h:6802) rather than IN_3D_VIEW, and
UIAttemptPutInContainer records IR_PICK_UP only for IN_3D_VIEW, treating
WIELDED and IN_CONTAINER alike as a plain IR_PUT_IN_CONTAINER transfer. So an
own-wielded item is unwielded in place: the request goes out immediately with
no approach, joining the existing current-ground-object shortcut. The shortcut
carries an ownership conjunct so it can never outrun the 0x005872B7 gate.

TryGetApproach now refuses attached children outright, for the same
IN_3D_VIEW reason. An Attached projection's bookkeeping WorldEntity.Position
carries the PARENT's composed root (EquippedChildRenderController
.ApplyParentWorldPose), not the child frame CPhysicsObj::UpdateChild @
0x00512D50 composes, so an approach built from it walked toward the wielder.
Slice 4 de-parented the marker anchor but left this one parent-derived; no
approach can anchor on a wielder now.

The pick predicates are deliberately untouched. Picking, selecting, examining,
lighting-pulse identity, and the vivid-marker anchor on a remote's wielded
weapon all behave exactly as Slice 4 shipped them -- retail's sr_Select and
sr_Examine branches of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 never
consult IsItemLegal. The gate is the transaction, not the pick.

f6db964f's message asserted the slice introduced no deviation and owed no
retail-divergence-register row. That was wrong: the unported 0x005872B7 arm
was a deviation it made reachable. This commit ports the arm in full, matches
retail on the own-wielded path, and removes the parent-derived approach
anchor, so the record is corrected here and no register row is owed.

Gates: dotnet build green; AcDream.App.Tests 3,960 passed / 3 skipped;
complete Release solution 9,792 passed / 5 skipped;
tools\run-connected-world-lifecycle-gate.ps1 -SkipBuild RESULT=PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:09:38 +02:00

1058 lines
36 KiB
C#

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 Useable { get; set; } = true;
public bool Pickupable { get; set; } = true;
public bool WieldedByPlayer { get; set; }
/// <summary>
/// Guids whose <c>ACCWeenieObject::DeterminePositionState @
/// 0x0058BE70</c> result is <c>PositionState.WIELDED</c>. Ownership is
/// NOT faked — the controller resolves it through the real
/// <see cref="ClientObjectTable"/>.
/// </summary>
public HashSet<uint> 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<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 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 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<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(IPlayerApproachTokenSource approachTokens)
: IPlayerInteractionMovementSink
{
public List<InteractionApproach> Approaches { get; } = new();
public bool Starts { get; set; } = true;
public Action? AfterArm { get; set; }
public bool BeginApproach(
InteractionApproach approach,
Action<PlayerApproachToken>? 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<string> Toasts = new();
public readonly List<uint> Examines = new();
public readonly List<PendingBackpackPlacement> PendingPlacements = new();
public readonly List<PendingBackpackPlacement> 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);
}
[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);
}
[Fact]
public void FarUseSendsImmediatelyWithoutClientApproachAndDoesNotRetry()
{
var h = new Harness();
h.SetApproach(closeRange: false);
h.Controller.SendUse(Target);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Movement.Approaches);
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
[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);
}
/// <summary>
/// The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 guards
/// ItemHolder::UseObject with
/// <c>if (found-&gt;pwd._wielderID != SmartBox::player_id)</c> at
/// 0x004E5BE9. Selection and the click pulse still happen.
/// </summary>
[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);
}
/// <summary>
/// Registers a wielded weapon exactly as <c>ClientObjectTable.Ingest</c>
/// projects a CreateObject PublicWeenieDesc that carries Wielder plus
/// CurrentWieldedLocation. Ownership is resolved by the real
/// <c>ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0</c> port, not a fake.
/// </summary>
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);
}
/// <summary>
/// <c>ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0</c>
/// rejects <c>!IsOwnedByPlayer(item) &amp;&amp; item-&gt;pwd._location != 0</c>
/// at <c>0x005872B7</c> with the notice at <c>0x005872DB</c>, and it runs
/// at <c>0x00588173</c> before every other stage of
/// <c>AttemptToPlaceInContainer @ 0x00588140</c>. So: one local message,
/// no approach, no wire request, no waiting slot.
/// </summary>
[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);
}
/// <summary>
/// The same gate must hold on the direct
/// <c>CPlayerSystem::PlaceInBackpack @ 0x0055D8C0</c> entry point, whose
/// published waiting slot retail withdraws at <c>0x0055D918</c>
/// (<c>SetWaitingState(obj, 0)</c> + <c>SendNotice_EndPendingInPlayer</c>).
/// </summary>
[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));
}
/// <summary>
/// Slice 4 non-regression: the rejected item stays fully selectable and
/// examinable. Retail's <c>sr_Select</c>/<c>sr_Examine</c> branches of
/// <c>RecvNotice_SmartBoxObjectFound @ 0x004E5AD0</c> never consult
/// <c>IsItemLegal</c>.
/// </summary>
[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);
}
/// <summary>
/// Positive control: the identical ItemType lying in the world is not in
/// <c>PositionState.WIELDED</c> (<c>DeterminePositionState @ 0x0058BE70</c>
/// needs a nonzero <c>pwd._location</c>) and picks up normally.
/// </summary>
[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"));
}
/// <summary>
/// The player's OWN wielded item is <c>IsOwnedByPlayer</c>, so
/// <c>IsItemLegal</c>'s 0x005872B7 arm passes it. Its
/// <c>PositionState</c> is WIELDED rather than IN_3D_VIEW, so
/// <c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c> records
/// <c>IR_PUT_IN_CONTAINER</c> instead of <c>IR_PICK_UP</c>: the transfer
/// goes out immediately, with no world approach.
/// </summary>
[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"));
}
/// <summary>
/// The WIELDED no-approach shortcut must never outrun the legality gate:
/// ownership alone decides which of the two wielded items may transfer.
/// </summary>
[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 _));
}
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);
}