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

@ -48,10 +48,16 @@ namespace AcDream.App.UI.Layout;
/// <para> /// <para>
/// <b>Lifecycle: pure state projection.</b> This controller owns no /// <b>Lifecycle: pure state projection.</b> This controller owns no
/// session/transaction state of its own. It opens/repopulates/closes purely /// session/transaction state of its own. It opens/repopulates/closes purely
/// by observing <see cref="VendorState.Changed"/>; the one mutation it may /// by observing <see cref="VendorState.Changed"/>. The close button (G4,
/// trigger is the close button calling <see cref="VendorState.Close"/> — /// vendor gate finding) does NOT mutate <see cref="VendorState"/> at all —
/// going through the owner (client-local per research §A.4, nothing on the /// see <see cref="CloseButtonPressed"/> for the retail citation
/// wire), never writing fields directly. /// (<c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c> case,
/// <c>pc:204147-204182</c>): with nothing staged it is a plain window hide,
/// leaving the session open in the background exactly like retail's
/// still-registered range watcher. <see cref="VendorState.Close"/> —
/// client-local per research §A.4, nothing on the wire — is reached only via
/// <see cref="RuntimeVendorRangeQuery.EnforceRange"/>'s distance check, never
/// from a direct field write here.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class VendorUiController : IRetainedPanelController public sealed class VendorUiController : IRetainedPanelController
@ -79,6 +85,19 @@ public sealed class VendorUiController : IRetainedPanelController
public const uint BuyingPageId = 0x100000C4u; public const uint BuyingPageId = 0x100000C4u;
public const uint SellingPageId = 0x100000CDu; public const uint SellingPageId = 0x100000CDu;
// G2 (vendor gate finding): the Buying/Selling pages author their OWN
// item strip + scrollbar pair, geometrically identical to the Items
// tab's (same X/Y/Width/Height, same cell-template attribute 0x1000000e
// -> prototype 0x1000033A, verified against the fixture). Staging
// (populating these lists with a player's held-for-sale / to-buy items)
// is still deferred (Slice 6 territory) — these ids exist ONLY so the
// authored empty-slot fill can be wired, matching the Items list's
// treatment instead of leaving a bare blue background.
public const uint BuyingListId = 0x100000C5u;
public const uint BuyingScrollbarId = 0x100000C6u;
public const uint SellingListId = 0x100000CEu;
public const uint SellingScrollbarId = 0x100000CFu;
/// <summary> /// <summary>
/// F1 (Slice 5.4 review): the category dropdown's authored popup. /// F1 (Slice 5.4 review): the category dropdown's authored popup.
/// Retail <c>UIElement_Menu::MakePopup</c> (<c>0x0046D310</c>, /// Retail <c>UIElement_Menu::MakePopup</c> (<c>0x0046D310</c>,
@ -124,6 +143,35 @@ public sealed class VendorUiController : IRetainedPanelController
/// the absent authored sprite exactly rather than inventing one. /// the absent authored sprite exactly rather than inventing one.
/// </para> /// </para>
/// <para> /// <para>
/// <b>G5 correction (vendor gate finding): it is a SCROLLABLE single
/// column, not a 3-column grid.</b> The F1 review's "column-major grid"
/// framing was wrong — a live-dat scan (<c>tools/VendorLayoutScan</c>,
/// <c>dump</c>/<c>resolved 0x21000043 0x1000034F</c>) shows
/// <c>0x1000034F</c> has TWO children, not one: the ListBox
/// <c>0x10000350</c> (100x108, resolved attribute <c>0x5E=6</c>/
/// <c>0x5F=1</c> — six rows, ONE column) AND a SIBLING
/// <c>UIElement_Scrollbar</c> (class <c>0xB</c>, element
/// <c>0x10000351</c>, 16x108, docked at X=100 immediately right of the
/// list, with a real thumb/up/down-button subtree matching
/// <see cref="UiScrollbar"/>'s own shape exactly: thumb caps
/// <c>0x06004C60</c>/<c>63</c>/<c>66</c>, up button (element
/// <c>0x10000071</c>) <c>0x06004C69</c>/<c>6A</c>/<c>6B</c>, down button
/// (element <c>0x10000072</c>) <c>0x06004C6C</c>/<c>6D</c>/<c>6E</c>,
/// track <c>0x06004C5F</c>). With 18 authored categories and only 6
/// visible rows, retail's actual rendering is a single scrolling column
/// (matching the user's reference screenshot: ~visible rows + scrollbar +
/// highlight — not our earlier 3-column x 6-row grid showing all 18 at
/// once). <see cref="UiMenu.Scrollable"/> switches the popup to this
/// shape; <see cref="UiMenu.RowsPerColumn"/> keeps its existing meaning
/// as the authored visible-row count (still 6 — 108px ListBox height /
/// 18px row height, now interpreted as "rows before scrolling" instead
/// of "rows before wrapping to a new column"). Chat's own popup
/// (LayoutDesc <c>0x21000006</c>, element <c>0x1000001C</c>) has NO
/// sibling scrollbar element and is unaffected —
/// <see cref="ChatWindowController"/> never sets <c>Scrollable</c>, so
/// it keeps the original grid path byte-identical.
/// </para>
/// <para>
/// The button FACE reuses the same two sprites: <c>0x060012B3</c> is /// The button FACE reuses the same two sprites: <c>0x060012B3</c> is
/// literally what vendor's OWN button-face child (<c>0x1000034D</c>) /// literally what vendor's OWN button-face child (<c>0x1000034D</c>)
/// resolves to in the fixture, and <c>0x060012B4</c> (the row /// resolves to in the fixture, and <c>0x060012B4</c> (the row
@ -150,6 +198,19 @@ public sealed class VendorUiController : IRetainedPanelController
private const uint TypeMenuNormalSprite = 0x060012B3u; private const uint TypeMenuNormalSprite = 0x060012B3u;
private const uint TypeMenuPressedSprite = 0x060012B4u; private const uint TypeMenuPressedSprite = 0x060012B4u;
// G5 (vendor gate finding): the popup's docked scrollbar (element
// 0x10000351, verified via tools/VendorLayoutScan against the live dat —
// see the class doc's "G5 correction" paragraph above). Width/button
// extent both 16px matching the authored element/child sizes exactly.
private const float TypeMenuScrollbarWidth = 16f;
private const float TypeMenuScrollButtonExtent = 16f;
private const uint TypeMenuScrollTrackSprite = 0x06004C5Fu;
private const uint TypeMenuScrollThumbTopSprite = 0x06004C60u;
private const uint TypeMenuScrollThumbSprite = 0x06004C63u;
private const uint TypeMenuScrollThumbBottomSprite = 0x06004C66u;
private const uint TypeMenuScrollUpSprite = 0x06004C69u;
private const uint TypeMenuScrollDownSprite = 0x06004C6Cu;
/// <summary> /// <summary>
/// Retail's ordered category table, transcribed verbatim from /// Retail's ordered category table, transcribed verbatim from
/// <c>VendorItemsUI::OpenVendor</c>'s <c>AddTypeFilter</c> call chain /// <c>VendorItemsUI::OpenVendor</c>'s <c>AddTypeFilter</c> call chain
@ -195,6 +256,10 @@ public sealed class VendorUiController : IRetainedPanelController
private readonly UiElement _buyingTab; private readonly UiElement _buyingTab;
private readonly UiElement _sellingTab; private readonly UiElement _sellingTab;
private readonly UiItemList _itemList; private readonly UiItemList _itemList;
// G2: presentation-only strips (empty-slot fill only, never populated —
// see the BuyingListId/SellingListId doc comments).
private readonly UiItemList? _buyingList;
private readonly UiItemList? _sellingList;
private readonly UiMenu _typeMenu; private readonly UiMenu _typeMenu;
private readonly UiText _itemNameText; private readonly UiText _itemNameText;
private readonly UiText _itemCostText; private readonly UiText _itemCostText;
@ -228,6 +293,10 @@ public sealed class VendorUiController : IRetainedPanelController
UiElement sellingTab, UiElement sellingTab,
UiItemList itemList, UiItemList itemList,
UiScrollbar? itemScrollbar, UiScrollbar? itemScrollbar,
UiItemList? buyingList,
UiScrollbar? buyingScrollbar,
UiItemList? sellingList,
UiScrollbar? sellingScrollbar,
UiMenu typeMenu, UiMenu typeMenu,
UiText itemNameText, UiText itemNameText,
UiText itemCostText, UiText itemCostText,
@ -237,7 +306,9 @@ public sealed class VendorUiController : IRetainedPanelController
UiDatFont? datFont, UiDatFont? datFont,
BitmapFont? debugFont, BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolveSprite, Func<uint, (uint tex, int w, int h)> resolveSprite,
uint emptySlotSprite) uint emptySlotSprite,
uint buyingEmptySlotSprite,
uint sellingEmptySlotSprite)
{ {
_vendor = vendor; _vendor = vendor;
_window = window; _window = window;
@ -254,6 +325,8 @@ public sealed class VendorUiController : IRetainedPanelController
_buyingTab = buyingTab; _buyingTab = buyingTab;
_sellingTab = sellingTab; _sellingTab = sellingTab;
_itemList = itemList; _itemList = itemList;
_buyingList = buyingList;
_sellingList = sellingList;
_typeMenu = typeMenu; _typeMenu = typeMenu;
_itemNameText = itemNameText; _itemNameText = itemNameText;
_itemCostText = itemCostText; _itemCostText = itemCostText;
@ -295,6 +368,25 @@ public sealed class VendorUiController : IRetainedPanelController
itemScrollbar.Horizontal = true; itemScrollbar.Horizontal = true;
} }
// G2 (vendor gate finding): Buying/Selling get the SAME empty-slot
// fill treatment as the Items strip above — presentation only, the
// lists are never populated (staging into these tabs stays Slice 6
// territory, unimplemented). Mounting here (rather than skipping
// entirely) is what replaces the bare blue authored background with
// the correct empty-cell art the instant the panel opens.
ConfigureEmptyStrip(_buyingList, buyingEmptySlotSprite);
if (buyingScrollbar is not null && _buyingList is not null)
{
buyingScrollbar.Model = _buyingList.Scroll;
buyingScrollbar.Horizontal = true;
}
ConfigureEmptyStrip(_sellingList, sellingEmptySlotSprite);
if (sellingScrollbar is not null && _sellingList is not null)
{
sellingScrollbar.Model = _sellingList.Scroll;
sellingScrollbar.Horizontal = true;
}
// F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers // F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers
// (UiMenu draws nothing without SpriteResolve — see the popup // (UiMenu draws nothing without SpriteResolve — see the popup
// geometry class doc above) and the vendor-authored popup geometry // geometry class doc above) and the vendor-authored popup geometry
@ -309,6 +401,18 @@ public sealed class VendorUiController : IRetainedPanelController
_typeMenu.RowsPerColumn = TypeMenuRowsPerColumn; _typeMenu.RowsPerColumn = TypeMenuRowsPerColumn;
_typeMenu.RowHeight = TypeMenuRowHeight; _typeMenu.RowHeight = TypeMenuRowHeight;
_typeMenu.ColumnWidth = TypeMenuColumnWidth; _typeMenu.ColumnWidth = TypeMenuColumnWidth;
// G5: the authored popup is a scrollable single column with a docked
// scrollbar, not a column-major grid — see the class doc's "G5
// correction" paragraph above.
_typeMenu.Scrollable = true;
_typeMenu.ScrollbarWidth = TypeMenuScrollbarWidth;
_typeMenu.ScrollButtonExtent = TypeMenuScrollButtonExtent;
_typeMenu.ScrollTrackSprite = TypeMenuScrollTrackSprite;
_typeMenu.ScrollThumbTopSprite = TypeMenuScrollThumbTopSprite;
_typeMenu.ScrollThumbSprite = TypeMenuScrollThumbSprite;
_typeMenu.ScrollThumbBottomSprite = TypeMenuScrollThumbBottomSprite;
_typeMenu.ScrollUpSprite = TypeMenuScrollUpSprite;
_typeMenu.ScrollDownSprite = TypeMenuScrollDownSprite;
_typeMenu.OnSelect = payload => _typeMenu.OnSelect = payload =>
{ {
if (payload is uint mask) SelectCategory(mask); if (payload is uint mask) SelectCategory(mask);
@ -322,7 +426,7 @@ public sealed class VendorUiController : IRetainedPanelController
RetailTabBinding.SetClick(_buyingTab, () => ShowTab(VendorPanelTab.Buying)); RetailTabBinding.SetClick(_buyingTab, () => ShowTab(VendorPanelTab.Buying));
RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling)); RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling));
if (_close is not null) if (_close is not null)
_close.OnClick = () => _vendor.Close(); _close.OnClick = CloseButtonPressed;
// Slice 6.3: retail gmVendorUI::HandleButtonClicks' 0x100000C2 case — // Slice 6.3: retail gmVendorUI::HandleButtonClicks' 0x100000C2 case —
// BuySingleItem(selectedID) — an immediate single-item purchase, no // BuySingleItem(selectedID) — an immediate single-item purchase, no
// staging list required (research doc §B.1). // staging list required (research doc §B.1).
@ -398,6 +502,14 @@ public sealed class VendorUiController : IRetainedPanelController
/// <param name="debugFont">Fallback debug bitmap font (used when <paramref name="datFont"/> is null).</param> /// <param name="debugFont">Fallback debug bitmap font (used when <paramref name="datFont"/> is null).</param>
/// <param name="resolveSprite">Dat RenderSurface id → (GL tex handle, px width, px height).</param> /// <param name="resolveSprite">Dat RenderSurface id → (GL tex handle, px width, px height).</param>
/// <param name="emptySlotSprite">Authored empty-slot background for the item strip, or 0 for none.</param> /// <param name="emptySlotSprite">Authored empty-slot background for the item strip, or 0 for none.</param>
/// <param name="buyingEmptySlotSprite">
/// G2: authored empty-slot background for the Buying tab's own item
/// strip (<see cref="BuyingListId"/>), or 0 for none.
/// </param>
/// <param name="sellingEmptySlotSprite">
/// G2: authored empty-slot background for the Selling tab's own item
/// strip (<see cref="SellingListId"/>), or 0 for none.
/// </param>
public static VendorUiController? Bind( public static VendorUiController? Bind(
ImportedLayout layout, ImportedLayout layout,
VendorState vendor, VendorState vendor,
@ -411,7 +523,9 @@ public sealed class VendorUiController : IRetainedPanelController
UiDatFont? datFont, UiDatFont? datFont,
BitmapFont? debugFont, BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolveSprite, Func<uint, (uint tex, int w, int h)> resolveSprite,
uint emptySlotSprite = 0u) uint emptySlotSprite = 0u,
uint buyingEmptySlotSprite = 0u,
uint sellingEmptySlotSprite = 0u)
{ {
ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(vendor); ArgumentNullException.ThrowIfNull(vendor);
@ -442,6 +556,12 @@ public sealed class VendorUiController : IRetainedPanelController
UiScrollbar? itemScrollbar = layout.FindElement(ItemScrollbarId) as UiScrollbar; UiScrollbar? itemScrollbar = layout.FindElement(ItemScrollbarId) as UiScrollbar;
UiButton? buyButton = layout.FindElement(BuyButtonId) as UiButton; UiButton? buyButton = layout.FindElement(BuyButtonId) as UiButton;
UiButton? addButton = layout.FindElement(AddButtonId) as UiButton; UiButton? addButton = layout.FindElement(AddButtonId) as UiButton;
// G2: optional — presentation-only strips, absent gracefully no-ops
// (see the class-level BuyingListId/SellingListId doc comments).
UiItemList? buyingList = layout.FindElement(BuyingListId) as UiItemList;
UiScrollbar? buyingScrollbar = layout.FindElement(BuyingScrollbarId) as UiScrollbar;
UiItemList? sellingList = layout.FindElement(SellingListId) as UiItemList;
UiScrollbar? sellingScrollbar = layout.FindElement(SellingScrollbarId) as UiScrollbar;
return new VendorUiController( return new VendorUiController(
vendor, vendor,
@ -460,6 +580,10 @@ public sealed class VendorUiController : IRetainedPanelController
sellingTab, sellingTab,
itemList, itemList,
itemScrollbar, itemScrollbar,
buyingList,
buyingScrollbar,
sellingList,
sellingScrollbar,
typeMenu, typeMenu,
itemNameText, itemNameText,
itemCostText, itemCostText,
@ -469,7 +593,9 @@ public sealed class VendorUiController : IRetainedPanelController
datFont, datFont,
debugFont, debugFont,
resolveSprite, resolveSprite,
emptySlotSprite); emptySlotSprite,
buyingEmptySlotSprite,
sellingEmptySlotSprite);
} }
private enum VendorPanelTab { Items, Buying, Selling } private enum VendorPanelTab { Items, Buying, Selling }
@ -906,9 +1032,14 @@ public sealed class VendorUiController : IRetainedPanelController
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
string verb = quantity <= 1 ? "costs" : "cost"; string verb = quantity <= 1 ? "costs" : "cost";
// G1 (vendor gate finding): retail's literal format is
// "%s %hsp (you have %hsp)" (pc:202769) — the trailing "p" after
// each %hs is a LITERAL pyreal-currency suffix character, not part
// of the specifier. The prior port read "%hsp" as one token and
// dropped both literal p's.
return string.Format( return string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"{0} {1} (you have {2})", "{0} {1}p (you have {2}p)",
verb, verb,
price.ToString("N0", CultureInfo.InvariantCulture), price.ToString("N0", CultureInfo.InvariantCulture),
playerTotal.ToString("N0", CultureInfo.InvariantCulture)); playerTotal.ToString("N0", CultureInfo.InvariantCulture));
@ -1002,6 +1133,52 @@ public sealed class VendorUiController : IRetainedPanelController
_vendor.Profile.AlternateCurrencyWcid); _vendor.Profile.AlternateCurrencyWcid);
} }
/// <summary>
/// G4 (vendor gate finding): port of retail's close/pushpin button
/// handler — <c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c>
/// case (<c>pc:204147-204182</c>). Retail branches on whether the
/// Buying/Selling staging lists (<c>m_buyList</c>/<c>m_sellList</c>) hold
/// anything uncommitted: with nothing pending it calls ONLY
/// <c>this-&gt;vtable-&gt;SetVisible(0)</c> — a plain window hide, NOT
/// <c>gmVendorUI::CloseVendor</c> (<c>pc:202080</c>, the range-watcher-
/// unregister/session-teardown function <see cref="VendorState.Close"/>
/// ports). Only when something IS pending does retail show a
/// confirmation dialog ("You have not completed all transactions...")
/// whose Yes callback (<c>gmVendorUI::CloseVendorDialogCallback</c>,
/// <c>pc:202104-202166</c>) is what actually reaches
/// <c>CM_Vendor::SendNotice_CloseVendor</c> — itself an internal
/// notice-bus fanout to local UI listeners, not a network send (see the
/// class doc's A.4 citation: retail's close path never puts anything on
/// the wire either way).
/// <para>
/// This controller's staging lists are ALWAYS empty (Slice 6 territory —
/// the "Buying"/"Selling" tabs render but are never populated, see the
/// class doc's "Three tabs, not two" note), so retail's
/// <c>m_buyList.head == 0 &amp;&amp; m_sellList.head == 0</c> condition
/// is vacuously true for every close today — the confirmation-dialog
/// branch has no reachable case yet and is deliberately not ported;
/// revisit once staging lands.
/// </para>
/// <para>
/// <b>Behavior change from the prior port.</b> This button used to call
/// <see cref="VendorState.Close"/> directly — a full session teardown
/// (VendorId/Profile/Items cleared, every materialized shop item
/// retired) on every ordinary close, which retail does NOT do. The
/// session now stays open in the background exactly like retail's
/// hidden-but-still-registered range watcher:
/// <see cref="RuntimeVendorRangeQuery.EnforceRange"/> is evaluated
/// unconditionally every frame regardless of window visibility (it reads
/// only <see cref="VendorState.VendorId"/>, never this window's
/// <c>IsVisible</c>), so leaving <c>UseRadius</c> still converges to a
/// full <see cref="VendorState.Close"/> exactly as before. Re-approaching
/// the SAME vendor before then now lands on retail's <c>sameVendor==1</c>
/// refresh-in-place path (<see cref="VendorStateTransitionKind.Refreshed"/>,
/// which preserves the player's category selection) instead of a full
/// from-scratch <see cref="VendorStateTransitionKind.Opened"/> reopen.
/// </para>
/// </summary>
private void CloseButtonPressed() => _window.Hide();
private void ClearContent() private void ClearContent()
{ {
_presentCategories.Clear(); _presentCategories.Clear();
@ -1018,6 +1195,39 @@ public sealed class VendorUiController : IRetainedPanelController
ClearSelectionDisplay(); ClearSelectionDisplay();
} }
/// <summary>
/// G2 (vendor gate finding): mirrors the Items strip's empty-slot-fill
/// configuration (see the constructor's <c>_itemList</c> block) for the
/// Buying/Selling pages' own authored lists — presentation only. The
/// list is flushed once (dropping the single default cell every
/// <see cref="UiItemList"/> constructs itself with) so every visible
/// cell comes from <paramref name="list"/>'s own
/// <see cref="UiItemList.EmptySlotFactory"/> with consistent styling
/// (non-drag-source), then left alone: nothing ever calls
/// <see cref="UiItemList.AddItem"/> on it, so
/// <see cref="UiItemList.LayoutCells"/>'s empty-slot padding
/// (<c>UpdateEmptySlots</c>) is the ONLY thing that ever populates it.
/// </summary>
private static void ConfigureEmptyStrip(UiItemList? list, uint emptySlotSprite)
{
if (list is null) return;
list.Flush();
list.Columns = 1;
list.SingleRow = true;
list.HorizontalScroll = true;
list.CellWidth = 32f;
list.CellHeight = 32f;
list.FillVisibleEmptySlots = true;
if (emptySlotSprite != 0u)
list.CellEmptySprite = emptySlotSprite;
list.EmptySlotFactory = () => new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
AllowDragSource = false,
};
}
private static void SetPlainText(UiText text, string value) private static void SetPlainText(UiText text, string value)
{ {
IReadOnlyList<UiText.Line> lines = string.IsNullOrEmpty(value) IReadOnlyList<UiText.Line> lines = string.IsNullOrEmpty(value)

View file

@ -1943,6 +1943,8 @@ public sealed class RetailUiRuntime : IDisposable
{ {
ImportedLayout? layout; ImportedLayout? layout;
uint emptySlotSprite; uint emptySlotSprite;
uint buyingEmptySlotSprite;
uint sellingEmptySlotSprite;
lock (_bindings.Assets.DatLock) lock (_bindings.Assets.DatLock)
{ {
layout = LayoutImporter.Import( layout = LayoutImporter.Import(
@ -1959,6 +1961,18 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.Dats, _bindings.Assets.Dats,
VendorUiController.LayoutId, VendorUiController.LayoutId,
VendorUiController.ItemListId); VendorUiController.ItemListId);
// G2 (vendor gate finding): the Buying/Selling pages author
// their OWN item strips (same cell-template prototype as the
// Items list per the fixture, but resolved independently here
// rather than assumed identical).
buyingEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
VendorUiController.LayoutId,
VendorUiController.BuyingListId);
sellingEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
VendorUiController.LayoutId,
VendorUiController.SellingListId);
} }
if (layout is null) if (layout is null)
{ {
@ -2013,7 +2027,9 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont, _bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont, _bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,
emptySlotSprite); emptySlotSprite,
buyingEmptySlotSprite,
sellingEmptySlotSprite);
if (VendorController is null) if (VendorController is null)
{ {
Console.WriteLine("[M4] vendor: required authored controls are missing."); Console.WriteLine("[M4] vendor: required authored controls are missing.");

View file

@ -202,6 +202,22 @@ public class UiItemSlot : UiElement
/// vendor rows regardless of ItemId.</summary> /// vendor rows regardless of ItemId.</summary>
public override bool IsDragSource => ItemId != 0 && AllowDragSource; public override bool IsDragSource => ItemId != 0 && AllowDragSource;
/// <summary>
/// G3 fix (vendor gate finding): a slot CAPTURES its own press/click the instant it
/// is occupied, independently of whether it is ALSO a drag source. Before this,
/// <see cref="UiRoot"/>'s mousedown dispatch only claimed the press via
/// <see cref="IsDragSource"/> (checked first) — for a drag-allowed cell that's fine,
/// but the F3 fix that introduced <see cref="AllowDragSource"/> made vendor/salvage
/// rows occupied-yet-<c>IsDragSource == false</c>, so they fell all the way through
/// UiRoot's chain to the window-move fallback: hovering showed the move-window
/// cursor (<see cref="UiRoot.HoverWindowMove"/> also reads this flag) and a press
/// dragged the whole panel instead of selecting the row. Setting this true for any
/// occupied cell restores the capture without reopening the drag-source gate:
/// <see cref="UiRoot"/> checks <see cref="IsDragSource"/> BEFORE
/// <see cref="UiElement.HandlesClick"/>, so drag-allowed occupied cells are
/// unaffected — this only changes the occupied-but-non-drag-source case.</summary>
public override bool HandlesClick => ItemId != 0;
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary> /// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
protected UiItemList? FindList() protected UiItemList? FindList()
{ {

View file

@ -34,10 +34,70 @@ public sealed class UiMenu : UiElement
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary> /// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
public Func<string>? ButtonLabelProvider { get; set; } public Func<string>? ButtonLabelProvider { get; set; }
public int RowsPerColumn { get; set; } = 7; // items per column (dat item template) public int RowsPerColumn { get; set; } = 7; // items per column (dat item template);
// ALSO the visible-row window height when Scrollable
public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17 public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17
public float ColumnWidth { get; set; } = 191f; // dat item template W=191 public float ColumnWidth { get; set; } = 191f; // dat item template W=191
/// <summary>
/// G5 (vendor gate finding): retail's authored vendor category popup
/// (LayoutDesc <c>0x21000043</c>, root <c>0x1000034F</c>) pairs its
/// ListBox (element <c>0x10000350</c>, type <c>0x5</c>) with a SIBLING
/// <c>UIElement_Scrollbar</c> (element <c>0x10000351</c>, type <c>0xB</c>,
/// 16px wide, docked immediately right of the list at x=100) — verified
/// via a live-dat scan (<c>tools/VendorLayoutScan</c>) against
/// <c>client_local_English.dat</c>: the ListBox reads a single-column
/// shape (attributes resolving to <c>m_nCols=1</c>/<c>m_nRows=6</c>) and
/// the row template (<c>0x10000352</c>) is 100×18 — a SCROLLABLE single
/// column with 6 visible rows, not our earlier column-major grid
/// approximation (which showed all 18 categories at once across 3
/// columns, never matching the retail screenshot's ~one-column-with-
/// scrollbar look). <see cref="RowsPerColumn"/> becomes the VISIBLE ROW
/// COUNT in this mode (still authored-driven — 108px ListBox height / 18px
/// row height = 6). Chat's own popup (LayoutDesc <c>0x21000006</c>) has
/// NO sibling scrollbar element and keeps the class default false — the
/// legacy column-major grid path below is untouched for it.
/// </summary>
public bool Scrollable { get; set; }
/// <summary>
/// Vertical scroll model for the popup when <see cref="Scrollable"/> is
/// set. Content/view/line extents are (re)computed every draw from
/// <see cref="Items"/>.Count / <see cref="RowsPerColumn"/> /
/// <see cref="RowHeight"/>, mirroring how <see cref="UiItemList"/>
/// configures its own <c>Scroll</c> before every layout pass. Exposed so
/// a controller/test can assert or drive scroll position directly (the
/// popup owns no separate live <see cref="UiScrollbar"/> CHILD widget —
/// see the scrollbar chrome properties below for why).
/// </summary>
public UiScrollable PopupScroll { get; } = new();
/// <summary>
/// Authored width of the popup's docked scrollbar (16px, element
/// <c>0x10000351</c>'s own Width).
/// </summary>
public float ScrollbarWidth { get; set; } = 16f;
/// <summary>Authored extent of the up/down buttons along the scrollbar's
/// own axis (16px, elements <c>0x10000071</c>/<c>0x10000072</c>'s own Height —
/// same convention as <see cref="UiScrollbar.DecrementButtonExtent"/>).</summary>
public float ScrollButtonExtent { get; set; } = 16f;
// Scrollbar chrome sprites. UiMenu draws these itself (rather than hosting a
// live UiScrollbar child) because the popup renders in the OVERLAY pass (see
// OnDrawOverlay's doc comment) — a normal child widget would draw in the
// regular main pass and suffer the exact translucent-sibling artifact that
// pass exists to avoid. The geometry math is shared with UiScrollbar via its
// public static ThumbRect helper, so both draw identical thumbs.
public uint ScrollTrackSprite { get; set; }
public uint ScrollThumbSprite { get; set; }
public uint ScrollThumbTopSprite { get; set; }
public uint ScrollThumbBottomSprite { get; set; }
public uint ScrollUpSprite { get; set; }
public uint ScrollDownSprite { get; set; }
private bool _draggingPopupThumb;
private float _popupThumbDragOffset;
private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px)
// The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px
// square; the label starts just past it (box width + small gap) so text aligns with // square; the label starts just past it (box width + small gap) so text aligns with
@ -72,8 +132,14 @@ public sealed class UiMenu : UiElement
private bool _open; private bool _open;
// Interior = the row content; Outer = interior + the 8-piece bevel ring. // Interior = the row content; Outer = interior + the 8-piece bevel ring.
private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); // Scrollable: always exactly one column (RowsPerColumn is the VISIBLE window,
private float InteriorW => ColumnCount * ColumnWidth; // not a wrap threshold), widened by the docked scrollbar's own authored width.
private int ColumnCount => Scrollable
? 1
: (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
private float InteriorW => Scrollable
? ColumnWidth + ScrollbarWidth
: ColumnCount * ColumnWidth;
private float InteriorH => RowsPerColumn * RowHeight; private float InteriorH => RowsPerColumn * RowHeight;
private float OuterW => InteriorW + 2 * Border; private float OuterW => InteriorW + 2 * Border;
private float OuterH => InteriorH + 2 * Border; private float OuterH => InteriorH + 2 * Border;
@ -130,13 +196,24 @@ public sealed class UiMenu : UiElement
var resolve = SpriteResolve; var resolve = SpriteResolve;
if (!_open || resolve is null) return; if (!_open || resolve is null) return;
// Column-major popup opening UPWARD from the button, wrapped in the universal // Force OPAQUE (a menu reads solid even though the chat window is translucent).
// 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a // Draw bevel → panel fill → row sprites → labels, all through the sprite bucket
// bevelled floating window). Force OPAQUE (a menu reads solid even though the // in submission order so labels land on top.
// chat window is translucent). Draw bevel → panel fill → row sprites → labels,
// all through the sprite bucket in submission order so labels land on top.
ctx.PushAlphaAbsolute(1f); ctx.PushAlphaAbsolute(1f);
try try
{
if (Scrollable)
DrawScrollablePopup(ctx, resolve);
else
DrawGridPopup(ctx, resolve);
}
finally { ctx.PopAlpha(); }
}
/// <summary>Legacy column-major popup (chat's own shape — no authored sibling
/// scrollbar element; see <see cref="Scrollable"/>'s doc comment). Unchanged from
/// before G5.</summary>
private void DrawGridPopup(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{ {
float outerTop = -OuterH; // popup bottom sits at the button top (y=0) float outerTop = -OuterH; // popup bottom sits at the button top (y=0)
float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel) float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel)
@ -162,7 +239,97 @@ public sealed class UiMenu : UiElement
avail ? TextColorAvailable : TextColorGhosted); avail ? TextColorAvailable : TextColorGhosted);
} }
} }
finally { ctx.PopAlpha(); }
/// <summary>
/// G5: single-column popup with a docked scrollbar — port of the vendor category
/// dropdown's authored shape (LayoutDesc <c>0x21000043</c>, see <see cref="Scrollable"/>'s
/// doc comment). Draws exactly <see cref="RowsPerColumn"/> rows (the authored visible
/// window), sliced from <see cref="Items"/> starting at <see cref="VisibleTopRow"/>, plus
/// the scrollbar chrome using the SAME thumb geometry <see cref="UiScrollbar"/> itself
/// uses (<see cref="UiScrollbar.ThumbRect"/>).
/// </summary>
private void DrawScrollablePopup(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
ConfigurePopupScroll();
float outerTop = -OuterH;
float inX = Border, inY = outerTop + Border;
DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH);
DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, ColumnWidth, InteriorH);
int start = VisibleTopRow;
int count = System.Math.Min(RowsPerColumn, Items.Count - start);
float textY = (RowHeight - LineH()) * 0.5f;
for (int i = 0; i < count; i++)
{
int idx = start + i;
float y = inY + i * RowHeight;
bool selected = Equals(Items[idx].Payload, Selected);
DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, inX, y, ColumnWidth, RowHeight);
}
for (int i = 0; i < count; i++)
{
int idx = start + i;
float y = inY + i * RowHeight;
bool avail = EnabledProvider?.Invoke(Items[idx].Payload) ?? true;
DrawLabel(ctx, Items[idx].Label, inX + TextIndent, y + textY,
avail ? TextColorAvailable : TextColorGhosted);
}
DrawPopupScrollbar(ctx, resolve, inX + ColumnWidth, inY);
}
/// <summary>Recomputes <see cref="PopupScroll"/>'s extents from the current item
/// count/geometry — mirrors <see cref="UiItemList.LayoutCells"/>'s own "configure the
/// shared scroll model right before using it" pattern.</summary>
private void ConfigurePopupScroll()
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
PopupScroll.LineHeight = lineHeight;
PopupScroll.SetExtents(Items.Count * lineHeight, RowsPerColumn * lineHeight);
}
/// <summary>Index of the first visible row — nearest-row snap of the (possibly
/// mid-drag, pixel-continuous) scroll offset, so drawn rows never render partially
/// clipped.</summary>
private int VisibleTopRow
{
get
{
int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight));
int maxStart = System.Math.Max(0, Items.Count - RowsPerColumn);
int row = (int)MathF.Round((float)PopupScroll.ScrollY / lineHeight);
return System.Math.Clamp(row, 0, maxStart);
}
}
private void DrawPopupScrollbar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve, float x, float y)
{
DrawSprite(ctx, resolve, ScrollTrackSprite, x, y, ScrollbarWidth, InteriorH);
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
DrawSprite(ctx, resolve, ScrollUpSprite, x, y, ScrollbarWidth, decExtent);
DrawSprite(ctx, resolve, ScrollDownSprite, x, y + InteriorH - incExtent, ScrollbarWidth, incExtent);
if (!PopupScroll.HasOverflow) return;
float trackTop = decExtent;
float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
const float capH = 3f;
if (ScrollThumbTopSprite != 0 && ScrollThumbBottomSprite != 0 && th >= 2f * capH)
{
DrawSprite(ctx, resolve, ScrollThumbTopSprite, x, y + ty, ScrollbarWidth, capH);
DrawSprite(ctx, resolve, ScrollThumbSprite, x, y + ty + capH, ScrollbarWidth, th - 2f * capH);
DrawSprite(ctx, resolve, ScrollThumbBottomSprite, x, y + ty + th - capH, ScrollbarWidth, capH);
}
else
{
DrawSprite(ctx, resolve, ScrollThumbSprite, x, y + ty, ScrollbarWidth, th);
}
} }
/// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges + /// <summary>Draw the universal 8-piece retail window bevel (corners + tiled edges +
@ -210,6 +377,31 @@ public sealed class UiMenu : UiElement
public override bool OnEvent(in UiEvent e) public override bool OnEvent(in UiEvent e)
{ {
// G5: scrollbar drag/wheel handling for the scrollable popup. Checked BEFORE
// the MouseDown-only early return below since these span MouseMove/Scroll too.
if (Scrollable && _open)
{
if (e.Type == UiEventType.MouseMove && _draggingPopupThumb)
{
DragPopupThumb(e.Data2);
return true;
}
if (e.Type == UiEventType.MouseUp && _draggingPopupThumb)
{
// Ending a thumb drag must not also close the popup — UiRoot fires a
// trailing Click on the same target after MouseUp, which this class
// does not handle (falls through as a no-op), so the popup stays open.
_draggingPopupThumb = false;
return true;
}
if (e.Type == UiEventType.Scroll)
{
ConfigurePopupScroll();
PopupScroll.ScrollByLines(-e.Data0);
return true;
}
}
if (e.Type != UiEventType.MouseDown) return false; if (e.Type != UiEventType.MouseDown) return false;
float lx = e.Data1, ly = e.Data2; float lx = e.Data1, ly = e.Data2;
@ -218,6 +410,9 @@ public sealed class UiMenu : UiElement
// Map into the bevel interior, then to (col,row). Clicks in the bevel ring // Map into the bevel interior, then to (col,row). Clicks in the bevel ring
// (outside the interior) just close the menu. // (outside the interior) just close the menu.
float ix = lx - Border, iy = ly - (-OuterH + Border); float ix = lx - Border, iy = ly - (-OuterH + Border);
if (Scrollable)
return HandleScrollablePopupMouseDown(ix, iy);
if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH) if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH)
{ {
int col = (int)(ix / ColumnWidth); int col = (int)(ix / ColumnWidth);
@ -243,4 +438,73 @@ public sealed class UiMenu : UiElement
_open = !_open; // toggle on button click _open = !_open; // toggle on button click
return true; return true;
} }
/// <summary>
/// G5: mouse-down dispatch for the scrollable popup — a click on the item
/// column picks a row (offset by the current scroll position, closing the
/// popup exactly like the grid path); a click on the scrollbar's up/down
/// buttons, track, or thumb drives scrolling and does NOT close the popup
/// (mirrors <see cref="UiScrollbar.OnEvent"/>'s own MouseDown shape).
/// </summary>
private bool HandleScrollablePopupMouseDown(float ix, float iy)
{
if (ix >= 0 && ix < ColumnWidth && iy >= 0 && iy < InteriorH)
{
int row = (int)(iy / RowHeight);
int idx = VisibleTopRow + row;
if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
&& (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
{
OnSelect?.Invoke(Items[idx].Payload);
}
_open = false;
return true;
}
float scrollbarX = ColumnWidth;
if (ix >= scrollbarX && ix < scrollbarX + ScrollbarWidth && iy >= 0 && iy < InteriorH)
{
ConfigurePopupScroll();
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
if (iy < decExtent) { PopupScroll.ScrollByLines(-1); return true; }
if (iy >= InteriorH - incExtent) { PopupScroll.ScrollByLines(1); return true; }
float trackTop = decExtent;
float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
var (ty, th) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
if (iy >= ty && iy <= ty + th)
{
_draggingPopupThumb = true;
_popupThumbDragOffset = iy - ty;
}
else
{
PopupScroll.ScrollByPage(iy < ty ? -1 : 1);
}
return true; // scrollbar interaction never closes the popup
}
// Clicked the bevel ring — close, matching the grid path.
_open = false;
return true;
}
/// <summary>Continues an in-progress thumb drag (<see cref="_draggingPopupThumb"/>);
/// mirrors <see cref="UiScrollbar.OnEvent"/>'s own <c>MouseMove when _draggingThumb</c>
/// case, reusing <see cref="UiScrollbar.ThumbRect"/> for the exact same thumb height.</summary>
private void DragPopupThumb(float ly)
{
float iy = ly - (-OuterH + Border);
ConfigurePopupScroll();
float decExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH);
float incExtent = System.Math.Clamp(ScrollButtonExtent, 0f, InteriorH - decExtent);
float trackTop = decExtent;
float trackLen = MathF.Max(0f, InteriorH - decExtent - incExtent);
var (_, thumbH) = UiScrollbar.ThumbRect(PopupScroll, trackTop, trackLen);
float travel = MathF.Max(1f, trackLen - thumbH);
float ratio = (iy - _popupThumbDragOffset - trackTop) / travel;
PopupScroll.SetPositionRatio(ratio);
}
} }

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(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30)
Assert.Equal(300f, frame.Top); // y unchanged (310-10=300) 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); 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 private sealed class Harness
{ {
// F2/F3: a deterministic non-zero player coin total so the cost-text // 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"). // to the singular name UNCHANGED (not an invented "Arrows" + "s").
Assert.Equal("100 Arrows", GetText(h.ItemNameText)); Assert.Equal("100 Arrows", GetText(h.ItemNameText));
Assert.Equal( Assert.Equal(
$"cost {2000:N0} (you have {Harness.DefaultPlayerCoinValue:N0})", $"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText)); GetText(h.ItemCostText));
} }
@ -338,7 +414,7 @@ public sealed class VendorUiControllerTests
Assert.Equal("Bread", GetText(h.ItemNameText)); Assert.Equal("Bread", GetText(h.ItemNameText));
Assert.Equal( Assert.Equal(
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})", $"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText)); GetText(h.ItemCostText));
} }
@ -386,7 +462,7 @@ public sealed class VendorUiControllerTests
// selection. // selection.
Assert.Equal("Arrows", GetText(h.ItemNameText)); Assert.Equal("Arrows", GetText(h.ItemNameText));
Assert.Equal( Assert.Equal(
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})", $"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText)); GetText(h.ItemCostText));
// Player drags the slider to 40 AFTER selecting -- no re-click, no // 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. // SellPrice = ceil(2.0*10*40 - 0.1) = 800.
Assert.Equal("40 Arrows", GetText(h.ItemNameText)); Assert.Equal("40 Arrows", GetText(h.ItemNameText));
Assert.Equal( Assert.Equal(
$"cost {800:N0} (you have {Harness.DefaultPlayerCoinValue:N0})", $"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
GetText(h.ItemCostText)); GetText(h.ItemCostText));
h.BuyButton.OnClick!.Invoke(); 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.NotEqual(0u, cell.ItemId); // occupied -- would otherwise be a drag source by default
Assert.False(cell.IsDragSource); Assert.False(cell.IsDragSource);
Assert.Null(cell.GetDragPayload()); 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] [Fact]
@ -720,8 +835,22 @@ public sealed class VendorUiControllerTests
} }
[Fact] [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(); var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[] h.State.Apply(VendorGuid, Profile(), new[]
{ {
@ -730,12 +859,25 @@ public sealed class VendorUiControllerTests
h.CloseButton.OnClick!.Invoke(); 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); 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] [Fact]

View file

@ -175,4 +175,192 @@ public class UiMenuTests
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
Assert.Equal(ChatChannelKind.Fellowship, fired); 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); 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] [Fact]
public void VendorId_IsTheLiveActiveVendorIdSeamSource() public void VendorId_IsTheLiveActiveVendorIdSeamSource()
{ {