fix(vendor): Slice 6 review corrections — ownership-checked retire, live slider display, drag-proof shop rows, hardened buy reservation
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

All nine findings from the buy-arc review, at root:

F1 the materializer's retire pass re-checks ownership (guid->vendorId
map; remove only while the live object's ContainerId still equals the
recording vendor) — buying a player-sold UNIQUE no longer deletes the
item you just purchased; the discriminating reparent-then-refresh test
pins it. F2 the cost/name display subscribes to the live split state
and shares ONE quantity computation with Buy (retail re-renders per
slider tick: RecvNotice_StackSliderChanged 0x004C4500) — the sentence
and the charge can no longer disagree. F3 shop rows never mint drag
payloads (UiItemSlot.AllowDragSource gates both IsDragSource AND
GetDragPayload — the second gate was caught by this pass's own test).
F4 sendBuy reports whether anything was sent; a null-session buy
cancels the reservation instead of leaking BusyCount forever.
F5 the retire loop snapshots, isolates per-guid observer failures, and
clears its tracking in finally and Dispose — teardown convergence can
no longer wedge. F6 auto-select is retail's unconditional
first-filtered-item shape (pc:201180-201184; the survival-check was
our invention and the comment claiming otherwise is corrected).
F7 non-stack buys clamp to quantity 1 locally (BuySingleItem
pc:201669). F8 the Add button is hard-disabled until staging exists.
F9 AP-161/162/163 rewritten to the post-fix reality.

Clean-room complete solution: 11,378 passed / 4 skipped / 0 failed.
The #350 render-ledger overflow observed this session is under
separate investigation and is NOT addressed here.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 23:12:50 +02:00
parent 97cf873870
commit 3c9fc57adb
11 changed files with 670 additions and 116 deletions

View file

@ -28,6 +28,11 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
// F4 (Slice 6 review): simulates the composition-root sendBuy
// delegate's "no live session / not in world" no-op case —
// TryBuy must see this as false and release the reservation
// rather than mark it dispatched for a request nothing sent.
public bool SendBuySucceeds = true;
public readonly List<string> Toasts = new();
public readonly List<string> SystemMessages = new();
public readonly List<CombatMode> CombatModeRequests = new();
@ -97,7 +102,12 @@ public sealed class ItemInteractionControllerTests
sendChangeCombatMode: CombatModeRequests.Add,
requestUse: requestUse,
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId)));
{
if (!SendBuySucceeds)
return false;
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
return true;
});
}
public ItemInteractionController Controller { get; }
@ -2267,4 +2277,35 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void TryBuy_NoSessionToSendOn_ReleasesTheReservation_AndASubsequentBuyWorks()
{
// F4 (Slice 6 review): the composition-root sendBuy delegate
// returns false when there is no live session (or it's not in
// world) -- BEFORE this fix, sendBuy was a plain Action, so TryBuy
// could not tell "sent" apart from a silent no-op and always
// called MarkDispatched(). Since nothing was actually sent, no
// UseDone would ever arrive to balance it, leaking BusyCount and
// permanently wedging every future Use/Buy behind "You can only
// move or use one item at a time."
var h = new Harness();
h.SendBuySucceeds = false;
bool result = h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u);
Assert.False(result);
Assert.Empty(h.Buys);
// The reservation was cancelled, not dispatched -- the gate is
// free again immediately, with no UseDone needed to release it.
Assert.Equal(0, h.Controller.BusyCount);
// A subsequent buy, once a session IS available, proceeds normally.
h.SendBuySucceeds = true;
bool second = h.Controller.TryBuy(0x40001000u, 0x50002001u, 1, 0u);
Assert.True(second);
Assert.Single(h.Buys);
Assert.Equal(1, h.Controller.BusyCount);
}
}