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
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:
parent
58c8de264e
commit
5224e43890
8 changed files with 1061 additions and 51 deletions
|
|
@ -48,10 +48,16 @@ namespace AcDream.App.UI.Layout;
|
|||
/// <para>
|
||||
/// <b>Lifecycle: pure state projection.</b> This controller owns no
|
||||
/// session/transaction state of its own. It opens/repopulates/closes purely
|
||||
/// by observing <see cref="VendorState.Changed"/>; the one mutation it may
|
||||
/// trigger is the close button calling <see cref="VendorState.Close"/> —
|
||||
/// going through the owner (client-local per research §A.4, nothing on the
|
||||
/// wire), never writing fields directly.
|
||||
/// by observing <see cref="VendorState.Changed"/>. The close button (G4,
|
||||
/// vendor gate finding) does NOT mutate <see cref="VendorState"/> at all —
|
||||
/// see <see cref="CloseButtonPressed"/> for the retail citation
|
||||
/// (<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>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// F1 (Slice 5.4 review): the category dropdown's authored popup.
|
||||
/// 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.
|
||||
/// </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
|
||||
/// literally what vendor's OWN button-face child (<c>0x1000034D</c>)
|
||||
/// 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 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>
|
||||
/// Retail's ordered category table, transcribed verbatim from
|
||||
/// <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 _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<uint, (uint tex, int w, int h)> 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
|
|||
/// <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="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(
|
||||
ImportedLayout layout,
|
||||
VendorState vendor,
|
||||
|
|
@ -411,7 +523,9 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
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(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);
|
||||
}
|
||||
|
||||
/// <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->vtable->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 && 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()
|
||||
{
|
||||
_presentCategories.Clear();
|
||||
|
|
@ -1018,6 +1195,39 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
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)
|
||||
{
|
||||
IReadOnlyList<UiText.Line> lines = string.IsNullOrEmpty(value)
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -202,6 +202,22 @@ public class UiItemSlot : UiElement
|
|||
/// vendor rows regardless of ItemId.</summary>
|
||||
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>
|
||||
protected UiItemList? FindList()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,10 +34,70 @@ public sealed class UiMenu : UiElement
|
|||
/// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
|
||||
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 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)
|
||||
// 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(); }
|
||||
}
|
||||
|
||||
/// <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 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 +
|
||||
/// tiled centre fill) framing the rect (<paramref name="x"/>,<paramref name="y"/>,
|
||||
/// <paramref name="w"/>,<paramref name="h"/>). 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;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,82 @@ public sealed class VendorUiControllerTests
|
|||
Assert.NotNull(controller);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bind_FromRealDatFixture_BuyingAndSellingLists_ConfiguredForEmptySlotFill()
|
||||
{
|
||||
// G2 (vendor gate finding): the Buying/Selling pages' item strips
|
||||
// never got the empty-slot fill the Items list has, so they showed
|
||||
// the bare authored blue background instead. This proves the real
|
||||
// LayoutDesc 0x21000012 fixture's 0x100000C5 (Buying list)/0x100000CE
|
||||
// (Selling list) resolve to real UiItemList widgets and come out of
|
||||
// Bind configured identically to the Items strip (F7b) — same
|
||||
// single-row/horizontal-scroll/cell-size shape, fill enabled, a
|
||||
// non-drag-source empty-slot factory wired, and the sibling
|
||||
// scrollbar bound to the SAME list's scroll model. The lists stay
|
||||
// UNPOPULATED (no AddItem call anywhere in this path) — staging is
|
||||
// still deferred.
|
||||
ImportedLayout layout = FixtureLoader.LoadVendor();
|
||||
var screen = new UiRoot { Width = 1280f, Height = 800f };
|
||||
RetailWindowHandle window = RetailWindowFrame.Mount(
|
||||
screen,
|
||||
layout.Root,
|
||||
static _ => (0u, 0, 0),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = "vendor-fixture-smoke-2",
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Visible = false,
|
||||
});
|
||||
|
||||
var objects = new ClientObjectTable();
|
||||
using var itemInteraction = new ItemInteractionController(
|
||||
objects,
|
||||
new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
|
||||
new InteractionState(),
|
||||
playerGuid: static () => 0u,
|
||||
sendUse: null,
|
||||
sendUseWithTarget: null,
|
||||
sendWield: null,
|
||||
sendDrop: null);
|
||||
VendorUiController? controller = VendorUiController.Bind(
|
||||
layout,
|
||||
new VendorState(),
|
||||
window,
|
||||
static (_, _, _, _, _) => 0u,
|
||||
objects,
|
||||
static () => 0u,
|
||||
itemInteraction,
|
||||
new SelectionState(),
|
||||
new StackSplitQuantityState(),
|
||||
datFont: null,
|
||||
debugFont: null,
|
||||
static _ => (0u, 0, 0));
|
||||
Assert.NotNull(controller);
|
||||
|
||||
var buyingList = Assert.IsType<UiItemList>(layout.FindElement(VendorUiController.BuyingListId));
|
||||
var sellingList = Assert.IsType<UiItemList>(layout.FindElement(VendorUiController.SellingListId));
|
||||
var buyingScrollbar = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(VendorUiController.BuyingScrollbarId));
|
||||
var sellingScrollbar = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(VendorUiController.SellingScrollbarId));
|
||||
|
||||
foreach (UiItemList list in new[] { buyingList, sellingList })
|
||||
{
|
||||
Assert.True(list.SingleRow);
|
||||
Assert.True(list.HorizontalScroll);
|
||||
Assert.Equal(32f, list.CellWidth);
|
||||
Assert.Equal(32f, list.CellHeight);
|
||||
Assert.True(list.FillVisibleEmptySlots);
|
||||
Assert.NotNull(list.EmptySlotFactory);
|
||||
Assert.Equal(0, list.GetNumUIItems()); // never populated
|
||||
}
|
||||
|
||||
Assert.Same(buyingList.Scroll, buyingScrollbar.Model);
|
||||
Assert.True(buyingScrollbar.Horizontal);
|
||||
Assert.Same(sellingList.Scroll, sellingScrollbar.Model);
|
||||
Assert.True(sellingScrollbar.Horizontal);
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
// F2/F3: a deterministic non-zero player coin total so the cost-text
|
||||
|
|
@ -310,7 +386,7 @@ public sealed class VendorUiControllerTests
|
|||
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
|
||||
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {2000:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
|
|
@ -338,7 +414,7 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
Assert.Equal("Bread", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +462,7 @@ public sealed class VendorUiControllerTests
|
|||
// selection.
|
||||
Assert.Equal("Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
GetText(h.ItemCostText));
|
||||
|
||||
// Player drags the slider to 40 AFTER selecting -- no re-click, no
|
||||
|
|
@ -396,7 +472,7 @@ public sealed class VendorUiControllerTests
|
|||
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
|
||||
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {800:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
GetText(h.ItemCostText));
|
||||
|
||||
h.BuyButton.OnClick!.Invoke();
|
||||
|
|
@ -576,6 +652,45 @@ public sealed class VendorUiControllerTests
|
|||
Assert.NotEqual(0u, cell.ItemId); // occupied -- would otherwise be a drag source by default
|
||||
Assert.False(cell.IsDragSource);
|
||||
Assert.Null(cell.GetDragPayload());
|
||||
// G3 (vendor gate finding): a row must still CAPTURE its own press
|
||||
// even though it never mints a drag payload -- see
|
||||
// UiItemSlot.HandlesClick and the real-event-path test below.
|
||||
Assert.True(cell.HandlesClick);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShopRow_ClickEvent_SelectsItem_DespiteNotBeingADragSource()
|
||||
{
|
||||
// G3 (vendor gate finding): the F3 drag-suppression fix
|
||||
// (AllowDragSource=false) left occupied vendor rows with
|
||||
// IsDragSource==false -- before the G3 fix that meant UiRoot's
|
||||
// mousedown dispatch found no reason to claim the press at all, so
|
||||
// it fell through to the window-move fallback (hover showed the
|
||||
// move-window cursor; a press dragged the whole panel instead of
|
||||
// selecting a row). This drives the REAL UiItemSlot.OnEvent state
|
||||
// machine (MouseDown then Click), the same sequence UiRoot's
|
||||
// dispatch produces, rather than invoking the wired Clicked
|
||||
// delegate directly -- proving the row still completes a press then
|
||||
// click and drives selection.
|
||||
const uint SecondArmorGuid = 0x60000110u;
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
new VendorShopItem(SecondArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
|
||||
});
|
||||
Assert.Equal(ArmorItemGuid, h.Selection.SelectedObjectId); // auto-selected first
|
||||
|
||||
UiItemSlot secondCell = h.ItemList.GetItem(1)!;
|
||||
Assert.Equal(SecondArmorGuid, secondCell.ItemId);
|
||||
Assert.False(secondCell.IsDragSource); // never a drag source (F3)
|
||||
Assert.True(secondCell.HandlesClick); // but still claims its own press (G3)
|
||||
|
||||
secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.MouseDown));
|
||||
secondCell.OnEvent(new UiEvent(0u, secondCell, UiEventType.Click));
|
||||
|
||||
Assert.Equal(SecondArmorGuid, h.Selection.SelectedObjectId);
|
||||
Assert.True(secondCell.Selected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -720,8 +835,22 @@ public sealed class VendorUiControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButton_RoutesThroughVendorStateClose_NotADirectFieldWrite()
|
||||
public void CloseButton_HidesTheWindowOnly_LeavesTheSessionOpenForARefreshInPlaceReopen()
|
||||
{
|
||||
// G4 (vendor gate finding): retail's close/pushpin button —
|
||||
// gmVendorUI::HandleButtonClicks's 0x100000d6 case (pc:204147-204182)
|
||||
// — with nothing staged in the Buying/Selling lists (this port never
|
||||
// stages anything; Slice 6 territory) calls ONLY SetVisible(0),
|
||||
// never gmVendorUI::CloseVendor (pc:202080, the range-watcher-
|
||||
// unregister/session-teardown function VendorState.Close ports).
|
||||
// The OLD port called VendorState.Close() directly from this button
|
||||
// — an over-eager full teardown retail does not perform on an
|
||||
// ordinary close. RuntimeVendorRangeQuery.EnforceRange (evaluated
|
||||
// every frame regardless of window visibility) remains the sole
|
||||
// path to a full close once the player actually leaves UseRadius —
|
||||
// see Closed_HidesWindowAndClearsListAndText for that path,
|
||||
// unaffected by this change since it calls VendorState.Close()
|
||||
// directly rather than through this button.
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
|
|
@ -730,12 +859,25 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
// The panel never mutates VendorState directly — the ONLY path from
|
||||
// the close button to a cleared session is VendorState.Close()
|
||||
// itself, so VendorId reads back 0 through the owner's own public
|
||||
// surface, not a private field poke.
|
||||
Assert.Equal(0u, h.State.VendorId);
|
||||
Assert.False(h.Window.IsVisible);
|
||||
// Unlike the OLD port, the session itself is NOT torn down — the
|
||||
// owner still reports the same open vendor in the background,
|
||||
// matching retail's hidden-but-still-registered range watcher.
|
||||
Assert.Equal(VendorGuid, h.State.VendorId);
|
||||
|
||||
// Re-approaching the SAME vendor (e.g. pressing Use again while
|
||||
// still in range) now reaches retail's sameVendor==1 refresh-in-
|
||||
// place path (VendorStateTransitionKind.Refreshed) instead of a
|
||||
// from-scratch Opened, and reopens the window.
|
||||
var kinds = new List<VendorStateTransitionKind>();
|
||||
h.State.Changed += t => kinds.Add(t.Kind);
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.Equal([VendorStateTransitionKind.Refreshed], kinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -175,4 +175,192 @@ public class UiMenuTests
|
|||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60)));
|
||||
Assert.Equal(ChatChannelKind.Fellowship, fired);
|
||||
}
|
||||
|
||||
// ── G5 (vendor gate finding): Scrollable single-column popup ──────────────
|
||||
// Retail's vendor category dropdown (LayoutDesc 0x21000043) is a SCROLLABLE
|
||||
// single column with a docked scrollbar, not a column-major grid — see
|
||||
// VendorUiController's "G5 correction" class-doc paragraph. Chat's own popup
|
||||
// (exercised by every test above, Scrollable left at its false default) is
|
||||
// completely unaffected: it's a structurally different code path
|
||||
// (DrawGridPopup / the grid branch of OnEvent's MouseDown handling).
|
||||
|
||||
private const int Border = 5; // RetailChromeSprites.Border
|
||||
|
||||
private static UiMenu.MenuItem[] MakeCategoryItems(int count)
|
||||
=> System.Linq.Enumerable.Range(0, count)
|
||||
.Select(i => new UiMenu.MenuItem($"Category {i}", (object?)i))
|
||||
.ToArray();
|
||||
|
||||
private static UiMenu MakeScrollableMenu(int itemCount = 18) => new UiMenu
|
||||
{
|
||||
Width = 100f, Height = 18f,
|
||||
Items = MakeCategoryItems(itemCount),
|
||||
Selected = (object?)0,
|
||||
Scrollable = true,
|
||||
RowsPerColumn = 6,
|
||||
RowHeight = 18f,
|
||||
ColumnWidth = 100f,
|
||||
ScrollbarWidth = 16f,
|
||||
ScrollButtonExtent = 16f,
|
||||
};
|
||||
|
||||
/// <summary>Raw event Data2 (the same "ly" MouseDown/MouseMove receive) for a
|
||||
/// point at popup-interior-local Y <paramref name="iy"/> — derives the mapping
|
||||
/// from the SAME public geometry properties production code reads, mirroring
|
||||
/// how CategoryMenu_OpensAndSelectsThroughRealHitPath (VendorUiControllerTests)
|
||||
/// derives its click point rather than hardcoding a pixel constant.</summary>
|
||||
private static int RawY(UiMenu menu, float iy)
|
||||
{
|
||||
float outerH = menu.RowsPerColumn * menu.RowHeight + 2 * Border;
|
||||
return (int)(iy - outerH + Border);
|
||||
}
|
||||
|
||||
/// <summary>Raw event Data1 ("lx") for a point at popup-interior-local X <paramref name="ix"/>.</summary>
|
||||
private static int RawX(float ix) => (int)(ix + Border);
|
||||
|
||||
[Fact]
|
||||
public void Scrollable_18Categories_ConfiguresScrollExtentsFromAuthoredGeometry()
|
||||
{
|
||||
var menu = MakeScrollableMenu(18);
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
|
||||
|
||||
// A wheel-scroll (no actual movement, Data0=0) is enough to configure
|
||||
// PopupScroll from Items.Count/RowsPerColumn/RowHeight — the same
|
||||
// "configure right before use" pattern UiItemList.LayoutCells follows.
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
|
||||
|
||||
Assert.Equal(18 * 18, menu.PopupScroll.ContentHeight); // 18 items * 18px row height
|
||||
Assert.Equal(6 * 18, menu.PopupScroll.ViewHeight); // 6 visible rows (the authored window)
|
||||
Assert.True(menu.PopupScroll.HasOverflow); // 18 > 6 -> scrollbar warranted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrollable_ClickInFirstVisibleRow_SelectsItemZero_ThroughTheRealHitPath()
|
||||
{
|
||||
var menu = MakeScrollableMenu(18);
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
|
||||
object? fired = null;
|
||||
menu.OnSelect = p => fired = p;
|
||||
|
||||
// Row 0's vertical center — same "row * RowHeight + RowHeight/2" shape
|
||||
// the existing grid-mode tests already use for their row math.
|
||||
int ly = RawY(menu, menu.RowHeight / 2f);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
|
||||
|
||||
Assert.Equal(0, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrollable_ClickInScrollbarColumn_DoesNotSelectAnItem_AndKeepsThePopupOpen()
|
||||
{
|
||||
// Before G5's fix, a click at this X (where the OLD grid math would have
|
||||
// treated it as "column 1") could have picked the WRONG item entirely —
|
||||
// this X now belongs to the scrollbar, which must never fire OnSelect.
|
||||
var menu = MakeScrollableMenu(18);
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
|
||||
var fired = new List<object?>();
|
||||
menu.OnSelect = p => fired.Add(p);
|
||||
|
||||
// Middle of the scrollbar TRACK (below the up-button, above the down-button).
|
||||
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
|
||||
int trackMidY = RawY(menu, menu.RowsPerColumn * menu.RowHeight / 2f);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, trackMidY)));
|
||||
|
||||
Assert.Empty(fired); // scrollbar click never selects an item
|
||||
|
||||
// The popup stayed open — a subsequent item-column click still resolves
|
||||
// (against whatever row is now visible after the scrollbar's page-scroll).
|
||||
int rowLy = RawY(menu, menu.RowHeight / 2f);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), rowLy)));
|
||||
Assert.Single(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrollable_DownButtonClick_AdvancesByOneRow_AndSubsequentClickPicksTheAdvancedItem()
|
||||
{
|
||||
var menu = MakeScrollableMenu(18);
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
|
||||
|
||||
// Down-button region: the bottom ScrollButtonExtent px of the scrollbar column.
|
||||
int downX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
|
||||
int downY = RawY(menu, menu.RowsPerColumn * menu.RowHeight - menu.ScrollButtonExtent / 2f);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, downX, downY)));
|
||||
|
||||
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY); // scrolled exactly one row
|
||||
|
||||
object? fired = null;
|
||||
menu.OnSelect = p => fired = p;
|
||||
// Row 0's ON-SCREEN position now shows item index 1 (the window advanced).
|
||||
int ly = RawY(menu, menu.RowHeight / 2f);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
|
||||
Assert.Equal(1, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrollable_UpButtonClick_ReversesAPriorDownScroll()
|
||||
{
|
||||
var menu = MakeScrollableMenu(18);
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5));
|
||||
|
||||
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
|
||||
int downY = RawY(menu, menu.RowsPerColumn * menu.RowHeight - menu.ScrollButtonExtent / 2f);
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, downY));
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, downY));
|
||||
Assert.Equal((int)(2 * menu.RowHeight), menu.PopupScroll.ScrollY);
|
||||
|
||||
int upY = RawY(menu, menu.ScrollButtonExtent / 2f);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, upY)));
|
||||
|
||||
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrollable_MouseWheel_ScrollsWhilePopupIsOpen()
|
||||
{
|
||||
var menu = MakeScrollableMenu(18);
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
|
||||
|
||||
// Mirrors UiItemList's own wheel convention: +Y wheel (Data0>0) scrolls up/older.
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: -1)));
|
||||
|
||||
Assert.Equal((int)menu.RowHeight, menu.PopupScroll.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scrollable_ThumbDrag_MovesScrollPosition_AndReleaseKeepsThePopupOpen()
|
||||
{
|
||||
var menu = MakeScrollableMenu(18);
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5)); // open
|
||||
|
||||
// Establish the thumb's starting rect the SAME way production drawing/
|
||||
// hit-testing does: configure the model, then ask UiScrollbar's own
|
||||
// shared geometry helper (the exact function DrawPopupScrollbar/
|
||||
// HandleScrollablePopupMouseDown use internally).
|
||||
int trackTopY = (int)menu.ScrollButtonExtent;
|
||||
float trackLen = menu.RowsPerColumn * menu.RowHeight - 2 * menu.ScrollButtonExtent;
|
||||
// Force PopupScroll into a known-configured state via a zero-delta wheel
|
||||
// event before computing the thumb rect from it.
|
||||
menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0));
|
||||
var (thumbY, thumbH) = UiScrollbar.ThumbRect(menu.PopupScroll, trackTopY, trackLen);
|
||||
|
||||
int scrollbarMidX = RawX(menu.ColumnWidth + menu.ScrollbarWidth / 2f);
|
||||
int pressY = RawY(menu, thumbY + thumbH / 2f); // press inside the thumb
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, scrollbarMidX, pressY)));
|
||||
|
||||
// Drag most of the way down the track.
|
||||
int dragToIy = (int)(trackTopY + trackLen - thumbH / 2f);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseMove, 0, scrollbarMidX, RawY(menu, dragToIy))));
|
||||
|
||||
Assert.True(menu.PopupScroll.ScrollY > 0);
|
||||
Assert.True(menu.PopupScroll.PositionRatio > 0.5f);
|
||||
|
||||
// Releasing must NOT close the popup — a subsequent scrollbar/item click
|
||||
// still resolves through the same OnEvent path.
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseUp, 0, scrollbarMidX, RawY(menu, dragToIy))));
|
||||
object? fired = null;
|
||||
menu.OnSelect = p => fired = p;
|
||||
int ly = RawY(menu, menu.RowHeight / 2f);
|
||||
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, RawX(10), ly)));
|
||||
Assert.NotNull(fired); // popup was still open -> the click landed on a real row
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,6 +248,136 @@ public sealed class RuntimeVendorLifecycleTests
|
|||
Assert.Equal(0, snapshot.MaterializedVendorItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondUse_AfterLocalXClose_StillDispatchesOverTheWireAndReopensOnReapproach()
|
||||
{
|
||||
// G4 (vendor gate finding, client-side half): the user reported that
|
||||
// after X-closing the vendor panel (VendorState.Close() -- a PURE
|
||||
// client-local UI teardown per research doc A.3/A.4, no wire send),
|
||||
// using the vendor again does nothing. This isolates the Runtime
|
||||
// machinery a repeat Use actually depends on --
|
||||
// RuntimeInteractionTransactionState's reservation/busy-count gate
|
||||
// and VendorState's own open/close bookkeeping -- with NO App-layer
|
||||
// world-picking in the loop (AcDream.Runtime.Tests cannot reference
|
||||
// AcDream.App). If this passes, the suppression (if real) is NOT in
|
||||
// Runtime; it would have to be in the App-layer picking/identity
|
||||
// chain (WorldSelectionQuery/SelectionInteractionController), which
|
||||
// needs its own harness to confirm or rule out.
|
||||
using GameRuntime runtime = Create();
|
||||
VendorState vendor = runtime.InventoryOwner.Vendor;
|
||||
using IDisposable wiring = Wire(vendor);
|
||||
RuntimeInteractionTransactionState transactions = runtime.ActionOwner.Transactions;
|
||||
var transport = new FakeTransport();
|
||||
const uint vendorGuid = 0x40001000u;
|
||||
|
||||
// --- First open: press Use -> dispatch -> ApproachVendor arrives ->
|
||||
// UseDone arrives (ACE's Player_Use.TryUseItem ALWAYS schedules
|
||||
// SendUseDoneEvent() after ActOnUse returns, since Vendor.ActOnUse
|
||||
// never sets LastUseTime = float.MinValue -- confirmed against
|
||||
// references/ACE/Source/ACE.Server/WorldObjects/{Vendor,Player_Use}.cs).
|
||||
ItemUseRequestReservation reservation1 =
|
||||
transactions.BeginUseRequestReservation();
|
||||
RuntimeInteractionDispatchResult result1 = transactions.TryDispatchUse(
|
||||
vendorGuid,
|
||||
ownedByPlayer: false,
|
||||
useable: true,
|
||||
reservation1,
|
||||
transport,
|
||||
out _);
|
||||
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result1);
|
||||
Assert.Equal(new[] { vendorGuid }, transport.Uses);
|
||||
Assert.Equal(1, transactions.Inventory.BusyCount);
|
||||
|
||||
Dispatch(BuildApproachVendorPayload(
|
||||
vendorGuid: vendorGuid,
|
||||
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
||||
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
||||
currencyName: "",
|
||||
items: []));
|
||||
Assert.Equal(vendorGuid, vendor.VendorId);
|
||||
|
||||
transactions.CompleteUse(0u);
|
||||
Assert.Equal(0, transactions.Inventory.BusyCount);
|
||||
|
||||
// --- X-close: the ONLY thing our client does locally.
|
||||
Assert.True(vendor.Close());
|
||||
Assert.Equal(0u, vendor.VendorId);
|
||||
|
||||
// --- Second Use attempt on the SAME vendor guid, well past the
|
||||
// 200ms retail throttle (irrelevant here since TryDispatchUse has no
|
||||
// throttle of its own -- that lives one layer up in
|
||||
// ItemInteractionController/App -- but asserted for clarity).
|
||||
ItemUseRequestReservation reservation2 =
|
||||
transactions.BeginUseRequestReservation();
|
||||
RuntimeInteractionDispatchResult result2 = transactions.TryDispatchUse(
|
||||
vendorGuid,
|
||||
ownedByPlayer: false,
|
||||
useable: true,
|
||||
reservation2,
|
||||
transport,
|
||||
out _);
|
||||
|
||||
// If this fails, RuntimeInteractionTransactionState/VendorState is
|
||||
// the suppressor. If it passes (expected, given VendorState.Close()
|
||||
// touches no interaction-transaction state and ActiveVendorId only
|
||||
// gates USING SHOP ITEMS, not the vendor NPC itself -- see
|
||||
// ItemInteractionPolicy.DecideUse's ContainerId check), the
|
||||
// suppression is NOT here.
|
||||
Assert.Equal(RuntimeInteractionDispatchResult.Dispatched, result2);
|
||||
Assert.Equal(new[] { vendorGuid, vendorGuid }, transport.Uses);
|
||||
Assert.Equal(1, transactions.Inventory.BusyCount);
|
||||
|
||||
// --- Server re-approaches (mirrors Vendor.ActOnUse's UNCONDITIONAL
|
||||
// ApproachVendor -- confirmed no server-side "already open" gate
|
||||
// exists; see the G4 evidence chain in the final report). Our own
|
||||
// VendorState.Apply must reopen from a previous==0 baseline (Close()
|
||||
// already zeroed it), which VendorUiController.OnVendorChanged's
|
||||
// Opened case turns into _window.Show().
|
||||
var kinds = new List<VendorStateTransitionKind>();
|
||||
vendor.Changed += t => kinds.Add(t.Kind);
|
||||
Dispatch(BuildApproachVendorPayload(
|
||||
vendorGuid: vendorGuid,
|
||||
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
|
||||
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
|
||||
currencyName: "",
|
||||
items: []));
|
||||
|
||||
Assert.Equal(vendorGuid, vendor.VendorId);
|
||||
Assert.Equal([VendorStateTransitionKind.Opened], kinds);
|
||||
|
||||
transactions.CompleteUse(0u);
|
||||
Assert.Equal(0, transactions.Inventory.BusyCount);
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : IRuntimeInteractionTransport
|
||||
{
|
||||
private uint _sequence;
|
||||
public bool IsInWorld { get; set; } = true;
|
||||
public List<uint> Uses { get; } = [];
|
||||
|
||||
public bool TrySendUse(uint serverGuid, out uint sequence)
|
||||
{
|
||||
if (!IsInWorld)
|
||||
{
|
||||
sequence = 0u;
|
||||
return false;
|
||||
}
|
||||
sequence = ++_sequence;
|
||||
Uses.Add(serverGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TrySendPickup(
|
||||
uint itemGuid,
|
||||
uint destinationContainerId,
|
||||
int placement,
|
||||
out uint sequence)
|
||||
{
|
||||
sequence = 0u;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VendorId_IsTheLiveActiveVendorIdSeamSource()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue