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
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue