fix(interaction): close selection lifecycle review gaps
Bind queued actions and pending inventory requests to exact live incarnations, separate optimistic placement from authoritative responses, and serialize retail-style inventory ownership across UI surfaces. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
d2bb5af453
commit
5acc3f01cf
23 changed files with 2635 additions and 168 deletions
|
|
@ -53,4 +53,43 @@ public sealed class OutboundInteractionQueueTests
|
|||
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drain_ReentrantClearStopsTheCapturedBatch()
|
||||
{
|
||||
var queue = new OutboundInteractionQueue();
|
||||
var actual = new List<string>();
|
||||
queue.Enqueue(() =>
|
||||
{
|
||||
actual.Add("first");
|
||||
queue.Clear();
|
||||
});
|
||||
queue.Enqueue(() => actual.Add("stale"));
|
||||
|
||||
queue.Drain();
|
||||
|
||||
Assert.Equal(new[] { "first" }, actual);
|
||||
Assert.Equal(0, queue.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drain_ReentrantClearAndEnqueueDefersTheNewEpoch()
|
||||
{
|
||||
var queue = new OutboundInteractionQueue();
|
||||
var actual = new List<string>();
|
||||
queue.Enqueue(() =>
|
||||
{
|
||||
actual.Add("first");
|
||||
queue.Clear();
|
||||
queue.Enqueue(() => actual.Add("new-session"));
|
||||
});
|
||||
queue.Enqueue(() => actual.Add("stale"));
|
||||
|
||||
queue.Drain();
|
||||
Assert.Equal(new[] { "first" }, actual);
|
||||
Assert.Equal(1, queue.Count);
|
||||
|
||||
queue.Drain();
|
||||
Assert.Equal(new[] { "first", "new-session" }, actual);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Interaction;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Interaction;
|
||||
|
||||
public sealed class PlayerInteractionMovementSinkTests
|
||||
{
|
||||
private const uint Player = 0x5000_0001u;
|
||||
private const uint Target = 0x7000_0001u;
|
||||
private const uint Cell = 0x0101_0001u;
|
||||
|
||||
[Fact]
|
||||
public void MissingPlayerDoesNotArmTheIntent()
|
||||
{
|
||||
var sink = new PlayerInteractionMovementSink(() => null);
|
||||
bool armed = false;
|
||||
|
||||
Assert.False(sink.BeginApproach(Approach(closeRange: true), () => armed = true));
|
||||
Assert.False(armed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, MovementType.TurnToObject)]
|
||||
[InlineData(false, MovementType.MoveToObject)]
|
||||
public void ProductionSinkCancelsThenArmsAndInstallsExactMovement(
|
||||
bool closeRange,
|
||||
MovementType expectedType)
|
||||
{
|
||||
var controller = new PlayerMovementController(new PhysicsEngine());
|
||||
controller.SetPosition(Vector3.Zero, Cell);
|
||||
bool nonAutonomousAtTargetInstall = false;
|
||||
int cancellations = 0;
|
||||
double targetQuantum = 0d;
|
||||
var moveTo = new MoveToManager(
|
||||
controller.Motion,
|
||||
stopCompletely: () => { },
|
||||
getPosition: () => controller.CellPosition,
|
||||
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
|
||||
setHeading: (heading, _) => controller.Yaw = MoveToMath.YawFromHeading(heading),
|
||||
getOwnRadius: () => 0.4f,
|
||||
getOwnHeight: () => 1.8f,
|
||||
contact: () => true,
|
||||
isInterpolating: () => false,
|
||||
getVelocity: () => Vector3.Zero,
|
||||
getSelfId: () => Player,
|
||||
setTarget: (_, topLevelId, radius, quantum) =>
|
||||
{
|
||||
Assert.Equal(Target, topLevelId);
|
||||
Assert.Equal(0.5f, radius);
|
||||
targetQuantum = quantum;
|
||||
nonAutonomousAtTargetInstall =
|
||||
controller.Motion.PhysicsObj?.LastMoveWasAutonomous == false;
|
||||
},
|
||||
clearTarget: () => { },
|
||||
getTargetQuantum: () => targetQuantum,
|
||||
setTargetQuantum: quantum => targetQuantum = quantum);
|
||||
moveTo.MoveToCancelled = _ => cancellations++;
|
||||
controller.MoveTo = moveTo;
|
||||
|
||||
// Prove the sink's explicit cancel occurs before its arm callback.
|
||||
moveTo.MoveToPosition(
|
||||
new Position(Cell, new Vector3(2f, 0f, 0f), Quaternion.Identity),
|
||||
new MovementParameters { UseSpheres = false });
|
||||
bool armedAfterCancellation = false;
|
||||
var sink = new PlayerInteractionMovementSink(() => controller);
|
||||
|
||||
Assert.True(sink.BeginApproach(
|
||||
Approach(closeRange),
|
||||
() => armedAfterCancellation = cancellations == 1 && !moveTo.IsMovingTo()));
|
||||
|
||||
Assert.True(armedAfterCancellation);
|
||||
Assert.True(nonAutonomousAtTargetInstall);
|
||||
Assert.Equal(expectedType, moveTo.MovementTypeState);
|
||||
Assert.Equal(Target, moveTo.SoughtObjectId);
|
||||
Assert.Equal(Target, moveTo.TopLevelObjectId);
|
||||
Assert.Equal(closeRange ? 0f : 0.75f, moveTo.SoughtObjectRadius);
|
||||
Assert.Equal(closeRange ? 0f : 2.25f, moveTo.SoughtObjectHeight);
|
||||
Assert.Equal(0.6f, moveTo.Params.DistanceToObject);
|
||||
Assert.Equal(!closeRange, moveTo.Params.CanCharge);
|
||||
}
|
||||
|
||||
private static InteractionApproach Approach(bool closeRange)
|
||||
{
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 101u,
|
||||
ServerGuid = Target,
|
||||
SourceGfxObjOrSetupId = 0x0200_0001u,
|
||||
Position = new Vector3(5f, 0f, 0f),
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [],
|
||||
};
|
||||
return new InteractionApproach(
|
||||
new WorldInteractionTarget(Target, entity.Id, entity),
|
||||
new PlayerInteractionPose(Cell, Vector3.Zero),
|
||||
UseRadius: 0.6f,
|
||||
IsCloseRange: closeRange,
|
||||
CanCharge: !closeRange,
|
||||
TargetRadius: 0.75f,
|
||||
TargetHeight: 2.25f);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ public sealed class SelectionInteractionControllerTests
|
|||
public bool Hostile { get; set; }
|
||||
public bool Useable { get; set; } = true;
|
||||
public bool Pickupable { get; set; } = true;
|
||||
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();
|
||||
|
|
@ -41,6 +43,11 @@ public sealed class SelectionInteractionControllerTests
|
|||
=> 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;
|
||||
|
|
@ -100,9 +107,15 @@ public sealed class SelectionInteractionControllerTests
|
|||
private sealed class Movement : IPlayerInteractionMovementSink
|
||||
{
|
||||
public List<InteractionApproach> Approaches { get; } = new();
|
||||
public bool BeginApproach(InteractionApproach approach)
|
||||
public bool Starts { get; set; } = true;
|
||||
public Action? AfterArm { get; set; }
|
||||
public bool BeginApproach(InteractionApproach approach, Action? armAfterCancel = null)
|
||||
{
|
||||
Approaches.Add(approach);
|
||||
if (!Starts)
|
||||
return false;
|
||||
armAfterCancel?.Invoke();
|
||||
AfterArm?.Invoke();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +129,8 @@ public sealed class SelectionInteractionControllerTests
|
|||
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;
|
||||
|
||||
|
|
@ -127,6 +142,12 @@ public sealed class SelectionInteractionControllerTests
|
|||
ObjectId = Player,
|
||||
Type = ItemType.Creature,
|
||||
});
|
||||
Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Target,
|
||||
Name = "Target",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
Items = new ItemInteractionController(
|
||||
Objects,
|
||||
() => Player,
|
||||
|
|
@ -148,6 +169,8 @@ public sealed class SelectionInteractionControllerTests
|
|||
Transport,
|
||||
Movement,
|
||||
Toasts.Add);
|
||||
Items.PendingBackpackPlacementRequested += PendingPlacements.Add;
|
||||
Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add;
|
||||
}
|
||||
|
||||
public void SetApproach(bool closeRange, uint localEntityId = 101u)
|
||||
|
|
@ -270,12 +293,14 @@ public sealed class SelectionInteractionControllerTests
|
|||
h.Selection.Select(Target, SelectionChangeSource.World);
|
||||
h.Controller.HandleInputAction(InputAction.SelectionPickUp);
|
||||
h.Controller.SendUse(Target);
|
||||
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);
|
||||
}
|
||||
|
|
@ -305,6 +330,216 @@ public sealed class SelectionInteractionControllerTests
|
|||
Assert.Empty(h.Transport.Uses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SynchronousNaturalCompletionObservesTheArmedCloseAction()
|
||||
{
|
||||
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 SynchronousResetCannotResurrectTheCloseAction()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetApproach(closeRange: true);
|
||||
h.Movement.AfterArm = h.Controller.ResetSession;
|
||||
|
||||
h.Controller.SendUse(Target);
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
||||
Assert.Empty(h.Transport.Uses);
|
||||
}
|
||||
|
||||
[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 ErrorCompletionClearsCloseActionBeforeALaterSuccess()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.SetApproach(closeRange: true);
|
||||
h.Controller.SendUse(Target);
|
||||
|
||||
h.Controller.OnMoveToCancelled(WeenieError.NoObject);
|
||||
h.Controller.OnNaturalMoveToComplete();
|
||||
|
||||
Assert.Empty(h.Transport.Uses);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ public sealed class WorldSelectionQueryTests
|
|||
public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh;
|
||||
}
|
||||
|
||||
private sealed record Animation(WorldEntity Entity, uint CurrentMotion)
|
||||
: ILiveEntityAnimationRuntime;
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly ClientObjectTable Objects = new();
|
||||
|
|
@ -131,6 +134,41 @@ public sealed class WorldSelectionQueryTests
|
|||
Assert.Null(h.Query.PickAtCursor(includeSelf: false));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
public void PickRejectsPublishedPartAfterVisibilityLifetimeEnds(int transition)
|
||||
{
|
||||
var h = new Harness();
|
||||
WorldEntity target = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc);
|
||||
h.Publish(target);
|
||||
Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false));
|
||||
|
||||
switch (transition)
|
||||
{
|
||||
case 0:
|
||||
Assert.True(h.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
Target,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
|
||||
InstanceSequence: 1,
|
||||
StateSequence: 2),
|
||||
out _));
|
||||
break;
|
||||
case 1:
|
||||
Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target));
|
||||
break;
|
||||
default:
|
||||
Assert.True(h.Runtime.UnregisterLiveEntity(
|
||||
new DeleteObject.Parsed(Target, 1),
|
||||
isLocalPlayer: false));
|
||||
break;
|
||||
}
|
||||
|
||||
Assert.Null(h.Query.PickAtCursor(includeSelf: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters()
|
||||
{
|
||||
|
|
@ -150,6 +188,43 @@ public sealed class WorldSelectionQueryTests
|
|||
new Vector3(1f, 0f, 0f),
|
||||
ItemType.Misc,
|
||||
SelectedObjectHealthPolicy.BfAttackable);
|
||||
h.Add(
|
||||
0x7000_0013u,
|
||||
new Vector3(3f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
SelectedObjectHealthPolicy.BfAttackable | SelectedObjectHealthPolicy.BfPlayer);
|
||||
WorldEntity pet = h.Add(
|
||||
0x7000_0014u,
|
||||
new Vector3(4f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
SelectedObjectHealthPolicy.BfAttackable);
|
||||
h.Objects.Get(pet.ServerGuid)!.PetOwnerId = Player;
|
||||
WorldEntity dead = h.Add(
|
||||
0x7000_0015u,
|
||||
new Vector3(5f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
SelectedObjectHealthPolicy.BfAttackable);
|
||||
h.Runtime.SetAnimationRuntime(
|
||||
dead.ServerGuid,
|
||||
new Animation(dead, MotionCommand.Dead));
|
||||
WorldEntity hidden = h.Add(
|
||||
0x7000_0016u,
|
||||
new Vector3(6f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
SelectedObjectHealthPolicy.BfAttackable);
|
||||
Assert.True(h.Runtime.TryApplyState(
|
||||
new SetState.Parsed(
|
||||
hidden.ServerGuid,
|
||||
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
|
||||
InstanceSequence: 1,
|
||||
StateSequence: 2),
|
||||
out _));
|
||||
WorldEntity pending = h.Add(
|
||||
0x7000_0017u,
|
||||
new Vector3(7f, 0f, 0f),
|
||||
ItemType.Creature,
|
||||
SelectedObjectHealthPolicy.BfAttackable);
|
||||
Assert.True(h.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid));
|
||||
|
||||
ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster();
|
||||
|
||||
|
|
|
|||
|
|
@ -1424,14 +1424,445 @@ public sealed class ItemInteractionControllerTests
|
|||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x70000A0Bu;
|
||||
var pending = new List<(uint Item, uint Container, int Placement)>();
|
||||
h.Controller.PendingBackpackPlacementRequested +=
|
||||
(objectId, container, placement) => pending.Add((objectId, container, placement));
|
||||
var pending = new List<PendingBackpackPlacement>();
|
||||
h.Controller.PendingBackpackPlacementRequested += pending.Add;
|
||||
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
|
||||
|
||||
Assert.Equal(new[] { (item, Player, 0) }, pending);
|
||||
Assert.Collection(
|
||||
pending,
|
||||
placement =>
|
||||
{
|
||||
Assert.NotEqual(0u, placement.Token);
|
||||
Assert.Equal(item, placement.ItemId);
|
||||
Assert.Equal(Player, placement.ContainerId);
|
||||
Assert.Equal(0, placement.Placement);
|
||||
});
|
||||
Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements);
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var request));
|
||||
Assert.Equal(InventoryRequestKind.Pickup, request.Kind);
|
||||
Assert.False(request.Dispatched);
|
||||
|
||||
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.Pickup,
|
||||
item,
|
||||
static () => true,
|
||||
pending[0].Token));
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out request));
|
||||
Assert.True(request.Dispatched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingRequestSerializesEveryLaterItemWithoutSecondWire()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x70000A0Du;
|
||||
const uint laterItem = 0x70000A0Eu;
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = item,
|
||||
Name = "Loot",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
var requested = new List<PendingBackpackPlacement>();
|
||||
var cancelled = new List<PendingBackpackPlacement>();
|
||||
h.Controller.PendingBackpackPlacementRequested += requested.Add;
|
||||
h.Controller.PendingBackpackPlacementCancelled += cancelled.Add;
|
||||
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
|
||||
PendingBackpackPlacement first = Assert.Single(requested);
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(laterItem));
|
||||
|
||||
Assert.Single(requested);
|
||||
Assert.Empty(cancelled);
|
||||
Assert.Equal(new[] { (item, Player, 0) }, h.BackpackPlacements);
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
"Already attempting to place Loot here",
|
||||
"Already attempting to place Loot here",
|
||||
},
|
||||
h.SystemMessages);
|
||||
Assert.True(h.Controller.TryGetPendingBackpackPlacement(item, out var current));
|
||||
Assert.Equal(first, current);
|
||||
|
||||
h.Objects.RejectMove(item, weenieError: 0x29u);
|
||||
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
|
||||
Assert.Empty(cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BusyInventoryTransactionPreventsPickupReservationAndWireRequest()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x70000A0Fu;
|
||||
h.Controller.IncrementBusyCount();
|
||||
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(item));
|
||||
|
||||
Assert.Empty(h.BackpackPlacements);
|
||||
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
|
||||
Assert.Equal(
|
||||
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
||||
h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingPickupPreventsContainedUseRequest()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint pickup = 0x70000A10u;
|
||||
const uint contained = 0x50000A11u;
|
||||
h.AddContained(contained, item => item.Useability = ItemUseability.Contained);
|
||||
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
||||
h.Now += 200;
|
||||
h.Controller.ActivateItem(contained);
|
||||
|
||||
Assert.Empty(h.Uses);
|
||||
Assert.True(h.Controller.TryGetPendingBackpackPlacement(pickup, out _));
|
||||
Assert.Equal(
|
||||
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
||||
h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingPickupPreventsPaperdollWieldRequest()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint pickup = 0x70000A12u;
|
||||
const uint helm = 0x50000A13u;
|
||||
h.AddContained(helm, item => item.ValidLocations = EquipMask.HeadWear);
|
||||
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
||||
Assert.False(h.Controller.WieldFromPaperdoll(helm, EquipMask.HeadWear));
|
||||
|
||||
Assert.Empty(h.Wields);
|
||||
Assert.Equal(EquipMask.None, h.Objects.Get(helm)!.CurrentlyEquippedLocation);
|
||||
Assert.Equal(
|
||||
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
||||
h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingPickupPreventsConfirmedUseRequest()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint pickup = 0x70000A14u;
|
||||
const uint item = 0x50000A15u;
|
||||
h.AddContained(item, contained => contained.Useability = ItemUseability.Contained);
|
||||
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
||||
Assert.False(h.Controller.ExecuteConfirmedUse(item));
|
||||
|
||||
Assert.Empty(h.Uses);
|
||||
Assert.Equal(0, h.Controller.BusyCount);
|
||||
Assert.Equal(
|
||||
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
||||
h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalInventoryRequestRejectsPickupUntilMatchingMoveResponse()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint moving = 0x50000A16u;
|
||||
const uint unrelated = 0x50000A17u;
|
||||
const uint pickup = 0x70000A18u;
|
||||
h.AddContained(moving);
|
||||
h.AddContained(unrelated);
|
||||
|
||||
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
moving,
|
||||
static () => true));
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
||||
|
||||
Assert.Empty(h.BackpackPlacements);
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
||||
Assert.Equal(moving, pending.ItemId);
|
||||
Assert.Equal(
|
||||
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
||||
h.SystemMessages);
|
||||
|
||||
h.Objects.ApplyConfirmedServerMove(unrelated, Pack, 0u, 0);
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
|
||||
h.Objects.ApplyConfirmedServerMove(moving, Pack, 0u, 0);
|
||||
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
Assert.True(h.Controller.PlaceWorldItemInBackpack(pickup));
|
||||
Assert.Equal(new[] { (pickup, Player, 0) }, h.BackpackPlacements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitRequestReleasesOnlyOnSourceStackResponse()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint source = 0x50000A19u;
|
||||
const uint unrelated = 0x50000A1Au;
|
||||
h.AddContained(source, item => item.StackSize = 10);
|
||||
h.AddContained(unrelated, item => item.StackSize = 10);
|
||||
|
||||
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.SplitToContainer,
|
||||
source,
|
||||
static () => true));
|
||||
h.Objects.UpdateStackSize(unrelated, 9, 0);
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
|
||||
h.Objects.UpdateStackSize(source, 9, 0);
|
||||
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchingInventoryFailureReleasesGlobalRequest()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint source = 0x50000A1Bu;
|
||||
h.AddContained(source);
|
||||
|
||||
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
source,
|
||||
static () => true));
|
||||
|
||||
h.Objects.RejectMove(source, weenieError: 0x29u);
|
||||
|
||||
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
Assert.True(h.Controller.CanMakeInventoryRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProvisionalInventoryOwnerRejectsReentrantSecondDispatch()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint first = 0x50000A1Cu;
|
||||
const uint second = 0x50000A1Du;
|
||||
h.AddContained(first);
|
||||
h.AddContained(second);
|
||||
bool secondDispatched = true;
|
||||
|
||||
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
first,
|
||||
() =>
|
||||
{
|
||||
secondDispatched = h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
second,
|
||||
static () => true);
|
||||
return true;
|
||||
}));
|
||||
|
||||
Assert.False(secondDispatched);
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
||||
Assert.Equal(first, pending.ItemId);
|
||||
Assert.True(pending.Dispatched);
|
||||
Assert.Equal(
|
||||
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
||||
h.SystemMessages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SynchronousMatchingResponseDoesNotResurrectProvisionalOwner()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x50000A1Eu;
|
||||
h.AddContained(item);
|
||||
|
||||
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
item,
|
||||
() => h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0)));
|
||||
|
||||
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
Assert.True(h.Controller.CanMakeInventoryRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptimisticMoveKeepsGlobalOwnerUntilAuthoritativeResponse()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x50000A20u;
|
||||
const uint second = 0x50000A21u;
|
||||
h.AddContained(item);
|
||||
h.AddContained(second);
|
||||
|
||||
Assert.True(h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
item,
|
||||
() => h.Objects.MoveItemOptimistic(item, Player, 0)));
|
||||
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
||||
Assert.Equal(item, pending.ItemId);
|
||||
Assert.True(pending.Dispatched);
|
||||
Assert.False(h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
second,
|
||||
static () => true));
|
||||
|
||||
Assert.True(h.Objects.ApplyConfirmedServerMove(item, Player, 0u, 0));
|
||||
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeResponseReleasesGlobalOwnerBeforePublishingPlacementResolution()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint first = 0x70000A22u;
|
||||
const uint second = 0x50000A23u;
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = first,
|
||||
Name = "Loot",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
h.AddContained(second);
|
||||
bool secondDispatched = false;
|
||||
h.Controller.PendingBackpackPlacementResolved += _ =>
|
||||
secondDispatched = h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
second,
|
||||
static () => true);
|
||||
|
||||
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
|
||||
first,
|
||||
Player,
|
||||
0,
|
||||
InventoryRequestKind.Pickup,
|
||||
static () => true));
|
||||
|
||||
Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0));
|
||||
|
||||
Assert.True(secondDispatched);
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
||||
Assert.Equal(second, pending.ItemId);
|
||||
Assert.True(pending.Dispatched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedMoveReleasesOldOwnerOnceAndPreservesReentrantSameItemRequest()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x50000A24u;
|
||||
h.AddContained(item);
|
||||
bool replacementDispatched = false;
|
||||
h.Controller.PendingBackpackPlacementResolved += _ =>
|
||||
replacementDispatched = h.Controller.TryDispatchInventoryRequest(
|
||||
InventoryRequestKind.PutInContainer,
|
||||
item,
|
||||
static () => true);
|
||||
|
||||
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
|
||||
item,
|
||||
Player,
|
||||
0,
|
||||
InventoryRequestKind.PutInContainer,
|
||||
() => h.Objects.MoveItemOptimistic(item, Player, 0)));
|
||||
|
||||
Assert.True(h.Objects.RejectMove(item, weenieError: 0x29u));
|
||||
|
||||
Assert.True(replacementDispatched);
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
||||
Assert.Equal(item, pending.ItemId);
|
||||
Assert.True(pending.Dispatched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResponseClearsGlobalAndLocalStateBeforeReadinessNotification()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint first = 0x70000A25u;
|
||||
const uint second = 0x70000A26u;
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = first,
|
||||
Name = "First loot",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = second,
|
||||
Name = "Second loot",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
Assert.True(h.Controller.TryDispatchPendingBackpackPlacement(
|
||||
first,
|
||||
Player,
|
||||
0,
|
||||
InventoryRequestKind.Pickup,
|
||||
static () => true));
|
||||
|
||||
var eventOrder = new List<string>();
|
||||
h.Controller.PendingBackpackPlacementResolved += placement =>
|
||||
{
|
||||
if (placement.ItemId == first)
|
||||
eventOrder.Add("resolved-first");
|
||||
};
|
||||
h.Controller.PendingBackpackPlacementRequested += placement =>
|
||||
{
|
||||
if (placement.ItemId == second)
|
||||
eventOrder.Add("requested-second");
|
||||
};
|
||||
bool attempted = false;
|
||||
bool secondDispatched = false;
|
||||
h.Controller.StateChanged += () =>
|
||||
{
|
||||
if (attempted || !h.Controller.CanMakeInventoryRequest)
|
||||
return;
|
||||
attempted = true;
|
||||
eventOrder.Add("ready");
|
||||
secondDispatched = h.Controller.TryDispatchPendingBackpackPlacement(
|
||||
second,
|
||||
Player,
|
||||
0,
|
||||
InventoryRequestKind.Pickup,
|
||||
static () => true);
|
||||
};
|
||||
|
||||
Assert.True(h.Objects.ApplyConfirmedServerMove(first, Player, 0u, 0));
|
||||
|
||||
Assert.True(attempted);
|
||||
Assert.True(secondDispatched);
|
||||
Assert.True(h.Controller.TryGetPendingBackpackPlacement(second, out _));
|
||||
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
|
||||
Assert.Equal(second, pending.ItemId);
|
||||
Assert.True(pending.Dispatched);
|
||||
Assert.Equal(
|
||||
new[] { "resolved-first", "ready", "requested-second" },
|
||||
eventOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReentrantPendingPublicationCancellationPreventsWireDispatch()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint item = 0x70000A1Fu;
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = item,
|
||||
Name = "Loot",
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
bool wireDispatched = false;
|
||||
h.Controller.PendingBackpackPlacementRequested += pending =>
|
||||
h.Controller.CancelPendingBackpackPlacement(pending.ItemId, pending.Token);
|
||||
|
||||
Assert.False(h.Controller.TryDispatchPendingBackpackPlacement(
|
||||
item,
|
||||
Player,
|
||||
0,
|
||||
InventoryRequestKind.Pickup,
|
||||
() =>
|
||||
{
|
||||
wireDispatched = true;
|
||||
return true;
|
||||
}));
|
||||
|
||||
Assert.False(wireDispatched);
|
||||
Assert.False(h.Controller.TryGetPendingBackpackPlacement(item, out _));
|
||||
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -263,4 +263,81 @@ public sealed class ExternalContainerControllerTests
|
|||
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
|
||||
Assert.Empty(h.Puts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingPickupRejectsOwnedDropIntoChest()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Open();
|
||||
h.Objects.AddOrUpdate(new ClientObject { ObjectId = Item, Type = ItemType.Misc });
|
||||
h.Objects.MoveItem(Item, Player, 0);
|
||||
var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory };
|
||||
source.SetItem(Item, 0u);
|
||||
|
||||
Assert.True(h.Interaction.PlaceWorldItemInBackpack(0x70000A01u));
|
||||
h.Controller.HandleDropRelease(
|
||||
h.Contents,
|
||||
h.Contents.GetItem(0)!,
|
||||
new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source));
|
||||
|
||||
Assert.Empty(h.Puts);
|
||||
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
|
||||
Assert.True(h.Interaction.TryGetPendingBackpackPlacement(0x70000A01u, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingPickupRejectsPartialStackSplitIntoChest()
|
||||
{
|
||||
using var h = new Harness();
|
||||
h.Open();
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Item,
|
||||
StackSize = 5,
|
||||
StackSizeMax = 100,
|
||||
Type = ItemType.Misc,
|
||||
});
|
||||
h.Objects.MoveItem(Item, Player, 0);
|
||||
h.Selection.Select(Item, SelectionChangeSource.Inventory);
|
||||
h.Split.Reset(5u, 2u);
|
||||
var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory };
|
||||
source.SetItem(Item, 0u);
|
||||
|
||||
Assert.True(h.Interaction.PlaceWorldItemInBackpack(0x70000A02u));
|
||||
h.Controller.HandleDropRelease(
|
||||
h.Contents,
|
||||
h.Contents.GetItem(0)!,
|
||||
new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source));
|
||||
|
||||
Assert.Empty(h.Splits);
|
||||
Assert.Equal(Player, h.Objects.Get(Item)!.ContainerId);
|
||||
Assert.True(h.Interaction.TryGetPendingBackpackPlacement(0x70000A02u, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalPutOwnsGlobalGateUntilMatchingServerMove()
|
||||
{
|
||||
using var h = new Harness();
|
||||
const uint pickup = 0x70000A03u;
|
||||
h.Open();
|
||||
h.Objects.AddOrUpdate(new ClientObject { ObjectId = Item, Type = ItemType.Misc });
|
||||
h.Objects.MoveItem(Item, Player, 0);
|
||||
var source = new UiItemSlot { SourceKind = ItemDragSource.Inventory };
|
||||
source.SetItem(Item, 0u);
|
||||
|
||||
h.Controller.HandleDropRelease(
|
||||
h.Contents,
|
||||
h.Contents.GetItem(0)!,
|
||||
new ItemDragPayload(Item, ItemDragSource.Inventory, 0, source));
|
||||
Assert.True(h.Interaction.TryGetPendingInventoryRequest(out var pending));
|
||||
Assert.Equal(Item, pending.ItemId);
|
||||
|
||||
Assert.True(h.Interaction.PlaceWorldItemInBackpack(pickup));
|
||||
Assert.Empty(h.Pickups);
|
||||
|
||||
h.Objects.ApplyConfirmedServerMove(Item, Chest, 0u, 0);
|
||||
Assert.False(h.Interaction.TryGetPendingInventoryRequest(out _));
|
||||
Assert.True(h.Interaction.PlaceWorldItemInBackpack(pickup));
|
||||
Assert.Equal(new uint[] { pickup }, h.Pickups);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -706,6 +706,467 @@ public class InventoryControllerTests
|
|||
Assert.Equal(0xBu, grid.GetItem(2)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectLootWhilePendingKeepsTheOriginalProjectionAndRequest()
|
||||
{
|
||||
const uint chest = 0x70000021u;
|
||||
const uint firstLoot = 0x70000022u;
|
||||
const uint secondLoot = 0x70000023u;
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, firstLoot, chest, slot: 0, type: ItemType.Misc);
|
||||
SeedContained(objects, secondLoot, chest, slot: 1, type: ItemType.Misc);
|
||||
long now = 1_000;
|
||||
var eventOrder = new List<(string Kind, uint Item, ulong Token)>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
nowMs: () => now,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { });
|
||||
interaction.PendingBackpackPlacementRequested += pending =>
|
||||
eventOrder.Add(("request", pending.ItemId, pending.Token));
|
||||
interaction.PendingBackpackPlacementCancelled += pending =>
|
||||
eventOrder.Add(("cancel", pending.ItemId, pending.Token));
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
itemInteraction: interaction);
|
||||
|
||||
Assert.True(interaction.ActivateItem(firstLoot));
|
||||
now += 200;
|
||||
Assert.False(interaction.ActivateItem(secondLoot));
|
||||
|
||||
Assert.Collection(
|
||||
eventOrder,
|
||||
first => Assert.Equal(("request", firstLoot), (first.Kind, first.Item)));
|
||||
Assert.Equal(firstLoot, grid.GetItem(0)!.ItemId);
|
||||
Assert.True(grid.GetItem(0)!.WaitingVisual);
|
||||
Assert.DoesNotContain(
|
||||
Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId),
|
||||
id => id == secondLoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GroundDragThenDirectPickupKeepsTheFirstPendingProjection()
|
||||
{
|
||||
const uint chest = 0x70000031u;
|
||||
const uint draggedLoot = 0x70000032u;
|
||||
const uint directLoot = 0x70000033u;
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, draggedLoot, chest, slot: 0, type: ItemType.Misc);
|
||||
SeedContained(objects, directLoot, chest, slot: 1, type: ItemType.Misc);
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
var eventOrder = new List<(string Kind, uint Item)>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: (item, container, placement) =>
|
||||
puts.Add((item, container, placement)));
|
||||
interaction.PendingBackpackPlacementRequested += pending =>
|
||||
eventOrder.Add(("request", pending.ItemId));
|
||||
interaction.PendingBackpackPlacementCancelled += pending =>
|
||||
eventOrder.Add(("cancel", pending.ItemId));
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
puts.Add((item, container, placement)),
|
||||
itemInteraction: interaction);
|
||||
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
|
||||
source.SetItem(draggedLoot, 0u);
|
||||
|
||||
inventory.HandleDropRelease(
|
||||
grid,
|
||||
grid.GetItem(4)!,
|
||||
new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source));
|
||||
Assert.True(interaction.PlaceWorldItemInBackpack(directLoot));
|
||||
|
||||
Assert.Equal(new[] { ("request", draggedLoot) }, eventOrder);
|
||||
Assert.Equal(new[] { (draggedLoot, Player, 0) }, puts);
|
||||
Assert.Equal(draggedLoot, grid.GetItem(0)!.ItemId);
|
||||
Assert.True(grid.GetItem(0)!.WaitingVisual);
|
||||
Assert.DoesNotContain(
|
||||
Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId),
|
||||
id => id == directLoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectPickupThenGroundDragAcceptsHoverButRejectsTheSecondRequest()
|
||||
{
|
||||
const uint chest = 0x70000041u;
|
||||
const uint directLoot = 0x70000042u;
|
||||
const uint draggedLoot = 0x70000043u;
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, directLoot, chest, slot: 0, type: ItemType.Misc);
|
||||
SeedContained(objects, draggedLoot, chest, slot: 1, type: ItemType.Misc);
|
||||
var eventOrder = new List<(string Kind, uint Item)>();
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
var messages = new List<string>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: (item, container, placement) =>
|
||||
puts.Add((item, container, placement)),
|
||||
systemMessage: messages.Add);
|
||||
interaction.PendingBackpackPlacementRequested += pending =>
|
||||
eventOrder.Add(("request", pending.ItemId));
|
||||
interaction.PendingBackpackPlacementCancelled += pending =>
|
||||
eventOrder.Add(("cancel", pending.ItemId));
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
puts.Add((item, container, placement)),
|
||||
itemInteraction: interaction);
|
||||
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
|
||||
source.SetItem(draggedLoot, 0u);
|
||||
|
||||
Assert.True(interaction.PlaceWorldItemInBackpack(directLoot));
|
||||
Assert.Equal(
|
||||
ItemDragAcceptance.Accept,
|
||||
inventory.OnDragOver(
|
||||
grid,
|
||||
grid.GetItem(5)!,
|
||||
new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source)));
|
||||
inventory.HandleDropRelease(
|
||||
grid,
|
||||
grid.GetItem(5)!,
|
||||
new ItemDragPayload(draggedLoot, ItemDragSource.Ground, 0, source));
|
||||
|
||||
Assert.Equal(new[] { ("request", directLoot) }, eventOrder);
|
||||
Assert.Equal(new[] { (directLoot, Player, 0) }, puts);
|
||||
Assert.Equal(directLoot, grid.GetItem(0)!.ItemId);
|
||||
Assert.True(grid.GetItem(0)!.WaitingVisual);
|
||||
Assert.Equal(
|
||||
new[] { "Already attempting to place that item here" },
|
||||
messages);
|
||||
Assert.DoesNotContain(
|
||||
Enumerable.Range(0, grid.GetNumUIItems()).Select(i => grid.GetItem(i)!.ItemId),
|
||||
id => id == draggedLoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingContentsProjectionUsesGenericGlobalMessageOnBagColumnDrop()
|
||||
{
|
||||
const uint chest = 0x70000044u;
|
||||
const uint pendingLoot = 0x70000045u;
|
||||
const uint ownedItem = 0x50000046u;
|
||||
const uint bag = 0x50000047u;
|
||||
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, pendingLoot, chest, 0, type: ItemType.Misc);
|
||||
SeedContained(objects, ownedItem, Player, 0, type: ItemType.Misc);
|
||||
SeedBag(objects, bag, 1);
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
var messages = new List<string>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { },
|
||||
systemMessage: messages.Add);
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
puts.Add((item, container, placement)),
|
||||
itemInteraction: interaction);
|
||||
|
||||
Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot));
|
||||
inventory.HandleDropRelease(
|
||||
containers,
|
||||
containers.GetItem(0)!,
|
||||
Payload(ownedItem));
|
||||
|
||||
Assert.Empty(puts);
|
||||
Assert.Equal(
|
||||
new[] { ItemInteractionController.InventoryRequestBusyMessage },
|
||||
messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingPickupRejectsCompatibleGroundMergeBeforeWireDispatch()
|
||||
{
|
||||
const uint chest = 0x70000071u;
|
||||
const uint pendingLoot = 0x70000072u;
|
||||
const uint sourceStack = 0x70000073u;
|
||||
const uint targetStack = 0x70000074u;
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = sourceStack,
|
||||
WeenieClassId = 0x1234u,
|
||||
StackSize = 10,
|
||||
StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(sourceStack, chest, 1);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = targetStack,
|
||||
WeenieClassId = 0x1234u,
|
||||
StackSize = 50,
|
||||
StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(targetStack, Player, 0);
|
||||
var merges = new List<(uint Source, uint Target, uint Amount)>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { });
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
sendStackableMerge: (source, target, amount) =>
|
||||
merges.Add((source, target, amount)),
|
||||
itemInteraction: interaction);
|
||||
var sourceCell = new UiItemSlot { SourceKind = ItemDragSource.Ground };
|
||||
sourceCell.SetItem(sourceStack, 0u);
|
||||
|
||||
Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot));
|
||||
UiItemSlot targetCell = Enumerable.Range(0, grid.GetNumUIItems())
|
||||
.Select(i => grid.GetItem(i)!)
|
||||
.Single(cell => cell.ItemId == targetStack);
|
||||
inventory.HandleDropRelease(
|
||||
grid,
|
||||
targetCell,
|
||||
new ItemDragPayload(sourceStack, ItemDragSource.Ground, 0, sourceCell));
|
||||
|
||||
Assert.Empty(merges);
|
||||
Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingPickupRejectsPartialGroundSplitBeforeWireDispatch()
|
||||
{
|
||||
const uint chest = 0x70000081u;
|
||||
const uint pendingLoot = 0x70000082u;
|
||||
const uint sourceStack = 0x70000083u;
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = sourceStack,
|
||||
StackSize = 10,
|
||||
StackSizeMax = 100,
|
||||
});
|
||||
objects.MoveItem(sourceStack, chest, 1);
|
||||
var selection = new SelectionState();
|
||||
selection.Select(sourceStack, SelectionChangeSource.Inventory);
|
||||
var splitQuantity = new StackSplitQuantityState();
|
||||
splitQuantity.Reset(10u);
|
||||
splitQuantity.SetValue(1u);
|
||||
var splits = new List<(uint Item, uint Container, uint Placement, uint Amount)>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { });
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: selection,
|
||||
datFont: null,
|
||||
sendStackableSplitToContainer: (item, container, placement, amount) =>
|
||||
splits.Add((item, container, placement, amount)),
|
||||
itemInteraction: interaction,
|
||||
stackSplitQuantity: splitQuantity);
|
||||
var sourceCell = new UiItemSlot { SourceKind = ItemDragSource.Ground };
|
||||
sourceCell.SetItem(sourceStack, 0u);
|
||||
|
||||
Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot));
|
||||
inventory.HandleDropRelease(
|
||||
grid,
|
||||
grid.GetItem(5)!,
|
||||
new ItemDragPayload(sourceStack, ItemDragSource.Ground, 0, sourceCell));
|
||||
|
||||
Assert.Empty(splits);
|
||||
Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingPickupRejectsOwnedInventoryMoveBeforeOptimisticMutation()
|
||||
{
|
||||
const uint chest = 0x70000091u;
|
||||
const uint pendingLoot = 0x70000092u;
|
||||
const uint ownedItem = 0x70000093u;
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, pendingLoot, chest, slot: 0, type: ItemType.Misc);
|
||||
SeedContained(objects, ownedItem, Player, slot: 0, type: ItemType.Misc);
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { });
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
sendPutItemInContainer: (item, container, placement) =>
|
||||
puts.Add((item, container, placement)),
|
||||
itemInteraction: interaction);
|
||||
|
||||
Assert.True(interaction.PlaceWorldItemInBackpack(pendingLoot));
|
||||
inventory.HandleDropRelease(grid, grid.GetItem(5)!, Payload(ownedItem));
|
||||
|
||||
Assert.Empty(puts);
|
||||
Assert.Equal(Player, objects.Get(ownedItem)!.ContainerId);
|
||||
Assert.Equal(0, objects.Get(ownedItem)!.ContainerSlot);
|
||||
Assert.True(interaction.TryGetPendingBackpackPlacement(pendingLoot, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingLootRemovalImmediatelyWithdrawsTheProjection()
|
||||
{
|
||||
const uint chest = 0x70000051u;
|
||||
const uint loot = 0x70000052u;
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { });
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
itemInteraction: interaction);
|
||||
|
||||
Assert.True(interaction.PlaceWorldItemInBackpack(loot));
|
||||
Assert.Equal(loot, grid.GetItem(0)!.ItemId);
|
||||
Assert.True(grid.GetItem(0)!.WaitingVisual);
|
||||
|
||||
Assert.True(objects.Remove(loot));
|
||||
|
||||
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
|
||||
Assert.False(interaction.TryGetPendingBackpackPlacement(loot, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingLootGenerationReplacementDoesNotLeaveTheOldProjection()
|
||||
{
|
||||
const uint chest = 0x70000061u;
|
||||
const uint loot = 0x70000062u;
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, loot, chest, slot: 0, type: ItemType.Misc);
|
||||
using var interaction = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
groundObjectId: () => chest,
|
||||
backpackContainerId: () => Player,
|
||||
placeInBackpack: static (_, _, _) => { });
|
||||
using var inventory = InventoryController.Bind(
|
||||
layout,
|
||||
objects,
|
||||
() => Player,
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
strength: () => 100,
|
||||
selection: new SelectionState(),
|
||||
datFont: null,
|
||||
itemInteraction: interaction);
|
||||
|
||||
Assert.True(interaction.PlaceWorldItemInBackpack(loot));
|
||||
Assert.Equal(loot, grid.GetItem(0)!.ItemId);
|
||||
|
||||
objects.ReplaceGeneration(WorldReplacement(loot), generation: 2);
|
||||
|
||||
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
|
||||
Assert.False(interaction.TryGetPendingBackpackPlacement(loot, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LootDrop_ServerFailureRemovesOnlyThePendingProjection()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ public class PaperdollControllerTests
|
|||
SelectionState? selection = null,
|
||||
IReadOnlyDictionary<uint, uint>? emptySlotSprites = null,
|
||||
List<(uint item, uint container, int placement)>? puts = null,
|
||||
List<string>? systemMessages = null)
|
||||
List<string>? systemMessages = null,
|
||||
Action<ItemInteractionController>? configureInteraction = null)
|
||||
{
|
||||
var itemInteraction = new ItemInteractionController(
|
||||
objects,
|
||||
|
|
@ -59,6 +60,7 @@ public class PaperdollControllerTests
|
|||
? null
|
||||
: (item, container, placement) => puts.Add((item, container, placement)),
|
||||
systemMessage: systemMessages is null ? null : systemMessages.Add);
|
||||
configureInteraction?.Invoke(itemInteraction);
|
||||
return PaperdollController.Bind(layout, objects, () => Player,
|
||||
iconIds: (_, _, _, _, _) => 0x1234u,
|
||||
itemInteraction: itemInteraction,
|
||||
|
|
@ -240,6 +242,38 @@ public class PaperdollControllerTests
|
|||
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_pendingPickupRejectsWieldWithoutOptimisticMutation()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
const uint pickup = 0x70000D02u;
|
||||
const uint helm = 0x50000D03u;
|
||||
SeedPackItem(objects, helm, EquipMask.HeadWear);
|
||||
var wields = new List<(uint item, uint mask)>();
|
||||
var messages = new List<string>();
|
||||
ItemInteractionController? interaction = null;
|
||||
var ctrl = Bind(
|
||||
layout,
|
||||
objects,
|
||||
wields,
|
||||
systemMessages: messages,
|
||||
configureInteraction: value => interaction = value);
|
||||
Assert.True(interaction!.TryBeginPendingBackpackPlacement(
|
||||
pickup,
|
||||
Player,
|
||||
0,
|
||||
out _));
|
||||
ctrl.HandleDropRelease(
|
||||
lists[HeadSlot],
|
||||
lists[HeadSlot].Cell,
|
||||
new ItemDragPayload(helm, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell));
|
||||
|
||||
Assert.Empty(wields);
|
||||
Assert.Equal(EquipMask.None, objects.Get(helm)!.CurrentlyEquippedLocation);
|
||||
Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleDropRelease_weaponSlot_delegatesToConfirmedAutoWieldTransaction()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -301,6 +301,97 @@ public class ToolbarControllerTests
|
|||
Assert.Equal(new[] { (item, player, 0) }, puts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryButton_unownedGroundDropUsesDirectAttemptWithoutDestinationProjection()
|
||||
{
|
||||
const uint player = 0x5000u;
|
||||
const uint item = 0x70005001u;
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = item, Name = "Loot", Type = ItemType.Misc });
|
||||
var placements = new List<(uint Item, uint Container, int Placement)>();
|
||||
var directPuts = new List<(uint Item, uint Container, int Placement)>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
repo,
|
||||
playerGuid: () => player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
placeInBackpack: (i, c, p) => placements.Add((i, c, p)));
|
||||
ToolbarController.Bind(
|
||||
layout,
|
||||
repo,
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
useItem: static _ => { },
|
||||
itemInteraction: interaction,
|
||||
playerGuid: () => player,
|
||||
sendPutItemInContainer: (i, c, p) => directPuts.Add((i, c, p)));
|
||||
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||||
var payload = new ItemDragPayload(item, ItemDragSource.Ground, 0, new UiItemSlot());
|
||||
|
||||
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)));
|
||||
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)));
|
||||
|
||||
Assert.Empty(placements);
|
||||
Assert.Equal(new[] { (item, player, 0) }, directPuts);
|
||||
Assert.False(interaction.TryGetPendingBackpackPlacement(item, out _));
|
||||
Assert.True(interaction.TryGetPendingInventoryRequest(out var request));
|
||||
Assert.Equal(item, request.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryButton_ownedDropPublishesDestinationAndGloballyRejectsSecondRequest()
|
||||
{
|
||||
const uint player = 0x5000u;
|
||||
const uint first = 0x70005002u;
|
||||
const uint second = 0x70005003u;
|
||||
var (layout, _, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
const uint pack = 0x5004u;
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = player, Name = "Player", Type = ItemType.Creature });
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Name = "Pack", Type = ItemType.Container });
|
||||
repo.MoveItem(pack, player, 0);
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = first, Name = "First Loot", Type = ItemType.Misc });
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = second, Name = "Second Loot", Type = ItemType.Misc });
|
||||
repo.MoveItem(first, pack, 0);
|
||||
repo.MoveItem(second, pack, 1);
|
||||
var puts = new List<(uint Item, uint Container, int Placement)>();
|
||||
var messages = new List<string>();
|
||||
using var interaction = new ItemInteractionController(
|
||||
repo,
|
||||
playerGuid: () => player,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
systemMessage: messages.Add);
|
||||
ToolbarController.Bind(
|
||||
layout,
|
||||
repo,
|
||||
() => Array.Empty<ShortcutEntry>(),
|
||||
iconIds: static (_, _, _, _, _) => 0u,
|
||||
useItem: static _ => { },
|
||||
itemInteraction: interaction,
|
||||
playerGuid: () => player,
|
||||
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
|
||||
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
|
||||
|
||||
foreach (uint item in new[] { first, second })
|
||||
{
|
||||
var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload));
|
||||
button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload));
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { (first, player, 0) }, puts);
|
||||
Assert.True(interaction.TryGetPendingBackpackPlacement(first, out _));
|
||||
Assert.True(interaction.TryGetPendingInventoryRequest(out var request));
|
||||
Assert.Equal(first, request.ItemId);
|
||||
Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -72,6 +72,38 @@ public sealed class RetailItemConfirmationControllerTests
|
|||
Assert.Equal(0, items.BusyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositiveConfirmationRechecksGlobalInventoryGateBeforeUse()
|
||||
{
|
||||
var objects = BuildObjects(PublicWeenieFlags.VolatileRare);
|
||||
var uses = new List<uint>();
|
||||
var messages = new List<string>();
|
||||
var items = new ItemInteractionController(
|
||||
objects,
|
||||
playerGuid: () => Player,
|
||||
sendUse: uses.Add,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null,
|
||||
placeInBackpack: static (_, _, _) => { },
|
||||
systemMessage: messages.Add,
|
||||
nowMs: () => 1000L);
|
||||
var root = new UiRoot { Width = 800f, Height = 600f };
|
||||
ImportedLayout? shown = null;
|
||||
var factory = new RetailDialogFactory(root, _ =>
|
||||
shown = FixtureLoader.LoadConfirmationDialog());
|
||||
using var confirmations = new RetailItemConfirmationController(factory, items);
|
||||
|
||||
Assert.True(items.ActivateItem(Rare));
|
||||
Assert.True(items.PlaceWorldItemInBackpack(0x70000001u));
|
||||
Assert.IsType<UiButton>(shown!.FindElement(
|
||||
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
|
||||
|
||||
Assert.Empty(uses);
|
||||
Assert.Equal(0, items.BusyCount);
|
||||
Assert.Equal(new[] { ItemInteractionController.InventoryRequestBusyMessage }, messages);
|
||||
}
|
||||
|
||||
private static ClientObjectTable BuildObjects(PublicWeenieFlags flags)
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
|
|
|
|||
|
|
@ -85,6 +85,27 @@ public sealed class MoveToManagerCompletionSeamTests
|
|||
Assert.Empty(h.MoveToCompleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelMoveTo_FiresCancellationCompanionOnlyForAnActiveMove()
|
||||
{
|
||||
var h = new MoveToManagerHarness();
|
||||
var cancellations = new List<WeenieError>();
|
||||
h.Manager.MoveToCancelled = cancellations.Add;
|
||||
|
||||
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
Assert.Empty(cancellations);
|
||||
|
||||
h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity);
|
||||
h.Manager.MoveToPosition(
|
||||
new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity),
|
||||
new MovementParameters { UseSpheres = false });
|
||||
h.Manager.CancelMoveTo(WeenieError.ObjectGone);
|
||||
h.Manager.CancelMoveTo(WeenieError.ActionCancelled);
|
||||
|
||||
Assert.Equal(new[] { WeenieError.ObjectGone }, cancellations);
|
||||
Assert.Empty(h.MoveToCompleteCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StickyCompletion_FiresMoveToComplete_AfterStickToHandoff()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue