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

@ -479,4 +479,48 @@ public class DragDropSpineTests
Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300)
}
// ── G3 (vendor gate finding): occupied AllowDragSource=false cell ────────
// Regression introduced by F3 (Slice 6 review): AllowDragSource=false makes
// an OCCUPIED cell's IsDragSource false too, which — before the G3 fix —
// meant UiRoot's mousedown dispatch found no reason to claim the press at
// all (IsDragSource false, CapturesPointerDrag false, HandlesClick false)
// and fell all the way through to the IA-12 whole-window-drag fallback,
// exactly like an EMPTY cell. A vendor row must still capture its own
// press/click (selection) while genuinely never minting a drag payload.
[Fact]
public void OccupiedNonDragSourceSlotInsideDraggableWindow_capturesClick_doesNotMoveWindow()
{
var (root, frame, list) = DraggableFrameWithSlot(0x5001u);
list.Cell.AllowDragSource = false; // vendor/salvage row shape
bool clicked = false;
list.Cell.Clicked = () => clicked = true;
root.OnMouseDown(UiMouseButton.Left, 20, 310);
root.OnMouseMove(40, 310); // would promote to drag if armed
Assert.Null(root.DragSource); // never mints a drag payload
Assert.Equal(10f, frame.Left); // window did NOT move
Assert.Equal(300f, frame.Top);
// HandlesClick (G3) routes this cell through UiRoot's case #4
// (CapturesPointerDrag/HandlesClick), the SAME branch a plain button
// uses — no _dragCandidate is ever armed (unlike IsDragSource, case
// #3), so there is no drag to distinguish an in-bounds move from: a
// release still inside the cell's own screen rect is an ordinary
// click regardless of the small in-cell move above, exactly like
// HandlesClickWidget_insideDraggableWindow_stillEmitsClick.
root.OnMouseUp(UiMouseButton.Left, 40, 310);
Assert.True(clicked);
}
[Fact]
public void OccupiedNonDragSourceSlotInsideDraggableWindow_hoverDoesNotShowMoveCursor()
{
var (root, _, list) = DraggableFrameWithSlot(0x5001u);
list.Cell.AllowDragSource = false;
root.OnMouseMove(20, 310); // hover over the vendor row, no press
Assert.False(root.HoverWindowMove);
}
}

View file

