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]