fix(vendor): gate-findings pass — the X button HIDES like retail, clicks return, the dropdown scrolls, pyreal suffix, staged-tab slots
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

The user's connected gate found five issues; each fixed at the root:

G4 (the discovery): retail's vendor X button calls only SetVisible(0)
(pc:204147-204182) — the SESSION stays open and re-using the vendor
lands on the same-session refresh; the range watcher remains the sole
real close. Our port invented a full teardown on X, which is exactly
why reopening died. The Runtime fixture proves the wire dispatch was
never the problem; ACE has no already-open short-circuit.

G3 (regression from the drag-suppression fix): denying IsDragSource
also dropped press capture, so clicks fell through to window-drag.
UiItemSlot.HandlesClick now claims presses for any occupied cell
independent of drag eligibility — clickable and draggable are separate
concerns.

G5: the authored popup 0x21000043 is ONE scrollable column with a real
scrollbar subtree (live-dat scan: ListBox 0x10000350 + scrollbar
0x10000351), not a 3x6 grid. UiMenu gains an authored-driven
Scrollable mode (wheel, thumb drag, track paging, up/down buttons);
chat's menu is untouched and its ten tests prove it.

G1: retail's cost format is "%s %hsp (you have %hsp)" — the p after
each %hs is a LITERAL pyreal suffix the port swallowed as part of the
specifier. Restored.

G2: the Buying/Selling pages' authored lists (same cell template as
Items) get the empty-slot fill, presentation-only until staging.

Clean-room complete solution: 11,390 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 10:29:39 +02:00
parent 58c8de264e
commit 5224e43890
8 changed files with 1061 additions and 51 deletions

View file

@ -248,6 +248,136 @@ public sealed class RuntimeVendorLifecycleTests
Assert.Equal(0, snapshot.MaterializedVendorItemCount);
}
[Fact]
public void SecondUse_AfterLocalXClose_StillDispatchesOverTheWireAndReopensOnReapproach()
{
// G4 (vendor gate finding, client-side half): the user reported that
// after X-closing the vendor panel (VendorState.Close() -- a PURE
// client-local UI teardown per research doc A.3/A.4, no wire send),
// using the vendor again does nothing. This isolates the Runtime
// machinery a repeat Use actually depends on --
// RuntimeInteractionTransactionState's reservation/busy-count gate
// and VendorState's own open/close bookkeeping -- with NO App-layer
// world-picking in the loop (AcDream.Runtime.Tests cannot reference
// AcDream.App). If this passes, the suppression (if real) is NOT in
// Runtime; it would have to be in the App-layer picking/identity
// chain (WorldSelectionQuery/SelectionInteractionController), which
// needs its own harness to confirm or rule out.
using GameRuntime runtime = Create();
VendorState vendor = runtime.InventoryOwner.Vendor;
using IDisposable wiring = Wire(vendor);
RuntimeInteractionTransactionState transactions = runtime.ActionOwner.Transactions;
var transport = new FakeTransport();
const uint vendorGuid = 0x40001000u;
// --- First open: press Use -> dispatch -> ApproachVendor arrives ->
// UseDone arrives (ACE's Player_Use.TryUseItem ALWAYS schedules
// SendUseDoneEvent() after ActOnUse returns, since Vendor.ActOnUse
// never sets LastUseTime = float.MinValue -- confirmed against
// references/ACE/Source/ACE.Server/WorldObjects/{Vendor,Player_Use}.cs).
ItemUseRequestReservation reservation1 =
transactions.BeginUseRequestReservation();
RuntimeInteractionDispatchResult result1 = transactions.TryDispatchUse(
vendorGuid,
ownedByPlayer: false,
useable: true,
reservation1,
transport,
out _);
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result1);
Assert.Equal(new[] { vendorGuid }, transport.Uses);
Assert.Equal(1, transactions.Inventory.BusyCount);
Dispatch(BuildApproachVendorPayload(
vendorGuid: vendorGuid,
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
currencyName: "",
items: []));
Assert.Equal(vendorGuid, vendor.VendorId);
transactions.CompleteUse(0u);
Assert.Equal(0, transactions.Inventory.BusyCount);
// --- X-close: the ONLY thing our client does locally.
Assert.True(vendor.Close());
Assert.Equal(0u, vendor.VendorId);
// --- Second Use attempt on the SAME vendor guid, well past the
// 200ms retail throttle (irrelevant here since TryDispatchUse has no
// throttle of its own -- that lives one layer up in
// ItemInteractionController/App -- but asserted for clarity).
ItemUseRequestReservation reservation2 =
transactions.BeginUseRequestReservation();
RuntimeInteractionDispatchResult result2 = transactions.TryDispatchUse(
vendorGuid,
ownedByPlayer: false,
useable: true,
reservation2,
transport,
out _);
// If this fails, RuntimeInteractionTransactionState/VendorState is
// the suppressor. If it passes (expected, given VendorState.Close()
// touches no interaction-transaction state and ActiveVendorId only
// gates USING SHOP ITEMS, not the vendor NPC itself -- see
// ItemInteractionPolicy.DecideUse's ContainerId check), the
// suppression is NOT here.
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result2);
Assert.Equal(new[] { vendorGuid, vendorGuid }, transport.Uses);
Assert.Equal(1, transactions.Inventory.BusyCount);
// --- Server re-approaches (mirrors Vendor.ActOnUse's UNCONDITIONAL
// ApproachVendor -- confirmed no server-side "already open" gate
// exists; see the G4 evidence chain in the final report). Our own
// VendorState.Apply must reopen from a previous==0 baseline (Close()
// already zeroed it), which VendorUiController.OnVendorChanged's
// Opened case turns into _window.Show().
var kinds = new List<VendorStateTransitionKind>();
vendor.Changed += t => kinds.Add(t.Kind);
Dispatch(BuildApproachVendorPayload(
vendorGuid: vendorGuid,
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
currencyName: "",
items: []));
Assert.Equal(vendorGuid, vendor.VendorId);
Assert.Equal([VendorStateTransitionKind.Opened], kinds);
transactions.CompleteUse(0u);
Assert.Equal(0, transactions.Inventory.BusyCount);
}
private sealed class FakeTransport : IRuntimeInteractionTransport
{
private uint _sequence;
public bool IsInWorld { get; set; } = true;
public List<uint> Uses { get; } = [];
public bool TrySendUse(uint serverGuid, out uint sequence)
{
if (!IsInWorld)
{
sequence = 0u;
return false;
}
sequence = ++_sequence;
Uses.Add(serverGuid);
return true;
}
public bool TrySendPickup(
uint itemGuid,
uint destinationContainerId,
int placement,
out uint sequence)
{
sequence = 0u;
return false;
}
}
[Fact]
public void VendorId_IsTheLiveActiveVendorIdSeamSource()
{