@ -70,6 +70,82 @@ public sealed class VendorUiControllerTests
Assert.NotNull(controller);
}
[Fact]
public void Bind_FromRealDatFixture_BuyingAndSellingLists_ConfiguredForEmptySlotFill()
{
// G2 (vendor gate finding): the Buying/Selling pages' item strips
// never got the empty-slot fill the Items list has, so they showed
// the bare authored blue background instead. This proves the real
// LayoutDesc 0x21000012 fixture's 0x100000C5 (Buying list)/0x100000CE
// (Selling list) resolve to real UiItemList widgets and come out of
// Bind configured identically to the Items strip (F7b) — same
// single-row/horizontal-scroll/cell-size shape, fill enabled, a
// non-drag-source empty-slot factory wired, and the sibling
// scrollbar bound to the SAME list's scroll model. The lists stay
// UNPOPULATED (no AddItem call anywhere in this path) — staging is
// still deferred.
ImportedLayout layout = FixtureLoader.LoadVendor();
var screen = new UiRoot { Width = 1280f, Height = 800f };
RetailWindowHandle window = RetailWindowFrame.Mount(
screen,
layout.Root,
static _ => (0u, 0, 0),
new RetailWindowFrame.Options
{
WindowName = "vendor-fixture-smoke-2",
Chrome = RetailWindowChrome.Imported,
Visible = false,
});
var objects = new ClientObjectTable();
using var itemInteraction = new ItemInteractionController(
objects,
new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(),
playerGuid: static () => 0u,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
VendorUiController? controller = VendorUiController.Bind(
layout,
new VendorState(),
window,
static (_, _, _, _, _) => 0u,
objects,
static () => 0u,
itemInteraction,
new SelectionState(),
new StackSplitQuantityState(),
datFont: null,
debugFont: null,
static _ => (0u, 0, 0));
Assert.NotNull(controller);
var buyingList = Assert.IsType<UiItemList>(layout.FindElement(VendorUiController.BuyingListId));
var sellingList = Assert.IsType<UiItemList>(layout.FindElement(VendorUiController.SellingListId));
var buyingScrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(VendorUiController.BuyingScrollbarId));
var sellingScrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(VendorUiController.SellingScrollbarId));
foreach (UiItemList list in new[] { buyingList, sellingList })
{
Assert.True(list.SingleRow);
Assert.True(list.HorizontalScroll);
Assert.Equal(32f, list.CellWidth);
Assert.Equal(32f, list.CellHeight);
Assert.True(list.FillVisibleEmptySlots);
Assert.NotNull(list.EmptySlotFactory);
Assert.Equal(0, list.GetNumUIItems()); // never populated
}
Assert.Same(buyingList.Scroll, buyingScrollbar.Model);
Assert.True(buyingScrollbar.Horizontal);
Assert.Same(sellingList.Scroll, sellingScrollbar.Model);
Assert.True(sellingScrollbar.Horizontal);
}
private sealed class Harness
{
// F2/F3: a deterministic non-zero player coin total so the cost-text
@ -310,7 +386,7 @@ public sealed class VendorUiControllerTests
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
Assert.Equal(
$"cost {2000:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
}
@ -338,7 +414,7 @@ public sealed class VendorUiControllerTests
Assert.Equal("Bread", GetText(h.ItemNameText));
Assert.Equal(
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
}
@ -386,7 +462,7 @@ public sealed class VendorUiControllerTests
// selection.
Assert.Equal("Arrows", GetText(h.ItemNameText));
Assert.Equal(
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
// Player drags the slider to 40 AFTER selecting -- no re-click, no
@ -396,7 +472,7 @@ public sealed class VendorUiControllerTests
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
Assert.Equal(
$"cost {800:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText));
h.BuyButton.OnClick!.Invoke();
@ -576,6 +652,45 @@ public sealed class VendorUiControllerTests
Assert.NotEqual(0u, cell.ItemId); // occupied -- would otherwise be a drag source by default
Assert.False(cell.IsDragSource);
Assert.Null(cell.GetDragPayload());
// G3 (vendor gate finding): a row must still CAPTURE its own press
// even though it never mints a drag payload -- see
// UiItemSlot.HandlesClick and the real-event-path test below.
Assert.True(cell.HandlesClick);
}
[Fact]
public void ShopRow_ClickEvent_SelectsItem_DespiteNotBeingADragSource()
{
// G3 (vendor gate finding): the F3 drag-suppression fix
// (AllowDragSource=false) left occupied vendor rows with
// IsDragSource==false -- before the G3 fix that meant UiRoot's
// mousedown dispatch found no reason to claim the press at all, so
// it fell through to the window-move fallback (hover showed the
// move-window cursor; a press dragged the whole panel instead of
// selecting a row). This drives the REAL UiItemSlot.OnEvent state
// machine (MouseDown then Click), the same sequence UiRoot's
// dispatch produces, rather than invoking the wired Clicked
// delegate directly -- proving the row still completes a press then
// click and drives selection.
const uint SecondArmorGuid = 0x60000110u;
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(SecondArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
});
Assert.Equal(ArmorItemGuid, h.Selection.SelectedObjectId); // auto-selected first
UiItemSlot secondCell = h.ItemList.GetItem(1)!;
Assert.Equal(SecondArmorGuid, secondCell.ItemId);
Assert.False(secondCell.IsDragSource); // never a drag source (F3)
Assert.True(secondCell.HandlesClick); // but still claims its own press (G3)
secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.MouseDown));
secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.Click));
Assert.Equal(SecondArmorGuid, h.Selection.SelectedObjectId);
Assert.True(secondCell.Selected);
}
[Fact]
@ -720,8 +835,22 @@ public sealed class VendorUiControllerTests
}
[Fact]
public void CloseButton_RoutesThroughVendorStateClose_NotADirectFieldWrite()
public void CloseButton_HidesTheWindowOnly_LeavesTheSessionOpenForARefreshInPlaceReopen()
{
// G4 (vendor gate finding): retail's close/pushpin button —
// gmVendorUI::HandleButtonClicks's 0x100000d6 case (pc:204147-204182)
// — with nothing staged in the Buying/Selling lists (this port never
// stages anything; Slice 6 territory) calls ONLY SetVisible(0),
// never gmVendorUI::CloseVendor (pc:202080, the range-watcher-
// unregister/session-teardown function VendorState.Close ports).
// The OLD port called VendorState.Close() directly from this button
// — an over-eager full teardown retail does not perform on an
// ordinary close. RuntimeVendorRangeQuery.EnforceRange (evaluated
// every frame regardless of window visibility) remains the sole
// path to a full close once the player actually leaves UseRadius —
// see Closed_HidesWindowAndClearsListAndText for that path,
// unaffected by this change since it calls VendorState.Close()
// directly rather than through this button.
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
@ -730,12 +859,25 @@ public sealed class VendorUiControllerTests
h.CloseButton.OnClick!.Invoke();
// The panel never mutates VendorState directly — the ONLY path from
// the close button to a cleared session is VendorState.Close()
// itself, so VendorId reads back 0 through the owner's own public
// surface, not a private field poke.
Assert.Equal(0u, h.State.VendorId);
Assert.False(h.Window.IsVisible);
// Unlike the OLD port, the session itself is NOT torn down — the
// owner still reports the same open vendor in the background,
// matching retail's hidden-but-still-registered range watcher.
Assert.Equal(VendorGuid, h.State.VendorId);
// Re-approaching the SAME vendor (e.g. pressing Use again while
// still in range) now reaches retail's sameVendor==1 refresh-in-
// place path (VendorStateTransitionKind.Refreshed) instead of a
// from-scratch Opened, and reopens the window.
var kinds = new List<VendorStateTransitionKind>();
h.State.Changed += t => kinds.Add(t.Kind);
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
Assert.True(h.Window.IsVisible);
Assert.Equal([VendorStateTransitionKind.Refreshed], kinds);
}
[Fact]

