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

@ -61,8 +61,12 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<string>? _systemMessage;
private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
// Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId.
private readonly Action<uint, uint, int, uint>? _sendBuy;
// Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId -> true
// when the wire send actually happened. F4 (Slice 6 review): a plain
// Action can't tell TryBuy apart from a silent no-op (no session / not
// in world) — the bool return is what lets TryBuy release the
// reservation instead of leaking BusyCount forever.
private readonly Func<uint, uint, int, uint, bool>? _sendBuy;
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
private readonly InventoryTransactionState _transactions;
@ -104,7 +108,7 @@ public sealed class ItemInteractionController : IDisposable
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null,
Action<uint, ItemUseRequestReservation>? requestUse = null,
Action<uint, uint, int, uint>? sendBuy = null)
Func<uint, uint, int, uint, bool>? sendBuy = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -242,6 +246,20 @@ public sealed class ItemInteractionController : IDisposable
/// exactly what <see cref="EnsureInventoryRequestReady"/>'s
/// <c>BusyCount == 0</c> check already guards for every other request.
/// </summary>
/// <remarks>
/// F4 (Slice 6 review): <see cref="_sendBuy"/> reports whether the wire
/// send actually happened (false when there is no live session or the
/// session is not in world). Mirrors
/// <c>RuntimeInteractionTransactionState.TryDispatchUse</c>'s shape —
/// every rejecting path calls <see cref="ItemUseRequestReservation.CancelBeforeDispatch"/>
/// before returning, never <see cref="ItemUseRequestReservation.MarkDispatched"/>.
/// The OLD void-returning delegate could not distinguish "sent" from a
/// session-null no-op, so a buy attempted while disconnected/mid-teardown
/// permanently marked the reservation dispatched — <c>BusyCount</c> then
/// never balances, because no <c>UseDone</c> will ever arrive for a
/// request that was never actually sent, leaking "You can only move or
/// use one item at a time" forever.
/// </remarks>
public bool TryBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId)
{
if (vendorGuid == 0u || itemGuid == 0u || amount <= 0 || _sendBuy is null)
@ -250,15 +268,23 @@ public sealed class ItemInteractionController : IDisposable
return false;
ItemUseRequestReservation reservation = BeginUseRequestReservation();
bool dispatched;
try
{
_sendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId);
dispatched = _sendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
if (!dispatched)
{
reservation.CancelBeforeDispatch();
return false;
}
reservation.MarkDispatched();
return true;
}