diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index f3b341d6..7e7f9399 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -48,10 +48,16 @@ namespace AcDream.App.UI.Layout; /// /// Lifecycle: pure state projection. This controller owns no /// session/transaction state of its own. It opens/repopulates/closes purely -/// by observing ; the one mutation it may -/// trigger is the close button calling — -/// going through the owner (client-local per research §A.4, nothing on the -/// wire), never writing fields directly. +/// by observing . The close button (G4, +/// vendor gate finding) does NOT mutate at all — +/// see for the retail citation +/// (gmVendorUI::HandleButtonClicks's 0x100000d6 case, +/// pc:204147-204182): with nothing staged it is a plain window hide, +/// leaving the session open in the background exactly like retail's +/// still-registered range watcher. — +/// client-local per research §A.4, nothing on the wire — is reached only via +/// 's distance check, never +/// from a direct field write here. /// /// public sealed class VendorUiController : IRetainedPanelController @@ -79,6 +85,19 @@ public sealed class VendorUiController : IRetainedPanelController public const uint BuyingPageId = 0x100000C4u; 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; + /// /// F1 (Slice 5.4 review): the category dropdown's authored popup. /// Retail UIElement_Menu::MakePopup (0x0046D310, @@ -124,6 +143,35 @@ public sealed class VendorUiController : IRetainedPanelController /// the absent authored sprite exactly rather than inventing one. /// /// + /// G5 correction (vendor gate finding): it is a SCROLLABLE single + /// column, not a 3-column grid. The F1 review's "column-major grid" + /// framing was wrong — a live-dat scan (tools/VendorLayoutScan, + /// dump/resolved 0x21000043 0x1000034F) shows + /// 0x1000034F has TWO children, not one: the ListBox + /// 0x10000350 (100x108, resolved attribute 0x5E=6/ + /// 0x5F=1 — six rows, ONE column) AND a SIBLING + /// UIElement_Scrollbar (class 0xB, element + /// 0x10000351, 16x108, docked at X=100 immediately right of the + /// list, with a real thumb/up/down-button subtree matching + /// 's own shape exactly: thumb caps + /// 0x06004C60/63/66, up button (element + /// 0x10000071) 0x06004C69/6A/6B, down button + /// (element 0x10000072) 0x06004C6C/6D/6E, + /// track 0x06004C5F). 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). switches the popup to this + /// shape; 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 0x21000006, element 0x1000001C) has NO + /// sibling scrollbar element and is unaffected — + /// never sets Scrollable, so + /// it keeps the original grid path byte-identical. + /// + /// /// The button FACE reuses the same two sprites: 0x060012B3 is /// literally what vendor's OWN button-face child (0x1000034D) /// resolves to in the fixture, and 0x060012B4 (the row @@ -150,6 +198,19 @@ public sealed class VendorUiController : IRetainedPanelController private const uint TypeMenuNormalSprite = 0x060012B3u; 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; + /// /// Retail's ordered category table, transcribed verbatim from /// VendorItemsUI::OpenVendor's AddTypeFilter call chain @@ -195,6 +256,10 @@ public sealed class VendorUiController : IRetainedPanelController private readonly UiElement _buyingTab; private readonly UiElement _sellingTab; 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 UiText _itemNameText; private readonly UiText _itemCostText; @@ -228,6 +293,10 @@ public sealed class VendorUiController : IRetainedPanelController UiElement sellingTab, UiItemList itemList, UiScrollbar? itemScrollbar, + UiItemList? buyingList, + UiScrollbar? buyingScrollbar, + UiItemList? sellingList, + UiScrollbar? sellingScrollbar, UiMenu typeMenu, UiText itemNameText, UiText itemCostText, @@ -237,7 +306,9 @@ public sealed class VendorUiController : IRetainedPanelController UiDatFont? datFont, BitmapFont? debugFont, Func resolveSprite, - uint emptySlotSprite) + uint emptySlotSprite, + uint buyingEmptySlotSprite, + uint sellingEmptySlotSprite) { _vendor = vendor; _window = window; @@ -254,6 +325,8 @@ public sealed class VendorUiController : IRetainedPanelController _buyingTab = buyingTab; _sellingTab = sellingTab; _itemList = itemList; + _buyingList = buyingList; + _sellingList = sellingList; _typeMenu = typeMenu; _itemNameText = itemNameText; _itemCostText = itemCostText; @@ -295,6 +368,25 @@ public sealed class VendorUiController : IRetainedPanelController 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 // (UiMenu draws nothing without SpriteResolve — see the popup // geometry class doc above) and the vendor-authored popup geometry @@ -309,6 +401,18 @@ public sealed class VendorUiController : IRetainedPanelController _typeMenu.RowsPerColumn = TypeMenuRowsPerColumn; _typeMenu.RowHeight = TypeMenuRowHeight; _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 => { if (payload is uint mask) SelectCategory(mask); @@ -322,7 +426,7 @@ public sealed class VendorUiController : IRetainedPanelController RetailTabBinding.SetClick(_buyingTab, () => ShowTab(VendorPanelTab.Buying)); RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling)); if (_close is not null) - _close.OnClick = () => _vendor.Close(); + _close.OnClick = CloseButtonPressed; // Slice 6.3: retail gmVendorUI::HandleButtonClicks' 0x100000C2 case — // BuySingleItem(selectedID) — an immediate single-item purchase, no // staging list required (research doc §B.1). @@ -398,6 +502,14 @@ public sealed class VendorUiController : IRetainedPanelController /// Fallback debug bitmap font (used when is null). /// Dat RenderSurface id → (GL tex handle, px width, px height). /// Authored empty-slot background for the item strip, or 0 for none. + /// + /// G2: authored empty-slot background for the Buying tab's own item + /// strip (), or 0 for none. + /// + /// + /// G2: authored empty-slot background for the Selling tab's own item + /// strip (), or 0 for none. + /// public static VendorUiController? Bind( ImportedLayout layout, VendorState vendor, @@ -411,7 +523,9 @@ public sealed class VendorUiController : IRetainedPanelController UiDatFont? datFont, BitmapFont? debugFont, Func resolveSprite, - uint emptySlotSprite = 0u) + uint emptySlotSprite = 0u, + uint buyingEmptySlotSprite = 0u, + uint sellingEmptySlotSprite = 0u) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(vendor); @@ -442,6 +556,12 @@ public sealed class VendorUiController : IRetainedPanelController UiScrollbar? itemScrollbar = layout.FindElement(ItemScrollbarId) as UiScrollbar; UiButton? buyButton = layout.FindElement(BuyButtonId) 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( vendor, @@ -460,6 +580,10 @@ public sealed class VendorUiController : IRetainedPanelController sellingTab, itemList, itemScrollbar, + buyingList, + buyingScrollbar, + sellingList, + sellingScrollbar, typeMenu, itemNameText, itemCostText, @@ -469,7 +593,9 @@ public sealed class VendorUiController : IRetainedPanelController datFont, debugFont, resolveSprite, - emptySlotSprite); + emptySlotSprite, + buyingEmptySlotSprite, + sellingEmptySlotSprite); } 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; 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( CultureInfo.InvariantCulture, - "{0} {1} (you have {2})", + "{0} {1}p (you have {2}p)", verb, price.ToString("N0", CultureInfo.InvariantCulture), playerTotal.ToString("N0", CultureInfo.InvariantCulture)); @@ -1002,6 +1133,52 @@ public sealed class VendorUiController : IRetainedPanelController _vendor.Profile.AlternateCurrencyWcid); } + /// + /// G4 (vendor gate finding): port of retail's close/pushpin button + /// handler — gmVendorUI::HandleButtonClicks's 0x100000d6 + /// case (pc:204147-204182). Retail branches on whether the + /// Buying/Selling staging lists (m_buyList/m_sellList) hold + /// anything uncommitted: with nothing pending it calls ONLY + /// this->vtable->SetVisible(0) — a plain window hide, NOT + /// gmVendorUI::CloseVendor (pc:202080, the range-watcher- + /// unregister/session-teardown function + /// ports). Only when something IS pending does retail show a + /// confirmation dialog ("You have not completed all transactions...") + /// whose Yes callback (gmVendorUI::CloseVendorDialogCallback, + /// pc:202104-202166) is what actually reaches + /// CM_Vendor::SendNotice_CloseVendor — 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). + /// + /// 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 + /// m_buyList.head == 0 && m_sellList.head == 0 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. + /// + /// + /// Behavior change from the prior port. This button used to call + /// 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: + /// is evaluated + /// unconditionally every frame regardless of window visibility (it reads + /// only , never this window's + /// IsVisible), so leaving UseRadius still converges to a + /// full exactly as before. Re-approaching + /// the SAME vendor before then now lands on retail's sameVendor==1 + /// refresh-in-place path (, + /// which preserves the player's category selection) instead of a full + /// from-scratch reopen. + /// + /// + private void CloseButtonPressed() => _window.Hide(); + private void ClearContent() { _presentCategories.Clear(); @@ -1018,6 +1195,39 @@ public sealed class VendorUiController : IRetainedPanelController ClearSelectionDisplay(); } + /// + /// G2 (vendor gate finding): mirrors the Items strip's empty-slot-fill + /// configuration (see the constructor's _itemList block) for the + /// Buying/Selling pages' own authored lists — presentation only. The + /// list is flushed once (dropping the single default cell every + /// constructs itself with) so every visible + /// cell comes from 's own + /// with consistent styling + /// (non-drag-source), then left alone: nothing ever calls + /// on it, so + /// 's empty-slot padding + /// (UpdateEmptySlots) is the ONLY thing that ever populates it. + /// + 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) { IReadOnlyList lines = string.IsNullOrEmpty(value) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 84c3a728..fa1ce966 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1943,6 +1943,8 @@ public sealed class RetailUiRuntime : IDisposable { ImportedLayout? layout; uint emptySlotSprite; + uint buyingEmptySlotSprite; + uint sellingEmptySlotSprite; lock (_bindings.Assets.DatLock) { layout = LayoutImporter.Import( @@ -1959,6 +1961,18 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.Dats, VendorUiController.LayoutId, 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) { @@ -2013,7 +2027,9 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.DefaultFont, _bindings.Assets.DebugFont, _bindings.Assets.ResolveSprite, - emptySlotSprite); + emptySlotSprite, + buyingEmptySlotSprite, + sellingEmptySlotSprite); if (VendorController is null) { Console.WriteLine("[M4] vendor: required authored controls are missing."); diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index d99f9869..a63a6766 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -202,6 +202,22 @@ public class UiItemSlot : UiElement /// vendor rows regardless of ItemId. public override bool IsDragSource => ItemId != 0 && AllowDragSource; + /// + /// 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, + /// 's mousedown dispatch only claimed the press via + /// (checked first) — for a drag-allowed cell that's fine, + /// but the F3 fix that introduced made vendor/salvage + /// rows occupied-yet-IsDragSource == false, so they fell all the way through + /// UiRoot's chain to the window-move fallback: hovering showed the move-window + /// cursor ( 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: + /// checks BEFORE + /// , so drag-allowed occupied cells are + /// unaffected — this only changes the occupied-but-non-drag-source case. + public override bool HandlesClick => ItemId != 0; + /// Walk up to the containing (the drop handler owner). protected UiItemList? FindList() { diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index c10bd419..044b967f 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -34,10 +34,70 @@ public sealed class UiMenu : UiElement /// Button-face caption (the active target). Null ⇒ blank face. public Func? 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 ColumnWidth { get; set; } = 191f; // dat item template W=191 + /// + /// G5 (vendor gate finding): retail's authored vendor category popup + /// (LayoutDesc 0x21000043, root 0x1000034F) pairs its + /// ListBox (element 0x10000350, type 0x5) with a SIBLING + /// UIElement_Scrollbar (element 0x10000351, type 0xB, + /// 16px wide, docked immediately right of the list at x=100) — verified + /// via a live-dat scan (tools/VendorLayoutScan) against + /// client_local_English.dat: the ListBox reads a single-column + /// shape (attributes resolving to m_nCols=1/m_nRows=6) and + /// the row template (0x10000352) 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). becomes the VISIBLE ROW + /// COUNT in this mode (still authored-driven — 108px ListBox height / 18px + /// row height = 6). Chat's own popup (LayoutDesc 0x21000006) has + /// NO sibling scrollbar element and keeps the class default false — the + /// legacy column-major grid path below is untouched for it. + /// + public bool Scrollable { get; set; } + + /// + /// Vertical scroll model for the popup when is + /// set. Content/view/line extents are (re)computed every draw from + /// .Count / / + /// , mirroring how + /// configures its own Scroll before every layout pass. Exposed so + /// a controller/test can assert or drive scroll position directly (the + /// popup owns no separate live CHILD widget — + /// see the scrollbar chrome properties below for why). + /// + public UiScrollable PopupScroll { get; } = new(); + + /// + /// Authored width of the popup's docked scrollbar (16px, element + /// 0x10000351's own Width). + /// + public float ScrollbarWidth { get; set; } = 16f; + /// Authored extent of the up/down buttons along the scrollbar's + /// own axis (16px, elements 0x10000071/0x10000072's own Height — + /// same convention as ). + 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) // 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 @@ -72,8 +132,14 @@ public sealed class UiMenu : UiElement private bool _open; // Interior = the row content; Outer = interior + the 8-piece bevel ring. - private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); - private float InteriorW => ColumnCount * ColumnWidth; + // Scrollable: always exactly one column (RowsPerColumn is the VISIBLE window, + // 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 OuterW => InteriorW + 2 * Border; private float OuterH => InteriorH + 2 * Border; @@ -130,41 +196,142 @@ public sealed class UiMenu : UiElement var resolve = SpriteResolve; if (!_open || resolve is null) return; - // Column-major popup opening UPWARD from the button, wrapped in the universal - // 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a - // bevelled floating window). Force OPAQUE (a menu reads solid even though the - // chat window is translucent). Draw bevel → panel fill → row sprites → labels, - // all through the sprite bucket in submission order so labels land on top. + // Force OPAQUE (a menu reads solid even though the 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); try { - float outerTop = -OuterH; // popup bottom sits at the button top (y=0) - float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel) - - DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH); - DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows - - for (int i = 0; i < Items.Count; i++) - { - int col = i / RowsPerColumn, row = i % RowsPerColumn; - float x = inX + col * ColumnWidth, y = inY + row * RowHeight; - bool selected = Equals(Items[i].Payload, Selected); - DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight); - } - - float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row - for (int i = 0; i < Items.Count; i++) - { - int col = i / RowsPerColumn, row = i % RowsPerColumn; - // Items grey out when unavailable; when EnabledProvider is null all items are enabled. - bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true; - DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY, - avail ? TextColorAvailable : TextColorGhosted); - } + if (Scrollable) + DrawScrollablePopup(ctx, resolve); + else + DrawGridPopup(ctx, resolve); } finally { ctx.PopAlpha(); } } + /// Legacy column-major popup (chat's own shape — no authored sibling + /// scrollbar element; see 's doc comment). Unchanged from + /// before G5. + private void DrawGridPopup(UiRenderContext ctx, Func resolve) + { + float outerTop = -OuterH; // popup bottom sits at the button top (y=0) + float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel) + + DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH); + DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows + + for (int i = 0; i < Items.Count; i++) + { + int col = i / RowsPerColumn, row = i % RowsPerColumn; + float x = inX + col * ColumnWidth, y = inY + row * RowHeight; + bool selected = Equals(Items[i].Payload, Selected); + DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight); + } + + float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row + for (int i = 0; i < Items.Count; i++) + { + int col = i / RowsPerColumn, row = i % RowsPerColumn; + // Items grey out when unavailable; when EnabledProvider is null all items are enabled. + bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true; + DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY, + avail ? TextColorAvailable : TextColorGhosted); + } + } + + /// + /// G5: single-column popup with a docked scrollbar — port of the vendor category + /// dropdown's authored shape (LayoutDesc 0x21000043, see 's + /// doc comment). Draws exactly rows (the authored visible + /// window), sliced from starting at , plus + /// the scrollbar chrome using the SAME thumb geometry itself + /// uses (). + /// + private void DrawScrollablePopup(UiRenderContext ctx, Func 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); + } + + /// Recomputes 's extents from the current item + /// count/geometry — mirrors 's own "configure the + /// shared scroll model right before using it" pattern. + private void ConfigurePopupScroll() + { + int lineHeight = System.Math.Max(1, (int)MathF.Round(RowHeight)); + PopupScroll.LineHeight = lineHeight; + PopupScroll.SetExtents(Items.Count * lineHeight, RowsPerColumn * lineHeight); + } + + /// 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. + 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 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); + } + } + /// Draw the universal 8-piece retail window bevel (corners + tiled edges + /// tiled centre fill) framing the rect (,, /// ,). Reuses the same geometry + @@ -210,6 +377,31 @@ public sealed class UiMenu : UiElement 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; 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 // (outside the interior) just close the menu. float ix = lx - Border, iy = ly - (-OuterH + Border); + if (Scrollable) + return HandleScrollablePopupMouseDown(ix, iy); + if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH) { int col = (int)(ix / ColumnWidth); @@ -243,4 +438,73 @@ public sealed class UiMenu : UiElement _open = !_open; // toggle on button click return true; } + + /// + /// 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 's own MouseDown shape). + /// + 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; + } + + /// Continues an in-progress thumb drag (); + /// mirrors 's own MouseMove when _draggingThumb + /// case, reusing for the exact same thumb height. + 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); + } } diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index d5452b3d..7cf9e128 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -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); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index 0687fd43..1e7a0bf7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -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(layout.FindElement(VendorUiController.BuyingListId)); + var sellingList = Assert.IsType(layout.FindElement(VendorUiController.SellingListId)); + var buyingScrollbar = Assert.IsType( + layout.FindElement(VendorUiController.BuyingScrollbarId)); + var sellingScrollbar = Assert.IsType( + 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(); + 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] diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs index 4b1a16fe..15240604 100644 --- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -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, + }; + + /// Raw event Data2 (the same "ly" MouseDown/MouseMove receive) for a + /// point at popup-interior-local Y — 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. + private static int RawY(UiMenu menu, float iy) + { + float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * Border; + return (int)(iy - outerH + Border); + } + + /// Raw event Data1 ("lx") for a point at popup-interior-local X . + 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(); + 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 + } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs index 14eab2ea..33ea8a50 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeVendorLifecycleTests.cs @@ -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(); + 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 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() {