View file

@ -175,4 +175,192 @@ public class UiMenuTests
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(ChatChannelKind.Fellowship, fired);
}
// ── G5 (vendor gate finding): Scrollable single-column popup ──────────────
// Retail's vendor category dropdown (LayoutDesc 0x21000043) is a SCROLLABLE
// single column with a docked scrollbar, not a column-major grid — see
// VendorUiController's "G5 correction" class-doc paragraph. Chat's own popup
// (exercised by every test above, Scrollable left at its false default) is
// completely unaffected: it's a structurally different code path
// (DrawGridPopup / the grid branch of OnEvent's MouseDown handling).
private const int Border = 5; // RetailChromeSprites.Border
private static UiMenu.MenuItem[] MakeCategoryItems(int count)
=> System.Linq.Enumerable.Range(0, count)
.Select(i => new UiMenu.MenuItem($"Category {i}", (object?)i))
.ToArray();
private static UiMenu MakeScrollableMenu(int itemCount = 18) => new UiMenu
{
Width = 100f, Height = 18f,
Items = MakeCategoryItems(itemCount),
Selected = (object?)0,
Scrollable = true,
RowsPerColumn = 6,
RowHeight = 18f,
ColumnWidth = 100f,
ScrollbarWidth = 16f,
ScrollButtonExtent = 16f,
};
/// <summary>Raw event Data2 (the same "ly" MouseDown/MouseMove receive) for a
/// point at popup-interior-local Y <paramref name="iy"/> — derives the mapping
/// from the SAME public geometry properties production code reads, mirroring
/// how CategoryMenu_OpensAndSelectsThroughRealHitPath (VendorUiControllerTests)
/// derives its click point rather than hardcoding a pixel constant.</summary>
private static int RawY(UiMenu menu, float iy)
{
float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * Border;
return (int)(iy - outerH + Border);
}
/// <summary>Raw event Data1 ("lx") for a point at popup-interior-local X <paramref name="ix"/>.</summary>
private static int RawX(float ix) => (int)(ix + Border);
[Fact]
public void Scrollable_18Categories_ConfiguresScrollExtentsFromAuthoredGeometry()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
// A wheel-scroll (no actual movement, Data0=0) is enough to configure
// PopupScroll from Items.Count/RowsPerColumn/RowHeight — the same
// "configure right before use" pattern UiItemList.LayoutCells follows.
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
Assert.Equal(18 * 18, menu.PopupScroll.ContentHeight); // 18 items * 18px row height
Assert.Equal(6 * 18, menu.PopupScroll.ViewHeight); // 6 visible rows (the authored window)
Assert.True(menu.PopupScroll.HasOverflow); // 18 > 6 -> scrollbar warranted
}
[Fact]
public void Scrollable_ClickInFirstVisibleRow_SelectsItemZero_ThroughTheRealHitPath()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
object? fired = null;
menu.OnSelect = p => fired = p;
// Row 0's vertical center — same "row * RowHeight + RowHeight/2" shape
// the existing grid-mode tests already use for their row math.
int ly = RawY(menu, menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
Assert.Equal(0, fired);
}
[Fact]
public void Scrollable_ClickInScrollbarColumn_DoesNotSelectAnItem_AndKeepsThePopupOpen()
{
// Before G5's fix, a click at this X (where the OLD grid math would have
// treated it as "column 1") could have picked the WRONG item entirely —
// this X now belongs to the scrollbar, which must never fire OnSelect.
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
var fired = new List<object?>();
menu.OnSelect = p => fired.Add(p);
// Middle of the scrollbar TRACK (below the up-button, above the down-button).
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
int trackMidY = RawY(menu, menu.RowsPerColumn * menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, trackMidY)));
Assert.Empty(fired); // scrollbar click never selects an item
// The popup stayed open — a subsequent item-column click still resolves
// (against whatever row is now visible after the scrollbar's page-scroll).
int rowLy = RawY(menu, menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), rowLy)));
Assert.Single(fired);
}
[Fact]
public void Scrollable_DownButtonClick_AdvancesByOneRow_AndSubsequentClickPicksTheAdvancedItem()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
// Down-button region: the bottom ScrollButtonExtent px of the scrollbar column.
int downX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
int downY = RawY(menu, menu.RowsPerColumn * menu.RowHeight - menu.ScrollButtonExtent / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, downX, downY)));
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY); // scrolled exactly one row
object? fired = null;
menu.OnSelect = p => fired = p;
// Row 0's ON-SCREEN position now shows item index 1 (the window advanced).
int ly = RawY(menu, menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
Assert.Equal(1, fired);
}
[Fact]
public void Scrollable_UpButtonClick_ReversesAPriorDownScroll()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5));
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
int downY = RawY(menu, menu.RowsPerColumn * menu.RowHeight - menu.ScrollButtonExtent / 2f);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, downY));
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, downY));
Assert.Equal((int)(2 * menu.RowHeight), menu.PopupScroll.ScrollY);
int upY = RawY(menu, menu.ScrollButtonExtent / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, upY)));
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY);
}
[Fact]
public void Scrollable_MouseWheel_ScrollsWhilePopupIsOpen()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
// Mirrors UiItemList's own wheel convention: +Y wheel (Data0>0) scrolls up/older.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: -1)));
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY);
}
[Fact]
public void Scrollable_ThumbDrag_MovesScrollPosition_AndReleaseKeepsThePopupOpen()
{
var menu = MakeScrollableMenu(18);
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
// Establish the thumb's starting rect the SAME way production drawing/
// hit-testing does: configure the model, then ask UiScrollbar's own
// shared geometry helper (the exact function DrawPopupScrollbar/
// HandleScrollablePopupMouseDown use internally).
int trackTopY = (int)menu.ScrollButtonExtent;
float trackLen = menu.RowsPerColumn * menu.RowHeight - 2 * menu.ScrollButtonExtent;
// Force PopupScroll into a known-configured state via a zero-delta wheel
// event before computing the thumb rect from it.
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
var (thumbY, thumbH) = UiScrollbar.ThumbRect(menu.PopupScroll, trackTopY, trackLen);
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
int pressY = RawY(menu, thumbY + thumbH / 2f); // press inside the thumb
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, pressY)));
// Drag most of the way down the track.
int dragToIy = (int)(trackTopY + trackLen - thumbH / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseMove, 0, scrollbarMidX, RawY(menu, dragToIy))));
Assert.True(menu.PopupScroll.ScrollY > 0);
Assert.True(menu.PopupScroll.PositionRatio > 0.5f);
// Releasing must NOT close the popup — a subsequent scrollbar/item click
// still resolves through the same OnEvent path.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseUp, 0, scrollbarMidX, RawY(menu, dragToIy))));
object? fired = null;
menu.OnSelect = p => fired = p;
int ly = RawY(menu, menu.RowHeight / 2f);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
Assert.NotNull(fired); // popup was still open -> the click landed on a real row
}
}

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()
{