One user-ordered batch across the FA social panel + world selection.
Every root cause was probe-proven before the fix (new
ProbeSocialClickRouting in SocialPanelLiveMountProbeTests - production
window mount + real UiRoot hit-tests + a synthetic click):
1. STUCK CHECKBOXES (fellowship x4, allegiance x1, "always checked /
can't change any options"): the authored checkboxes carry DAT
ToggleBehavior, so UiButton SELF-FLIPS Selected at MouseUp - the old
handlers read the flipped value and wrote the ORIGINAL back, snapping
every click to where it started (the probe recorded (id, oldValue)).
Fix: SuppressSelfToggle (the CH6a/b mirror discipline) + derive the
next value from the STORE; the per-tick seeding mirrors it back.
2. UNCLICKABLE ROSTER ROWS ("only get the move window cursor"): the row
name text is display-text ClickThrough=true, which the hit-test walk
skips regardless of HandlesClick - the wired OnClick was unreachable.
Fix: UiText.OnClick assignment now clears ClickThrough (central,
documented); the stats text gains the same select handler so most of
the row's width selects the fellow.
3. TRUNCATED EMPTY-STATE ("You do not belong... To create MISSING"):
the authored string resolves COMPLETE (three sentences) but embedded
'\n's rendered as one clipped line. DatWidgetFactory now splits
authored strings into one Line per newline, with the provider still
re-reading DefaultColor live (the state-color contract - caught by
BuildText_AuthoredLineTracksStateFontColor).
4. FELLOW NAMES WHITE (user-directed): the AD-82 invented leader-gold +
selection-blue tints are deleted; names always white (register row
narrowed).
5. ALLEGIANCE HEADER LABELS: bare "0"/"0" -> "Followers: N" / "Rank: [N]"
(user-specified format; the full retail StringInfo composition stays
AD-85's gap), monarch block matching.
6. FRIENDS/SQUELCH LIVE (AD-79 mostly retired): Add friend (name box ->
0x0018, retail clears the box - Request_AddFriend @0x0048D240),
Remove (row-click selection -> 0x0017), Appear Offline (CharacterOption
0x27 via the immediate 0x0005 auto-save, ACE pushes FriendStatusChanged
to your friend-of list), Squelch Character/Account add-by-name
(0x0058 guid0/type AllChannels + 0x0059) and Remove for the selected
row. The wire beneath (builders, WorldSession sends, Runtime commands,
parsers) existed end-to-end since J4.1/FA1 - this is panel wiring only
(docs/research/2026-08-13-social-wire-completion.md, committed here).
Send Tell stays inert (not in the order; AD-79's remainder).
7. WORLD SELF-SELECTION ("clicking my own char should select myself"):
retail has NO self-exclusion (CPhysicsPart::Draw @0x0050D823 arms
every physobj; RecvNotice_SmartBoxObjectFound @0x004E5BAE selects
unconditionally) - the includeSelf gate was an unregistered
divergence, now removed on both the left-click and right-click paths.
Element roles were probe-measured, never guessed (Add 0x10000514 /
Remove 0x10000515 / Send Tell 0x10000516 / Appear Offline 0x1000052C /
name field 0x1000051B; Squelch: field 0x10000540, Remove 0x10000547,
Squelch Character 0x1000054B, Squelch Account 0x1000054C).
Register: AD-79 mostly retired, AD-82 narrowed. Known remainder, filed
not hidden: the fellowship page's authored 600px content vs the 362px
viewport leaves Dismiss/Assign-Leader below the fold until the window is
resized taller (probe-measured; candidate follow-up).
Tests: Checkbox_Click fact rewritten to the mirror contract (both
directions), monarch-followers label updated, includeSelf expectation
updated, probe extended (click routing, synthetic click, action-widget
role dump). App suite 4,976/3 skips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1217 lines
43 KiB
C#
1217 lines
43 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 Attackable { 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 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<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));
|
|
|
|
// 2026-08-13 gate: retail has NO self-exclusion on this path
|
|
// (RecvNotice_SmartBoxObjectFound @0x004E5BAE selects/examines
|
|
// unconditionally) — includeSelf is now always true.
|
|
Assert.True(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>RequestUse</c>
|
|
/// ordering called <c>BeginApproach</c> 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 <see cref="RejectedWorldUseReleasesItsBusyReservation"/>
|
|
/// test never configured an approach at all (<c>TryGetApproach</c>
|
|
/// returns false unconditionally), so it could not have caught this —
|
|
/// this test explicitly combines "far" with "rejected."
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>Player.HandleActionUseItem</c> polls
|
|
/// for the player to actually reach use range before calling
|
|
/// <c>ActOnUse</c>, and a Use that arrives too early is silently lost
|
|
/// (the vendor's cosmetic greeting fires — a distance-only reaction
|
|
/// independent of Use — but <c>ApproachVendor</c> never comes). Nothing
|
|
/// dispatches until <see cref="SelectionInteractionController.OnNaturalMoveToComplete"/>
|
|
/// 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).
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="WeenieError"/>)
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <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) && item->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 _));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #298: explicit-target admission routes through the wider
|
|
/// <c>IsAttackableTarget</c> query (retail <c>ObjectIsAttackable</c>,
|
|
/// which admits a compatible-PK player), not the narrower
|
|
/// <c>IsHostileMonster</c> that auto-acquisition uses.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #298 / IA-19 guard: when the explicit selection is not an admitted
|
|
/// attack target, auto-acquisition falls back to the narrower
|
|
/// <c>FindClosestHostileMonster</c> query unchanged.
|
|
/// </summary>
|
|
[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);
|
|
}
|