A click on a remote character's wielded weapon reported nothing. The picker was already correct: RetailSelectionScene publishes every drawn part under its own live-entity server GUID and RetailWorldPicker returns the weapon as the polygon winner. The failure was downstream eligibility - WorldSelectionQuery required TryGetInteractionEligibleRecord, whose _visible set admits LiveEntityProjectionKind.World only, so the winning hit was discarded. Retail has no such gate. Render::GfxObjUnderSelectionRay @ 0x0054C740 accumulates each hit under the drawn part's own physics-object id (CPhysicsPart::get_physobj_id @ 0x0050D490), and CPhysicsPart::Draw @ 0x0050D7A0 admits any drawn part whose physobj id is nonzero. An equipped item is a first-class CPhysicsObj with its own id and part array (CPhysicsObj::add_child @ 0x0050F870 via CSetup::GetHoldingLocation @ 0x005213F0). There is no parent redirection and no wielded-specific rule, so a click on a wielded weapon returns THE WEAPON'S GUID. PositionState.WIELDED is distinct from IN_CONTAINER (acclient.h:6802), so container suppression never hid a wielded selection either. LiveEntityRuntime gains two scoped predicates: TryGetAttachedProjectedRecord (a current Attached projection that is spatially projected) and TryGetPickEligibleRecord (that arm plus today's World visible-set arm, with the same WorldEntity.Id staleness recheck). TryGetInteractionEligibleRecord and the _visible set are deliberately NOT widened - they feed radar, auto-target, sticky/MoveTo establishment, and CombatAttackTargetSource, and retail's radar has no wielded blips. A regression test asserts an attached child stays out of that set while picking admits it. Marker anchoring had the twin problem. SmartBox::GetObjectBoundingBox @ 0x00452E20 pushes the picked object's OWN m_position - which for a child is the frame CPhysicsObj::UpdateChild @ 0x00512D50 recomposes each tick as Frame::combine(parent part frame, holding frame) - and CPartArray::GetSelectionSphere @ 0x00518B80 scales the authored sphere by that object's own part-array scale. acdream stores the PARENT's root in the child projection's Position/Rotation because the child's MeshRefs are parent-relative, which put the vivid brackets at the wielder's feet. The composed child root is already published per frame to EntityEffectPoseRegistry by EquippedChildRenderController.PublishChildPose, so selection now borrows it through an injected Func<uint, Matrix4x4?> wired in LivePresentationComposition beside the existing selection-sphere hook. There is no parent fallback: a child with no published composed root has no live frame this tick and no sphere. Its part-array scale comes from the spawn record, the same source EquippedChildRenderController.TryRealize reads, because an Attached WorldEntity carries the parent-derived pose rather than its own ObjScale. The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 guards ItemHolder::UseObject with `found->pwd._wielderID != SmartBox::player_id` at 0x004E5BE9 while still selecting and flashing. Equipped-child picking makes that click reachable, so the gate ships with it as IWorldSelectionQuery.IsWieldedByPlayer. CPhysicsObj::SetLighting @ 0x00511A80 is non-recursive, so the pulse lights the clicked object's own part array only - clicking a weapon never flashes its wielder. That follows from routing the pulse identity through the same predicate. RetailWorldPicker, RetailSelectionScene, WbDrawDispatcher, and EquippedChildRenderController are untouched, as are all wire and physics paths. The slice REMOVES an undocumented deviation (Attached projections excluded from pick eligibility versus retail's part-id pick) and introduces none, so no retail-divergence-register row is owed in either direction. Gates: dotnet build green; AcDream.App.Tests 3,951 passed / 3 skipped; complete Release solution 9,783 passed / 5 skipped; tools\run-connected-world-lifecycle-gate.ps1 RESULT=PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
867 lines
29 KiB
C#
867 lines
29 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 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; }
|
|
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 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)
|
|
{
|
|
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 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->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);
|
|
}
|
|
|
|
[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);
|
|
}
|