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:
Erik 2026-07-21 09:01:02 +02:00
parent d2bb5af453
commit 5acc3f01cf
23 changed files with 2635 additions and 168 deletions

View file

@ -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]

View file

@ -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);
}
}

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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()
{

View file

@ -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();