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
Double-clicking a vendor shop item now buys through the Buy button's exact quantity/price path — retail has NO double-click-to-buy (the named table sweep's negative evidence stands); the user chose the addition explicitly and AP-171 records it. #353 (pre-existing, user-reported): the stack-count entry is AUTHORED HJustify=2 — right-justified flush against the slider on its own row — and UiField already supported RightAligned; nobody had honored the authored value. The name element is AUTHORED two lines tall (H=31, W=140): long names now word-wrap at the authored pixel width onto a second centered row via two stacked one-line labels reusing the existing centered draw path (WrapNameTwoLines: greedy word break, no hyphenation, second row clips like retail). Ten SelectedObjectController structure tests updated from single-label to first-label access. Lesson re-learned the hard way: the first "green" run used a stale TEST assembly (only the App project had been rebuilt) — the clean-room caught it, per feedback_stale_build_artifacts. Full App 4,329/3 and Core 4,381/1 verified green on properly rebuilt assemblies; the one transient Core Release failure did not reproduce and is noted on #351. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2594 lines
128 KiB
C#
2594 lines
128 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Properties;
|
|
using AcDream.Core.Selection;
|
|
|
|
namespace AcDream.App.UI.Layout;
|
|
|
|
/// <summary>
|
|
/// Retained port of retail <c>gmVendorUI</c>'s "Items" tab
|
|
/// (<c>VendorItemsUI</c>) — the browse-only scope of Slice 5.4. LayoutDesc
|
|
/// <c>0x21000012</c>, root <c>0x100000B7</c> — discovered and verified
|
|
/// against the named-retail decomp in
|
|
/// <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c> §B.3/§B.4.
|
|
///
|
|
/// <para>
|
|
/// <b>Three tabs, not two.</b> The contract (world-interaction-completion.md,
|
|
/// Slice 5 decisions) anticipated a "browse/Buy" tab plus a "Sell" tab. The
|
|
/// dat authors three DAT-labeled tabs — "Items" (<see cref="ItemsTabId"/>),
|
|
/// "Buying" (<see cref="BuyingTabId"/>), "Selling" (<see cref="SellingTabId"/>) —
|
|
/// each a state/page pair. Retail's <c>gmVendorUI::OpenVendor</c>
|
|
/// (<c>pc:203650</c>) opens tab <see cref="ItemsTabId"/> for the ordinary
|
|
/// (mode 2) open this slice always exercises — matching the contract's
|
|
/// "browse/Buy tab" language, just under its actual dat name. "Buying"
|
|
/// (<c>VendorBuyUI</c>, staged-purchase review) and "Selling"
|
|
/// (<c>VendorSellUI</c>, staged-sale review) are Slice 6 territory: their
|
|
/// tab buttons render and switch the visible page (so the authored layout
|
|
/// looks complete), but neither page is ever populated by this controller —
|
|
/// clicking them just shows an empty page. No buy/sell action, no
|
|
/// <c>InqAcceptability</c>, no currency mutation happens anywhere in this
|
|
/// file.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Category filter = a dropdown, not more tabs.</b> Retail's
|
|
/// <c>VendorItemsUI::AddTypeFilter</c>/<c>::ListContainsType</c>
|
|
/// (<c>pc:199667</c>/<c>200132</c>, D0 read in the research doc's §B.4)
|
|
/// populate <see cref="UiMenu"/> <see cref="TypeFilterMenuId"/> with one
|
|
/// entry per <see cref="CategoryFilters"/> row the vendor's stock actually
|
|
/// contains, in retail's fixed table order, and the item list is ALWAYS
|
|
/// scoped to exactly one selected category (there is no "show everything"
|
|
/// state — an empty selection retail-authentically shows zero items).
|
|
/// </para>
|
|
///
|
|
/// <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 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, IItemListDragHandler
|
|
{
|
|
public const uint LayoutId = 0x21000012u;
|
|
public const uint RootId = 0x100000B7u;
|
|
public const uint CloseId = 0x100000D6u;
|
|
public const uint PanelGroupId = 0x100000B8u;
|
|
|
|
public const uint ItemsTabId = 0x100000B9u;
|
|
public const uint BuyingTabId = 0x100000BAu;
|
|
public const uint SellingTabId = 0x100000BBu;
|
|
|
|
public const uint ItemsPageId = 0x100000BCu;
|
|
public const uint ItemListId = 0x100000BDu;
|
|
public const uint ItemScrollbarId = 0x100000BEu;
|
|
public const uint TypeFilterMenuId = 0x100000BFu;
|
|
public const uint ItemNameTextId = 0x100000C0u;
|
|
public const uint ItemCostTextId = 0x100000C1u;
|
|
public const uint BuyButtonId = 0x100000C2u;
|
|
public const uint AddButtonId = 0x100000C3u;
|
|
|
|
// Slice 6 territory — present so the authored layout looks complete,
|
|
// never populated here (see the class doc's "three tabs" note).
|
|
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;
|
|
|
|
// R3 (user-requested retail presentation, closing AP-166's text half):
|
|
// the Buying/Selling tabs' own staged-count/total-value and player-
|
|
// purse text elements — retail m_buyListText/m_buyPurseText
|
|
// (VendorBuyUI::VendorBuyUI, pc:199733-199738) and m_sellListText/
|
|
// m_sellPurseText (VendorSellUI::VendorSellUI, pc:199777-199782).
|
|
public const uint BuyingListTextId = 0x100000C7u;
|
|
public const uint BuyingPurseTextId = 0x100000C8u;
|
|
public const uint SellingListTextId = 0x100000D0u;
|
|
public const uint SellingPurseTextId = 0x100000D1u;
|
|
|
|
// Slice 6b: the "Buying" tab's staging-review buttons
|
|
// (docs/research/2026-08-08-slice5-vendor-browse-research.md §B.4 D0
|
|
// tree). All four are optional (nullable) the same way BuyButtonId/
|
|
// AddButtonId are — a missing authored control degrades gracefully
|
|
// instead of failing Bind.
|
|
public const uint BuyItemButtonId = 0x100000C9u;
|
|
public const uint BuyAllButtonId = 0x100000CAu;
|
|
public const uint BuyClearItemButtonId = 0x100000CBu;
|
|
public const uint BuyClearListButtonId = 0x100000CCu;
|
|
|
|
// Slice 6c: the "Selling" tab's staging-review buttons — same D0 tree.
|
|
public const uint SellItemButtonId = 0x100000D2u;
|
|
public const uint SellAllButtonId = 0x100000D3u;
|
|
public const uint SellClearItemButtonId = 0x100000D4u;
|
|
public const uint SellClearListButtonId = 0x100000D5u;
|
|
|
|
/// <summary>
|
|
/// F1 (Slice 5.4 review): the category dropdown's authored popup.
|
|
/// Retail <c>UIElement_Menu::MakePopup</c> (<c>0x0046D310</c>,
|
|
/// <c>pc:120705-120764</c>) reads a PER-MENU popup <c>LayoutDesc</c>
|
|
/// from element attribute 7 (DataID) and a root element id from
|
|
/// attribute 6 (Enum); <c>Initialize</c> (<c>0x0046D440</c>,
|
|
/// <c>pc:120789-120828</c>) then locates the popup's
|
|
/// <c>UIElement_ListBox</c> child via attribute 2. Element
|
|
/// <c>0x100000BF</c>'s resolved attributes — verified against the
|
|
/// committed fixture (<c>vendor_21000012_100000B7.json</c>, property
|
|
/// state <c>4294967295</c>) AND a live-dat scan of LayoutDesc
|
|
/// <c>0x21000043</c> — are <c>7=0x21000043</c> (popup catalog layout),
|
|
/// <c>6=0x1000034F</c> (popup root), <c>2=0x10000350</c> (its
|
|
/// <c>ListBox</c>), <c>9=0x10000352</c> (the row-template element the
|
|
/// dropdown's rows inherit from). NOTE: the original review's citation
|
|
/// of <c>6=0x1000014F</c> was a transcription slip — the fixture and a
|
|
/// live-dat re-scan both read <c>0x1000034F</c>.
|
|
/// <para>
|
|
/// <see cref="UiMenu"/> draws its OWN simplified column-major grid, not
|
|
/// retail's nested <c>ListBox</c> tree, so this port reads off the
|
|
/// AUTHORED VALUES instead of instantiating that subtree: row size
|
|
/// 100x18 (<c>0x10000352</c>'s own Width/Height), 6 rows per column
|
|
/// (the <c>ListBox 0x10000350</c>'s own Height 108 / row height 18),
|
|
/// and the row sprites <c>0x060012B3</c> (Normal)/<c>0x060012B4</c>
|
|
/// (Highlight) — INSTEAD of reusing <see cref="UiMenu"/>'s class-default
|
|
/// constants, which are chat's own authored values (191x17,
|
|
/// RowsPerColumn 7, sprites 0x0600124C/4D/4E: chat's channel menu
|
|
/// (element <c>0x10000014</c> in LayoutDesc <c>0x21000006</c>) has the
|
|
/// SAME three attributes pointing at a completely different, bespoke
|
|
/// popup subtree it authors within its own layout —
|
|
/// <c>7=0x21000006</c> (its own layout id), <c>6=0x1000001C</c>,
|
|
/// <c>9=0x1000001E</c>. This keeps <see cref="ChatWindowController"/>
|
|
/// byte-identical (it is not touched by this fix) while giving
|
|
/// vendor's dropdown its own authored geometry instead of chat's.
|
|
/// </para>
|
|
/// <para>
|
|
/// The popup root <c>0x1000034F</c> carries NO direct-state sprite of
|
|
/// its own (unlike chat's <c>0x0600124C</c> panel fill) — each row
|
|
/// tiles its own background with no gap between rows (6 rows x 18px =
|
|
/// the ListBox's exact 108px height), so
|
|
/// <see cref="UiMenu.PopupBgSprite"/> is left at its default 0
|
|
/// (<see cref="UiMenu"/>'s sprite draw helper no-ops on id 0), matching
|
|
/// 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
|
|
/// template's "Highlight" state) is the paired open/pressed look. This
|
|
/// remains a deliberate, bounded choice — <see cref="UiMenu"/>'s
|
|
/// single-texture 3-slice <c>DrawButtonFace</c> (end caps sized for
|
|
/// chat's 46px LED-arrow texture) is not redesigned into a two-piece
|
|
/// label+arrow renderer for this plain gold body.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>G6 (vendor gate finding, item 1 — the missing green arrow
|
|
/// indicator): closes the arrow-cap half of AP-161's residual.</b>
|
|
/// Retail's actual closed-button chrome IS a separate two-piece
|
|
/// label+arrow assembly — label <c>0x1000034D</c> (100x18, HJustify
|
|
/// LEFT, verified via the fixture) + a SEPARATE 17x19 arrow-cap image
|
|
/// <c>0x1000034E</c> docked at the button's right edge (X=100,Y=0
|
|
/// within the 117-wide button), with its own Normal/Highlight states
|
|
/// resolving to <c>0x060012B1</c> (closed)/<c>0x060012B2</c> (open) —
|
|
/// verified live via <c>tools/VendorLayoutScan</c>'s <c>resolved</c>
|
|
/// command reading the element's two StateMedia images directly out of
|
|
/// <c>client_local_English.dat</c>. <see cref="UiMenu.ArrowCapClosedSprite"/>/
|
|
/// <see cref="UiMenu.ArrowCapOpenSprite"/> draw this AS AN OVERLAY on top
|
|
/// of the existing 3-slice body (additive, not a DrawButtonFace rewrite)
|
|
/// so the green arrow now flips down (closed)/up (open) exactly like
|
|
/// retail, without touching chat's face (whose arrow is baked into its
|
|
/// own texture and never sets these).
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>G7 (vendor gate finding, item 2 — the popup opened upward instead
|
|
/// of downward).</b> Retail's <c>UIElement_Menu::Open</c>
|
|
/// (<c>pc:120210-120252</c>, <c>0x0046cc30</c>) places the popup at
|
|
/// <c>ScreenY1(button)</c> (the button's own BOTTOM edge — i.e. BELOW
|
|
/// it) unless the menu authors bool attribute 5 true, in which case it
|
|
/// places it at <c>ScreenY0(button) - popupHeight</c> (ABOVE).
|
|
/// <c>UIElement::GetAttribute_Bool</c> (<c>pc:106749-106778</c>) defaults
|
|
/// an ABSENT attribute to false. Element <c>0x100000BF</c>'s resolved
|
|
/// attribute bag (the same fixture cited throughout this doc) carries NO
|
|
/// property "5" at all — unlike chat's channel menu (<c>0x10000014</c> in
|
|
/// LayoutDesc <c>0x21000006</c>), which explicitly authors property "5" =
|
|
/// <c>true</c>. So retail opens vendor's dropdown DOWNWARD and chat's
|
|
/// UPWARD — two different authored choices, not one hardcoded direction.
|
|
/// <see cref="UiMenu.OpenUpward"/> (default <c>true</c>, preserving
|
|
/// chat's untouched behavior) is set <c>false</c> here to match.
|
|
/// Retail's <c>Open</c> has no dynamic screen-edge clamp or flip of its
|
|
/// own — the direction is the fixed authored attribute, full stop — so
|
|
/// none is added here either; see <see cref="UiMenu.OpenUpward"/>'s own
|
|
/// doc for the citation.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>G8 (vendor gate finding, item 3 — popup/button text overflowing
|
|
/// instead of sitting flush left).</b> <see cref="UiMenu.TextIndent"/>
|
|
/// (19px) and <see cref="UiMenu.ButtonTextIndent"/> (20px) are CHAT's
|
|
/// own authored offsets — clearing space for a baked-in row checkbox and
|
|
/// a button-face LED socket, respectively. Vendor's row template
|
|
/// (<c>0x10000352</c>, live-dat verified <c>HJustify=Left</c>) has no
|
|
/// checkbox child at all, and vendor's button label child
|
|
/// (<c>0x1000034D</c>) is itself <c>HJustify=Left</c> at X=0 with no LED
|
|
/// art — so both indents are set to 0 here, retail's own left-justified
|
|
/// <c>UiText</c> convention for an icon-less label (every OTHER
|
|
/// dat-driven <c>UiText</c> in this codebase sets <c>Padding=0f</c>).
|
|
/// Reusing chat's 19px row indent measurably overflowed the authored
|
|
/// 100px-wide row: the longest category label, "Spell Components",
|
|
/// measures 92px at the default retail font (<c>Font 0x40000000</c>,
|
|
/// live-measured via <c>tools/VendorLayoutScan measure</c>) —
|
|
/// 19+92=111px > the 100px row, an 11px overflow; with 0px it fits
|
|
/// with 8px to spare.
|
|
/// </para>
|
|
/// </summary>
|
|
private const int TypeMenuRowsPerColumn = 6;
|
|
private const float TypeMenuRowHeight = 18f;
|
|
private const float TypeMenuColumnWidth = 100f;
|
|
private const uint TypeMenuItemNormalSprite = 0x060012B3u;
|
|
private const uint TypeMenuItemHighlightSprite = 0x060012B4u;
|
|
private const uint TypeMenuNormalSprite = 0x060012B3u;
|
|
private const uint TypeMenuPressedSprite = 0x060012B4u;
|
|
// G6: the separate arrow-cap overlay (element 0x1000034E) — see the
|
|
// class doc's "G6" paragraph above.
|
|
private const uint TypeMenuArrowCapClosedSprite = 0x060012B1u;
|
|
private const uint TypeMenuArrowCapOpenSprite = 0x060012B2u;
|
|
|
|
// 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
|
|
/// (<c>pc:200792-201005</c>). Order matters: it's the display/insertion
|
|
/// order AND the "first present entry wins on fresh open" default. Every
|
|
/// mask cross-checked bit-for-bit against
|
|
/// <see cref="ItemType"/> — see the research doc's §B.4 table.
|
|
/// </summary>
|
|
private static readonly (string Label, ItemType Mask)[] CategoryFilters =
|
|
[
|
|
("Armor", ItemType.Armor), // 0x2
|
|
("Books, Paper", ItemType.Writable), // 0x2000
|
|
("Clothing", ItemType.Clothing), // 0x4
|
|
("Containers", ItemType.Container), // 0x200
|
|
("Food", ItemType.Food), // 0x20
|
|
("Gems", ItemType.Gem), // 0x800
|
|
("Jewelry", ItemType.Jewelry), // 0x8
|
|
("Keys, Tools", ItemType.TinkeringTool | ItemType.Key), // 0x20004000
|
|
("Miscellaneous", ItemType.Useless | ItemType.Misc | ItemType.Creature), // 0x490
|
|
("Services", ItemType.Service), // 0x100000
|
|
("Spell Components", ItemType.SpellComponents), // 0x1000
|
|
("Trade Notes", ItemType.PromissoryNote), // 0x40000
|
|
("Weapons", ItemType.Weapon), // 0x101
|
|
("Mana Stones", ItemType.ManaStone), // 0x80000
|
|
("Magic Items", ItemType.Caster), // 0x8000
|
|
("Alchemical Items", ItemType.CraftAlchemyIntermediate | ItemType.CraftAlchemyBase), // 0x4800000
|
|
("Cooking Items", ItemType.CraftCookingBase), // 0x400000
|
|
("Fletching Items", ItemType.CraftFletchingIntermediate | ItemType.CraftFletchingBase), // 0x9000000
|
|
];
|
|
|
|
private readonly VendorState _vendor;
|
|
private readonly RetailWindowHandle _window;
|
|
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
|
|
private readonly ClientObjectTable _objects;
|
|
private readonly Func<uint> _playerGuid;
|
|
private readonly ItemInteractionController _itemInteraction;
|
|
private readonly SelectionState _selection;
|
|
private readonly StackSplitQuantityState _splitQuantity;
|
|
private readonly UiElement _itemsPage;
|
|
private readonly UiElement _buyingPage;
|
|
private readonly UiElement _sellingPage;
|
|
private readonly UiElement _itemsTab;
|
|
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;
|
|
// R3: the Buying/Selling tabs' own staged summary text — optional, the
|
|
// same nullable degrade-gracefully convention as the staging buttons.
|
|
private readonly UiText? _buyListText;
|
|
private readonly UiText? _buyPurseText;
|
|
private readonly UiText? _sellListText;
|
|
private readonly UiText? _sellPurseText;
|
|
private readonly UiButton? _close;
|
|
private readonly UiButton? _buyButton;
|
|
private readonly UiButton? _addButton;
|
|
// Slice 6b: "Buying" tab staging-review buttons.
|
|
private readonly UiButton? _buyItemButton;
|
|
private readonly UiButton? _buyAllButton;
|
|
private readonly UiButton? _buyClearItemButton;
|
|
private readonly UiButton? _buyClearListButton;
|
|
// Slice 6c: "Selling" tab staging-review buttons.
|
|
private readonly UiButton? _sellItemButton;
|
|
private readonly UiButton? _sellAllButton;
|
|
private readonly UiButton? _sellClearItemButton;
|
|
private readonly UiButton? _sellClearListButton;
|
|
// Slice 6b/6c: retail's m_buyList/m_sellList — see VendorStagingList's doc comment.
|
|
private readonly VendorStagingList _buyStaging = new();
|
|
private readonly VendorStagingList _sellStaging = new();
|
|
private readonly RetailDialogFactory? _dialogs;
|
|
private readonly Action<string>? _systemMessage;
|
|
|
|
private readonly List<(string Label, ItemType Mask)> _presentCategories = new();
|
|
private int _selectedCategoryIndex = -1;
|
|
// Slice 6.3/6b: tracks "does the CURRENT selection permit Buy/Add"
|
|
// separately from "is a Buy request currently in flight"
|
|
// (RecomputeBuyButtonEnabled combines both for the Buy AND Add buttons —
|
|
// Add to List is now wired (Slice 6b), no longer permanently disabled).
|
|
private bool _buyEnabledBySelection;
|
|
// Slice 6b: gmVendorUI::m_curDialogContext — a nonzero value means the
|
|
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6
|
|
// case only opens a NEW one when this is 0 (pc:204155).
|
|
private uint _closeConfirmContext;
|
|
// F5: see DragOverGlobalTimeSink's own doc comment.
|
|
private readonly DragOverGlobalTimeSink _dragOverSink;
|
|
private bool _disposed;
|
|
|
|
private VendorUiController(
|
|
VendorState vendor,
|
|
RetailWindowHandle window,
|
|
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
|
ClientObjectTable objects,
|
|
Func<uint> playerGuid,
|
|
ItemInteractionController itemInteraction,
|
|
SelectionState selection,
|
|
StackSplitQuantityState splitQuantity,
|
|
UiElement itemsPage,
|
|
UiElement buyingPage,
|
|
UiElement sellingPage,
|
|
UiElement itemsTab,
|
|
UiElement buyingTab,
|
|
UiElement sellingTab,
|
|
UiItemList itemList,
|
|
UiScrollbar? itemScrollbar,
|
|
UiItemList? buyingList,
|
|
UiScrollbar? buyingScrollbar,
|
|
UiItemList? sellingList,
|
|
UiScrollbar? sellingScrollbar,
|
|
UiMenu typeMenu,
|
|
UiText itemNameText,
|
|
UiText itemCostText,
|
|
UiText? buyListText,
|
|
UiText? buyPurseText,
|
|
UiText? sellListText,
|
|
UiText? sellPurseText,
|
|
UiButton? close,
|
|
UiButton? buyButton,
|
|
UiButton? addButton,
|
|
UiButton? buyItemButton,
|
|
UiButton? buyAllButton,
|
|
UiButton? buyClearItemButton,
|
|
UiButton? buyClearListButton,
|
|
UiButton? sellItemButton,
|
|
UiButton? sellAllButton,
|
|
UiButton? sellClearItemButton,
|
|
UiButton? sellClearListButton,
|
|
RetailDialogFactory? dialogs,
|
|
Action<string>? systemMessage,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont,
|
|
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
|
uint emptySlotSprite,
|
|
uint buyingEmptySlotSprite,
|
|
uint sellingEmptySlotSprite)
|
|
{
|
|
_vendor = vendor;
|
|
_window = window;
|
|
_resolveIcon = resolveIcon;
|
|
_objects = objects;
|
|
_playerGuid = playerGuid;
|
|
_itemInteraction = itemInteraction;
|
|
_selection = selection;
|
|
_splitQuantity = splitQuantity;
|
|
_itemsPage = itemsPage;
|
|
_buyingPage = buyingPage;
|
|
_sellingPage = sellingPage;
|
|
_itemsTab = itemsTab;
|
|
_buyingTab = buyingTab;
|
|
_sellingTab = sellingTab;
|
|
_itemList = itemList;
|
|
_buyingList = buyingList;
|
|
_sellingList = sellingList;
|
|
_typeMenu = typeMenu;
|
|
_itemNameText = itemNameText;
|
|
_itemCostText = itemCostText;
|
|
_buyListText = buyListText;
|
|
_buyPurseText = buyPurseText;
|
|
_sellListText = sellListText;
|
|
_sellPurseText = sellPurseText;
|
|
_close = close;
|
|
_buyButton = buyButton;
|
|
_addButton = addButton;
|
|
_buyItemButton = buyItemButton;
|
|
_buyAllButton = buyAllButton;
|
|
_buyClearItemButton = buyClearItemButton;
|
|
_buyClearListButton = buyClearListButton;
|
|
_sellItemButton = sellItemButton;
|
|
_sellAllButton = sellAllButton;
|
|
_sellClearItemButton = sellClearItemButton;
|
|
_sellClearListButton = sellClearListButton;
|
|
_dialogs = dialogs;
|
|
_systemMessage = systemMessage;
|
|
|
|
_itemList.Columns = 1;
|
|
_itemList.SingleRow = true;
|
|
_itemList.HorizontalScroll = true;
|
|
_itemList.CellWidth = 32f;
|
|
_itemList.CellHeight = 32f;
|
|
// F7b (Slice 5.4 review): fill visible empty slots the same way
|
|
// ExternalContainerController.ConfigureList does — an authored,
|
|
// mostly-empty single-row strip should show its empty-slot
|
|
// background art in the unused cells, not blank space.
|
|
_itemList.FillVisibleEmptySlots = true;
|
|
if (emptySlotSprite != 0u)
|
|
_itemList.CellEmptySprite = emptySlotSprite;
|
|
// F3 (Slice 6 review): vendor rows are never drag sources — see
|
|
// UiItemSlot.AllowDragSource. Set here too (empty cells are already
|
|
// non-sources via ItemId==0) so the invariant holds by construction
|
|
// rather than incidentally.
|
|
_itemList.EmptySlotFactory = () => new UiItemSlot
|
|
{
|
|
SpriteResolve = _itemList.SpriteResolve,
|
|
AllowDragSource = false,
|
|
};
|
|
// Slice 6.1: mirrors ExternalContainerController's own
|
|
// right-click-examine wiring (UiItemSlot.OnEvent's RightClick case).
|
|
// Now that shop items are materialized into ClientObjectTable (see
|
|
// VendorShopItemMaterializer), AppraisalUiController.Apply's lookup
|
|
// succeeds and this stops being a dead end — closes half of AP-161
|
|
// finding #2.
|
|
_itemList.ExamineItemRequested = ExamineItem;
|
|
if (itemScrollbar is not null)
|
|
{
|
|
itemScrollbar.Model = _itemList.Scroll;
|
|
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;
|
|
}
|
|
// Slice 6c: gmVendorUI::HandleDropRelease routes EVERY drop in the
|
|
// whole panel through one IsAncestorOfMe(target, m_sellShopList)
|
|
// gate (pc:204229-204246) — the Selling tab's list is the ONLY drop
|
|
// target. UiItemList.RegisterDragHandler is the structural analogue.
|
|
_sellingList?.RegisterDragHandler(this);
|
|
|
|
// F5: mount the global-time sink so a live drag hovering anywhere
|
|
// over this window auto-switches to the Selling tab — see
|
|
// DragOverGlobalTimeSink's and PollDragOver's own doc comments.
|
|
_dragOverSink = new DragOverGlobalTimeSink(PollDragOver);
|
|
_window.ContentRoot.AddChild(_dragOverSink);
|
|
|
|
// 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
|
|
// instead of UiMenu's chat-shaped class defaults.
|
|
_typeMenu.SpriteResolve = resolveSprite;
|
|
_typeMenu.DatFont = datFont;
|
|
_typeMenu.Font = debugFont;
|
|
_typeMenu.NormalSprite = TypeMenuNormalSprite;
|
|
_typeMenu.PressedSprite = TypeMenuPressedSprite;
|
|
_typeMenu.ItemNormalSprite = TypeMenuItemNormalSprite;
|
|
_typeMenu.ItemHighlightSprite = TypeMenuItemHighlightSprite;
|
|
_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;
|
|
// G6: the separate open/closed arrow-cap overlay — see the class
|
|
// doc's "G6" paragraph above.
|
|
_typeMenu.ArrowCapClosedSprite = TypeMenuArrowCapClosedSprite;
|
|
_typeMenu.ArrowCapOpenSprite = TypeMenuArrowCapOpenSprite;
|
|
// G7: retail authors NO bool attribute 5 on this menu, so
|
|
// UIElement_Menu::Open's absent-defaults-false path opens the popup
|
|
// DOWNWARD (ScreenY1, the button's own bottom edge) — see the class
|
|
// doc's "G7" paragraph above. Chat's own menu is unaffected (its
|
|
// controller never touches this property, leaving UiMenu's true
|
|
// default).
|
|
_typeMenu.OpenUpward = false;
|
|
// G8: vendor's row/button-label children carry no checkbox/LED art
|
|
// to clear (unlike chat's), so both indents are flush-left (0) —
|
|
// see the class doc's "G8" paragraph above.
|
|
_typeMenu.TextIndent = 0f;
|
|
_typeMenu.ButtonTextIndent = 0f;
|
|
_typeMenu.OnSelect = payload =>
|
|
{
|
|
if (payload is uint mask) SelectCategory(mask);
|
|
};
|
|
_typeMenu.ButtonLabelProvider = () =>
|
|
_selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
|
|
? _presentCategories[_selectedCategoryIndex].Label
|
|
: string.Empty;
|
|
|
|
RetailTabBinding.SetClick(_itemsTab, () => ShowTab(VendorPanelTab.Items));
|
|
RetailTabBinding.SetClick(_buyingTab, () => ShowTab(VendorPanelTab.Buying));
|
|
RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling));
|
|
if (_close is not null)
|
|
_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).
|
|
if (_buyButton is not null)
|
|
_buyButton.OnClick = BuySelectedItem;
|
|
// Slice 6b: 0x100000c3 — VendorItemsUI::AddToBuyList (research doc
|
|
// §Q3). No longer permanently disabled (AP-161 F8 residual closes).
|
|
if (_addButton is not null)
|
|
_addButton.OnClick = AddSelectedToBuyList;
|
|
// Slice 6b: the "Buying" tab's four staging buttons.
|
|
if (_buyItemButton is not null)
|
|
_buyItemButton.OnClick = BuyItemButtonPressed;
|
|
if (_buyAllButton is not null)
|
|
_buyAllButton.OnClick = BuyAllButtonPressed;
|
|
if (_buyClearItemButton is not null)
|
|
_buyClearItemButton.OnClick = BuyClearItemButtonPressed;
|
|
if (_buyClearListButton is not null)
|
|
_buyClearListButton.OnClick = () => _buyStaging.Clear();
|
|
// Slice 6c: the "Selling" tab's four staging buttons.
|
|
if (_sellItemButton is not null)
|
|
_sellItemButton.OnClick = SellItemButtonPressed;
|
|
if (_sellAllButton is not null)
|
|
_sellAllButton.OnClick = SellAllButtonPressed;
|
|
if (_sellClearItemButton is not null)
|
|
_sellClearItemButton.OnClick = SellClearItemButtonPressed;
|
|
if (_sellClearListButton is not null)
|
|
_sellClearListButton.OnClick = () => _sellStaging.Clear();
|
|
|
|
_buyStaging.Changed += RebuildBuyingList;
|
|
// F2 (Slice 6b/6c review): the Items tab's own row visibility must
|
|
// track staging too — see RefreshItemsTabAvailability's doc.
|
|
_buyStaging.Changed += RefreshItemsTabAvailability;
|
|
_sellStaging.Changed += RebuildSellingList;
|
|
// R3: the tabs' own staged-count/total-value/purse text must track
|
|
// every staging change, matching retail's Update() -> ...
|
|
// UpdateTransactionValue()/UpdateTotalValue() chain (VendorBuyUI::Update
|
|
// pc:202996-203005, VendorSellUI::Update pc:203009-203018) — both
|
|
// called unconditionally on EVERY staging mutation, not just Add/Remove.
|
|
_buyStaging.Changed += UpdateBuyTransactionText;
|
|
_sellStaging.Changed += UpdateSellTransactionText;
|
|
// R3: a player money change (a purchase/sale elsewhere, a pickup, a
|
|
// drop) must repaint the purse line even with staging unchanged —
|
|
// retail's own m_totalValue/m_last_sale-driven purse text has no
|
|
// separate "staging changed" gate from "holdings changed" (both
|
|
// UpdateTotalValue calls read the LIVE holding fresh, same as
|
|
// BuildCostText's own PropertyInt.CoinValue read).
|
|
_objects.ObjectUpdated += OnObjectMoneyChanged;
|
|
|
|
ShowTab(VendorPanelTab.Items);
|
|
ClearContent();
|
|
|
|
_vendor.Changed += OnVendorChanged;
|
|
// Slice 6.2: SelectionState is now the AUTHORITY (research doc §B.4:
|
|
// vendor-context selections flow through the SAME global
|
|
// ACCWeenieObject::SetSelectedObject primitive as every other
|
|
// origin) — this panel is a CONSUMER, mirroring
|
|
// ExternalContainerController.OnSelectionChanged's shape exactly.
|
|
_selection.Changed += OnSelectionTransition;
|
|
// Slice 6.2: mirrors ExternalContainerController.OnObjectRemoved —
|
|
// retail's VendorItemsUI::RemoveFromShop (pc:202848-202850,
|
|
// 0x004c3d4a) clears the global selection when a shop item leaves
|
|
// the list (there is no separate CloseVendor-level SetSelectedObject(0)
|
|
// call; retail's ItemList_Flush on close does not touch selection
|
|
// directly). Since VendorShopItemMaterializer removes every
|
|
// materialized item from ClientObjectTable on Close/Reset/replace
|
|
// (Slice 6.1), subscribing here gives "vendor session close clears
|
|
// a vendor-owned selection" as a consequence of the SAME generic
|
|
// mechanism every other panel already uses, not a vendor-specific
|
|
// special case.
|
|
_objects.ObjectRemoved += OnObjectRemoved;
|
|
// Slice 6.3: mirrors ExternalContainerController's own
|
|
// _itemInteraction.StateChanged subscription — the Buy button must
|
|
// disable the instant a reservation is taken (BeginUseRequestReservation
|
|
// increments BusyCount synchronously, before the wire send), and
|
|
// re-enable on the matching UseDone/cancel, without polling.
|
|
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
|
// F2 (Slice 6 review): retail re-runs UpdateItemsUI on every slider
|
|
// change (gmVendorUI::RecvNotice_StackSliderChanged, pc:203262-203278)
|
|
// — the displayed name/price must track the LIVE split, not just the
|
|
// value at selection time.
|
|
_splitQuantity.Changed += OnSplitQuantityChanged;
|
|
}
|
|
|
|
/// <param name="objects">
|
|
/// F2/F3 (Slice 5.4 review): the local player's coin total
|
|
/// (<c>PropertyInt.CoinValue</c>) lives here — the same generic
|
|
/// <c>ClientObjectTable</c> int-property bundle every other
|
|
/// PropertyInt-driven display reads, populated by
|
|
/// <c>ObjectTableWiring</c>'s <c>PrivateUpdatePropertyInt</c> routing.
|
|
/// </param>
|
|
/// <param name="playerGuid">Resolves the local player's guid to look up in <paramref name="objects"/>.</param>
|
|
/// <param name="itemInteraction">
|
|
/// Slice 6.1: the shared retail item interaction orchestrator — its
|
|
/// <c>ExamineSelectedOrEnterMode</c> is what right-click-examine on a
|
|
/// shop row routes through, mirroring
|
|
/// <see cref="ExternalContainerController"/>'s own examine wiring.
|
|
/// </param>
|
|
/// <param name="selection">
|
|
/// Slice 6.2: the canonical <see cref="SelectionState"/> — now the
|
|
/// AUTHORITY for shop-row selection (row clicks, the F4 auto-select
|
|
/// fallback, and right-click examine all call
|
|
/// <see cref="SelectionState.Select"/> directly); this panel only
|
|
/// listens and reacts, the same way every sibling panel does.
|
|
/// </param>
|
|
/// <param name="splitQuantity">
|
|
/// Slice 6.3: the shared toolbar stack-quantity control (retail
|
|
/// <c>GenItemHolder::splitSize</c>/<c>maxSplitSize</c>) — the Buy button
|
|
/// reads the SAME live value <see cref="SelectedObjectController"/>
|
|
/// seeds/the player adjusts via <c>ItemHolder::GetObjectSplitSize</c>
|
|
/// (<c>0x00586F00</c>), matching retail's <c>BuySingleItem</c>
|
|
/// (<c>pc:201674-201681</c>: quantity 1 if <c>_stackSize <= 1</c>,
|
|
/// else the current slider value).
|
|
/// </param>
|
|
/// <param name="datFont">Retail dat font for the category dropdown's button/row labels.</param>
|
|
/// <param name="debugFont">Fallback debug bitmap font (used when <paramref name="datFont"/> is null).</param>
|
|
/// <param name="resolveSprite">Dat RenderSurface id → (GL tex handle, px width, px height).</param>
|
|
/// <param name="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,
|
|
RetailWindowHandle window,
|
|
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
|
ClientObjectTable objects,
|
|
Func<uint> playerGuid,
|
|
ItemInteractionController itemInteraction,
|
|
SelectionState selection,
|
|
StackSplitQuantityState splitQuantity,
|
|
UiDatFont? datFont,
|
|
BitmapFont? debugFont,
|
|
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
|
uint emptySlotSprite = 0u,
|
|
uint buyingEmptySlotSprite = 0u,
|
|
uint sellingEmptySlotSprite = 0u,
|
|
// Slice 6b: the panel's own X-close confirmation when staging is
|
|
// non-empty (Q3's close-button finding). Optional — absent gracefully
|
|
// degrades the close gate to a plain hide (see CloseButtonPressed).
|
|
RetailDialogFactory? dialogs = null,
|
|
// Slice 6b/6c: InqAcceptability rejection strings, the Buy-All
|
|
// affordability/capacity pre-send guards (F1, Slice 6b/6c review),
|
|
// and the buy/sell staging notices all share this one sink.
|
|
Action<string>? systemMessage = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(layout);
|
|
ArgumentNullException.ThrowIfNull(vendor);
|
|
ArgumentNullException.ThrowIfNull(window);
|
|
ArgumentNullException.ThrowIfNull(resolveIcon);
|
|
ArgumentNullException.ThrowIfNull(objects);
|
|
ArgumentNullException.ThrowIfNull(playerGuid);
|
|
ArgumentNullException.ThrowIfNull(itemInteraction);
|
|
ArgumentNullException.ThrowIfNull(selection);
|
|
ArgumentNullException.ThrowIfNull(splitQuantity);
|
|
ArgumentNullException.ThrowIfNull(resolveSprite);
|
|
|
|
if (layout.FindElement(ItemsPageId) is not { } itemsPage
|
|
|| layout.FindElement(BuyingPageId) is not { } buyingPage
|
|
|| layout.FindElement(SellingPageId) is not { } sellingPage
|
|
|| layout.FindElement(ItemsTabId) is not { } itemsTab
|
|
|| layout.FindElement(BuyingTabId) is not { } buyingTab
|
|
|| layout.FindElement(SellingTabId) is not { } sellingTab
|
|
|| layout.FindElement(ItemListId) is not UiItemList itemList
|
|
|| layout.FindElement(TypeFilterMenuId) is not UiMenu typeMenu
|
|
|| layout.FindElement(ItemNameTextId) is not UiText itemNameText
|
|
|| layout.FindElement(ItemCostTextId) is not UiText itemCostText)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// R3: the Buying/Selling tabs' own summary text — optional, same
|
|
// degrade-gracefully convention as the staging buttons.
|
|
UiText? buyListText = layout.FindElement(BuyingListTextId) as UiText;
|
|
UiText? buyPurseText = layout.FindElement(BuyingPurseTextId) as UiText;
|
|
UiText? sellListText = layout.FindElement(SellingListTextId) as UiText;
|
|
UiText? sellPurseText = layout.FindElement(SellingPurseTextId) as UiText;
|
|
|
|
UiButton? close = layout.FindElement(CloseId) as UiButton;
|
|
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;
|
|
// Slice 6b/6c: the eight staging-review buttons — all optional.
|
|
UiButton? buyItemButton = layout.FindElement(BuyItemButtonId) as UiButton;
|
|
UiButton? buyAllButton = layout.FindElement(BuyAllButtonId) as UiButton;
|
|
UiButton? buyClearItemButton = layout.FindElement(BuyClearItemButtonId) as UiButton;
|
|
UiButton? buyClearListButton = layout.FindElement(BuyClearListButtonId) as UiButton;
|
|
UiButton? sellItemButton = layout.FindElement(SellItemButtonId) as UiButton;
|
|
UiButton? sellAllButton = layout.FindElement(SellAllButtonId) as UiButton;
|
|
UiButton? sellClearItemButton = layout.FindElement(SellClearItemButtonId) as UiButton;
|
|
UiButton? sellClearListButton = layout.FindElement(SellClearListButtonId) as UiButton;
|
|
|
|
return new VendorUiController(
|
|
vendor,
|
|
window,
|
|
resolveIcon,
|
|
objects,
|
|
playerGuid,
|
|
itemInteraction,
|
|
selection,
|
|
splitQuantity,
|
|
itemsPage,
|
|
buyingPage,
|
|
sellingPage,
|
|
itemsTab,
|
|
buyingTab,
|
|
sellingTab,
|
|
itemList,
|
|
itemScrollbar,
|
|
buyingList,
|
|
buyingScrollbar,
|
|
sellingList,
|
|
sellingScrollbar,
|
|
typeMenu,
|
|
itemNameText,
|
|
itemCostText,
|
|
buyListText,
|
|
buyPurseText,
|
|
sellListText,
|
|
sellPurseText,
|
|
close,
|
|
buyButton,
|
|
addButton,
|
|
buyItemButton,
|
|
buyAllButton,
|
|
buyClearItemButton,
|
|
buyClearListButton,
|
|
sellItemButton,
|
|
sellAllButton,
|
|
sellClearItemButton,
|
|
sellClearListButton,
|
|
dialogs,
|
|
systemMessage,
|
|
datFont,
|
|
debugFont,
|
|
resolveSprite,
|
|
emptySlotSprite,
|
|
buyingEmptySlotSprite,
|
|
sellingEmptySlotSprite);
|
|
}
|
|
|
|
private enum VendorPanelTab { Items, Buying, Selling }
|
|
|
|
/// <summary>
|
|
/// F5 (Slice 6b/6c review): a runtime-only, zero-size, always-invisible-
|
|
/// to-hit-testing helper that opts this window into retail's global UI
|
|
/// message 3 — <c>gmVendorUI::ListenToGlobalMessage</c> (<c>0x004c0480</c>):
|
|
/// <c>if (arg2 == 3) gmVendorUI::UpdateDragOver(this);</c>. This
|
|
/// controller is not itself a <see cref="UiElement"/> (it wraps
|
|
/// several), so it cannot directly implement <see cref="IUiGlobalTimeListener"/>
|
|
/// the way <see cref="UiButton"/> does — <see cref="UiRoot.Tick"/>'s
|
|
/// broadcast walks the ELEMENT tree, not arbitrary controllers. Mounting
|
|
/// this tiny sink as a child of the window gives it the same periodic
|
|
/// pulse retail's own <c>UIElementManager::UseTime</c> delivers, without
|
|
/// adding a second per-frame plumbing path.
|
|
/// </summary>
|
|
private sealed class DragOverGlobalTimeSink : UiElement, IUiGlobalTimeListener
|
|
{
|
|
private readonly Action _onGlobalUiTime;
|
|
public DragOverGlobalTimeSink(Action onGlobalUiTime) => _onGlobalUiTime = onGlobalUiTime;
|
|
public void OnGlobalUiTime(double nowSeconds) => _onGlobalUiTime();
|
|
}
|
|
|
|
private void ShowTab(VendorPanelTab tab)
|
|
{
|
|
_itemsPage.Visible = tab == VendorPanelTab.Items;
|
|
_buyingPage.Visible = tab == VendorPanelTab.Buying;
|
|
_sellingPage.Visible = tab == VendorPanelTab.Selling;
|
|
RetailTabBinding.SetOpen(_itemsTab, tab == VendorPanelTab.Items);
|
|
RetailTabBinding.SetOpen(_buyingTab, tab == VendorPanelTab.Buying);
|
|
RetailTabBinding.SetOpen(_sellingTab, tab == VendorPanelTab.Selling);
|
|
}
|
|
|
|
private void OnVendorChanged(VendorTransition transition)
|
|
{
|
|
switch (transition.Kind)
|
|
{
|
|
case VendorStateTransitionKind.Opened:
|
|
// F6 (Slice 5.4 review): a DIFFERENT vendor than whatever was
|
|
// previously open — gmVendorUI::OpenVendor flushes sub-UIs
|
|
// when sameVendor==0 (pc:203664-203667), so the previous
|
|
// vendor's category selection must not carry over into the
|
|
// new vendor's (unrelated) category list. VendorTransition
|
|
// already distinguishes this case from same-vendor
|
|
// Refreshed (VendorState.Apply's sameVendor check), so
|
|
// resetting here is sufficient — RebuildCategories' existing
|
|
// clamp (selected<0 -> 0) then lands on the new vendor's
|
|
// first present category, matching retail.
|
|
// Slice 6b/6c: a different vendor's staged items are for a
|
|
// shop the player is no longer looking at — clear both
|
|
// staging lists the same way the category selection resets.
|
|
_buyStaging.Clear();
|
|
_sellStaging.Clear();
|
|
_selectedCategoryIndex = -1;
|
|
ShowTab(VendorPanelTab.Items);
|
|
RebuildCategories();
|
|
_window.Show();
|
|
break;
|
|
case VendorStateTransitionKind.Refreshed:
|
|
// Same vendor re-approached (post-buy/sell refresh, Slice 6)
|
|
// — preserve the selection via RebuildCategories' clamp,
|
|
// matching retail's sameVendor==1 path. Staging is NOT
|
|
// cleared here: a Refreshed transition follows a Buy All/
|
|
// Sell All send, which already flushed its own list
|
|
// synchronously at send time (retail: PackableList::Flush
|
|
// right after SendShopEvent, pc:204076/label_4c560a) — by
|
|
// the time this fires the relevant list is already empty in
|
|
// the normal flow, and the OTHER (untouched) list must
|
|
// survive a refresh triggered by its sibling.
|
|
ShowTab(VendorPanelTab.Items);
|
|
RebuildCategories();
|
|
_window.Show();
|
|
break;
|
|
case VendorStateTransitionKind.Closed:
|
|
case VendorStateTransitionKind.Reset:
|
|
// Slice 6b/6c: session close/teardown clears staging WITH
|
|
// the session (contract's C2/C3 close semantics).
|
|
_buyStaging.Clear();
|
|
_sellStaging.Clear();
|
|
ClearContent();
|
|
ShowTab(VendorPanelTab.Items);
|
|
_window.Hide();
|
|
// F8 (Slice 6b/6c review): a live X-close confirmation
|
|
// dialog must not survive the session it was confirming
|
|
// the abandonment of — a range-triggered Close() or a
|
|
// portal/logout Reset() while the dialog is up left it
|
|
// dangling (a stale callback capturing this disposed
|
|
// controller's state) before this fix.
|
|
DismissCloseConfirmationIfOpen();
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of the tail of <c>VendorItemsUI::OpenVendor</c>
|
|
/// (<c>pc:200789-201022</c>): flush the menu, walk
|
|
/// <see cref="CategoryFilters"/> in order keeping only entries
|
|
/// <c>ListContainsType</c> would keep, then re-derive the selected index
|
|
/// with retail's exact clamp (preserve if still valid, else clamp to the
|
|
/// last entry, else 0).
|
|
/// </summary>
|
|
private void RebuildCategories()
|
|
{
|
|
IReadOnlyList<VendorShopItem> items = _vendor.Items;
|
|
|
|
_presentCategories.Clear();
|
|
foreach ((string label, ItemType mask) in CategoryFilters)
|
|
{
|
|
uint maskValue = (uint)mask;
|
|
bool present = false;
|
|
for (int i = 0; i < items.Count; i++)
|
|
{
|
|
if (((items[i].ItemType ?? 0u) & maskValue) != 0u)
|
|
{
|
|
present = true;
|
|
break;
|
|
}
|
|
}
|
|
if (present) _presentCategories.Add((label, mask));
|
|
}
|
|
|
|
int selected = _selectedCategoryIndex;
|
|
if (selected >= _presentCategories.Count - 1)
|
|
selected = _presentCategories.Count - 1;
|
|
if (selected < 0)
|
|
selected = 0;
|
|
_selectedCategoryIndex = selected;
|
|
|
|
_typeMenu.Items = _presentCategories
|
|
.Select(entry => new UiMenu.MenuItem(entry.Label, (object)(uint)entry.Mask))
|
|
.ToArray();
|
|
_typeMenu.Selected = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
|
|
? (object)(uint)_presentCategories[_selectedCategoryIndex].Mask
|
|
: null;
|
|
|
|
RebuildItemList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// User picked a different dropdown entry — port of
|
|
/// <c>VendorItemsUI::UpdateItemsList</c>'s explicit-mask branch
|
|
/// (<c>arg2 != 0</c>, <c>pc:201039-201040</c>).
|
|
/// </summary>
|
|
private void SelectCategory(uint mask)
|
|
{
|
|
int index = _presentCategories.FindIndex(entry => (uint)entry.Mask == mask);
|
|
if (index < 0 || index == _selectedCategoryIndex) return;
|
|
|
|
_selectedCategoryIndex = index;
|
|
_typeMenu.Selected = (object)mask;
|
|
RebuildItemList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of <c>VendorItemsUI::UpdateItemsList</c>'s item-insertion loop
|
|
/// (<c>pc:201059-201158</c>): only items whose type intersects the
|
|
/// active mask are shown. There is no "all categories" state — an empty
|
|
/// <see cref="_presentCategories"/> (no vendor stock at all, or nothing
|
|
/// selected) shows zero rows, matching retail.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// F4/F7a (Slice 5.4 review) + F6 (Slice 6 review): the tail of the SAME
|
|
/// retail function (<c>pc:201180-201190</c>) — after every rebuild the
|
|
/// list unconditionally scrolls back to its start
|
|
/// (<c>ScrollToShow(m_shopList, 0)</c>) and, on the retail caller's
|
|
/// "notify" paths, the FIRST item that passed the filter becomes the
|
|
/// selection UNCONDITIONALLY — there is no survival test
|
|
/// (<c>pc:201180-201184</c>: <c>if (arg3 != 0) SetSelectedObject(i_1, 0)</c>,
|
|
/// where <c>i_1</c> is simply the first matching item, 0/none if the
|
|
/// filter matched nothing). F6 confirmed all THREE of our call sites are
|
|
/// retail's notify=1 case, not notify=0: a fresh vendor open AND a
|
|
/// same-vendor post-buy/sell refresh both run
|
|
/// <c>VendorItemsUI::OpenVendor</c> UNCONDITIONALLY (<c>pc:203852</c>,
|
|
/// not gated on the sameVendor flag), which clamps the dropdown index
|
|
/// and calls <c>UIElement_Menu::SetSelectedItem(..., 1)</c>
|
|
/// (<c>pc:200783-201022</c>) — that trailing <c>1</c> is retail's
|
|
/// selection-changed notify flag, and setting the menu's selection
|
|
/// synchronously cascades into <c>UpdateItemsList(0, 1)</c> via
|
|
/// <c>gmVendorUI::ListenToElementMessage</c>'s <c>idMessage==7</c> case
|
|
/// (<c>pc:204302-204303</c>) — the SAME idMessage==7/notify=1 path a
|
|
/// manual category switch (<see cref="SelectCategory"/>) already takes.
|
|
/// Retail's ONLY notify=0 (no-reselect) call site is a bare tab-page-open
|
|
/// with no category/vendor change (<c>idMessage==0x2c</c>,
|
|
/// <c>m_OpenPageToken==0x100000bc</c>, <c>pc:204283-204285</c>) — this
|
|
/// controller has no equivalent call site (<see cref="ShowTab"/> never
|
|
/// calls this method), so every path that reaches
|
|
/// <see cref="RebuildItemList"/> is retail's notify=1 case. Slice 6.2:
|
|
/// retail routes the selection through the global
|
|
/// <c>ACCWeenieObject::selectedID</c>/<c>SetSelectedObject</c>
|
|
/// (<c>pc:201184</c>) — this calls <see cref="SelectionState.Select"/>/
|
|
/// <see cref="SelectionState.Clear"/> instead of the retired private
|
|
/// field, so the toolbar status bar and slider light up for the
|
|
/// auto-selected item too.
|
|
/// </remarks>
|
|
private void RebuildItemList() => RebuildItemList(reselectFirst: true);
|
|
|
|
/// <param name="reselectFirst">
|
|
/// F2 (Slice 6b/6c review): <see langword="true"/> for every ORIGINAL
|
|
/// call site (category rebuild — Opened/Refreshed/manual category
|
|
/// switch, retail's notify=1 case per the remarks above).
|
|
/// <see langword="false"/> for the NEW staging-triggered repaint this
|
|
/// review added (<see cref="RefreshItemsTabAvailability"/>) — retail's
|
|
/// <c>RemoveFromShop</c>/<c>DeleteItem</c> (the function that actually
|
|
/// hides/shrinks a row as staging consumes it) never reselects to the
|
|
/// first item; only a full <c>UpdateItemsList</c> rebuild does that, and
|
|
/// staging a Buy does not trigger one.
|
|
/// </param>
|
|
private void RebuildItemList(bool reselectFirst)
|
|
{
|
|
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
|
|
? _presentCategories[_selectedCategoryIndex].Mask
|
|
: default;
|
|
uint maskValue = (uint)activeMask;
|
|
|
|
IReadOnlyList<VendorShopItem> items = _vendor.Items;
|
|
// Best-effort initial paint only — the OLD selection, captured
|
|
// before this rebuild. The unconditional Select/Clear call below is
|
|
// what actually decides the post-rebuild selection (F6); this just
|
|
// avoids a one-frame flash of "nothing highlighted" in the common
|
|
// case where the old and new selections turn out to be the same
|
|
// guid (SelectionState.Select is a no-op then and won't re-fire
|
|
// Changed to correct it).
|
|
uint? selectedGuid = _selection.SelectedObjectId;
|
|
VendorShopItem? firstItem = null;
|
|
bool selectedStillVisible = false;
|
|
|
|
using (_itemList.DeferLayout())
|
|
{
|
|
_itemList.Flush();
|
|
if (maskValue != 0u)
|
|
{
|
|
foreach (VendorShopItem item in items)
|
|
{
|
|
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
|
|
// F2 (Slice 6b/6c review): a shop item fully consumed by
|
|
// staging hides its row — port of
|
|
// VendorItemsUI::RemoveFromShop's DeleteItem branch
|
|
// (pc:202846-202852) — see AvailableShopQuantity's own
|
|
// doc comment.
|
|
if (AvailableShopQuantity(item) <= 0) continue;
|
|
|
|
firstItem ??= item;
|
|
if (item.ItemGuid == selectedGuid) selectedStillVisible = true;
|
|
|
|
// F5 (Slice 5.4 review): forward the icon underlay/
|
|
// overlay/effects PublicWeenieDescParser already
|
|
// captures, matching ExternalContainerController.
|
|
// CreateCell's exact resolve-icon call shape.
|
|
uint icon = _resolveIcon(
|
|
(ItemType)(item.ItemType ?? 0u),
|
|
item.IconId,
|
|
item.IconUnderlayId,
|
|
item.IconOverlayId,
|
|
item.Effects);
|
|
var cell = new UiItemSlot
|
|
{
|
|
SpriteResolve = _itemList.SpriteResolve,
|
|
SlotIndex = _itemList.GetNumUIItems(),
|
|
// F3 (Slice 6 review): a shop row must never become a
|
|
// drag source — see UiItemSlot.AllowDragSource's doc
|
|
// comment for why this must be gated at the source,
|
|
// not left to every destination handler to reject.
|
|
AllowDragSource = false,
|
|
};
|
|
cell.SetItem(item.ItemGuid, icon);
|
|
cell.Selected = item.ItemGuid == selectedGuid;
|
|
VendorShopItem captured = item;
|
|
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
|
// AP-171: double-click buys the item — a DELIBERATE,
|
|
// user-approved modernization. Retail has NO
|
|
// double-click-to-buy anywhere in the named function
|
|
// table (negative evidence recorded at the Slice 6
|
|
// research); the user requested it explicitly
|
|
// 2026-08-08 after being told so. Select-then-buy so
|
|
// the quantity/price path is identical to the Buy
|
|
// button's.
|
|
cell.DoubleClicked = () =>
|
|
{
|
|
_selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
|
BuySelectedItem();
|
|
};
|
|
_itemList.AddItem(cell);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (reselectFirst)
|
|
{
|
|
// F6: unconditional — no survival test. Every rebuild call site
|
|
// (Opened/Refreshed via RebuildCategories, and a manual category
|
|
// switch via SelectCategory) is retail's notify=1 case; see the
|
|
// remarks above for the decomp trace.
|
|
if (firstItem is { } first)
|
|
_selection.Select(first.ItemGuid, SelectionChangeSource.Vendor);
|
|
else
|
|
_selection.Clear(SelectionChangeSource.Vendor);
|
|
|
|
// F7a: unconditional scroll-to-start on every rebuild (retail only
|
|
// guards on the list being non-empty; resetting an already-empty
|
|
// list's scroll is harmless).
|
|
_itemList.Scroll.SetScrollY(0);
|
|
}
|
|
else if (selectedGuid is not null && !selectedStillVisible)
|
|
{
|
|
// F2: the currently-selected row just disappeared (staging
|
|
// consumed its last unit) — matches RemoveFromShop's own
|
|
// unconditional SetSelectedObject(0, 0) on the delete path
|
|
// (pc:202848-202850), NOT a reselect-to-first.
|
|
_selection.Clear(
|
|
SelectionChangeSource.Vendor,
|
|
SelectionChangeReason.SelectedObjectRemoved);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2 (Slice 6b/6c review): retail's <c>RemoveFromShop</c>
|
|
/// (<c>0x004c3ce0</c>) recomputes "shop remaining = ORIGINAL vendor
|
|
/// supply - the item's CURRENT total staged amount" fresh on every call
|
|
/// (<c>pc:202844</c>: <c>_maxStackSize_1 = var_c - arg3</c>, where
|
|
/// <c>var_c</c> is read fresh from the untouched <c>shopItemProfileList</c>
|
|
/// and <c>arg3</c> is <c>AddToBuyList</c>'s own running accumulated
|
|
/// total) rather than incrementally decrementing a mutable counter.
|
|
/// This mirrors that shape exactly: <see cref="VendorState.Items"/>
|
|
/// (the untouched <c>ApproachVendor</c> snapshot) combined with
|
|
/// <see cref="_buyStaging"/>'s current total, recomputed on every call
|
|
/// — so un-staging automatically restores a hidden/shrunk row with no
|
|
/// separate "restore" code path. <see cref="int.MaxValue"/> for
|
|
/// unlimited supply (<see cref="VendorShopItem.StackSize"/> == -1,
|
|
/// retail's <c>ItemProfile</c> sign-extended unlimited sentinel) —
|
|
/// retail's own <c>var_c != 0xffffffff</c> guard skips the whole
|
|
/// decrement/delete branch for unlimited stock.
|
|
/// </summary>
|
|
private int AvailableShopQuantity(VendorShopItem item)
|
|
{
|
|
if (item.StackSize < 0)
|
|
return int.MaxValue;
|
|
int staged = _buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry) ? entry.Quantity : 0;
|
|
return item.StackSize - staged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2 (Slice 6b/6c review): repaints the Items tab's row visibility
|
|
/// whenever <see cref="_buyStaging"/> changes, WITHOUT the
|
|
/// reselect-to-first behavior a full category rebuild performs — see
|
|
/// <see cref="RebuildItemList(bool)"/>'s <c>reselectFirst</c> doc.
|
|
/// </summary>
|
|
private void RefreshItemsTabAvailability() => RebuildItemList(reselectFirst: false);
|
|
|
|
/// <summary>
|
|
/// Port of retail row selection display —
|
|
/// <c>VendorItemsUI::UpdateItemsUI</c> (<c>0x004C38E0</c>,
|
|
/// <c>pc:202539-202820</c>). Slice 6.2: called ONLY from
|
|
/// <see cref="OnSelectionTransition"/>, once <paramref name="item"/> has
|
|
/// already been confirmed to be the globally-selected guid — this method
|
|
/// no longer writes the selection itself (<see cref="SelectionState"/>
|
|
/// is the authority; row clicks, the F4 auto-select fallback, and
|
|
/// right-click examine all call <see cref="SelectionState.Select"/>
|
|
/// directly and let this method react).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// F2/F3 (Slice 5.4 review), rewired for F2 (Slice 6 review): the
|
|
/// priced/named QUANTITY is retail's <c>ItemHolder::GetObjectSplitSize</c>
|
|
/// (<c>0x00586F00</c>, <c>pc:401465-401477</c>) read LIVE — retail
|
|
/// re-runs this SAME display update on every slider change
|
|
/// (<c>gmVendorUI::RecvNotice_StackSliderChanged</c>,
|
|
/// <c>pc:203262-203278</c>, <c>0x004C4500</c>, wired via
|
|
/// <see cref="OnSplitQuantityChanged"/> below), reading the singular/
|
|
/// plural gate, the count, and the price off the CURRENT split value
|
|
/// (<c>pc:202602</c>/<c>202621</c>/<c>202644</c>), not a value frozen at
|
|
/// selection time. <see cref="ResolveBuyQuantity"/> is the SAME
|
|
/// computation <see cref="BuySelectedItem"/> uses to decide what it
|
|
/// actually sends, so the displayed price always equals what a Buy press
|
|
/// would charge. <c>VendorSplitPolicy.SeedQuantity</c> stays only in
|
|
/// <see cref="SelectedObjectController"/>'s real seeding path (the
|
|
/// toolbar slider's INITIAL value); this display no longer reads it.
|
|
/// Name: <c>ACCWeenieObject::GetObjectName</c> (<c>0x0058E6E0</c>,
|
|
/// <c>pc:409056-409132</c>) — NAME_SINGULAR for quantity <= 1,
|
|
/// NAME_PLURAL for quantity > 1; when no plural is authored
|
|
/// (<c>m_len==1</c>, an empty <c>PString</c>) retail falls back to the
|
|
/// SINGULAR name UNCHANGED — read directly off <c>GetObjectName</c>'s
|
|
/// body (label <c>label_58e76b</c>'s <c>m_buffer->m_len != 1</c>
|
|
/// branch falls through to the plain <c>_name</c> read when false), NOT
|
|
/// the "likely name+s" guess. Cost text: <see cref="BuildCostText"/>.
|
|
/// Button state: no selection disables Buy/Add
|
|
/// (<c>SetState(0xd)</c>, <c>pc:202572-202577</c>); a selection enables
|
|
/// them (<c>SetState(1)</c>, <c>pc:202784-202789</c>) — modeled here as
|
|
/// <see cref="UiButton.Enabled"/>, the project-wide idiom for this
|
|
/// retail state pair (see <c>ToolbarController</c>'s
|
|
/// <c>_useButton.Enabled</c>).
|
|
/// </remarks>
|
|
private void ApplyItemDisplay(VendorShopItem item)
|
|
{
|
|
for (int i = 0; i < _itemList.GetNumUIItems(); i++)
|
|
{
|
|
if (_itemList.GetItem(i) is { } cell)
|
|
cell.Selected = cell.ItemId == item.ItemGuid;
|
|
}
|
|
|
|
int quantity = (int)ResolveBuyQuantity(item);
|
|
|
|
string baseName = quantity <= 1
|
|
? item.Name ?? string.Empty
|
|
: (string.IsNullOrEmpty(item.PluralName) ? item.Name : item.PluralName) ?? string.Empty;
|
|
string nameText = quantity > 1 ? $"{quantity} {baseName}" : baseName;
|
|
SetPlainText(_itemNameText, nameText);
|
|
|
|
VendorShopProfile profile = _vendor.Profile;
|
|
int rawValue = item.Value ?? 0;
|
|
int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize);
|
|
int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity);
|
|
SetPlainText(_itemCostText, BuildCostText(profile, quantity, price));
|
|
|
|
SetActionButtonsEnabled(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Right-click examine on a shop row — mirrors
|
|
/// <c>ExternalContainerController.ExamineItem</c>'s "select then
|
|
/// request appraisal" shape, matching retail's single-selection model
|
|
/// (research doc §B.2: no dedicated double-click mechanism, plain
|
|
/// select-then-act). Slice 6.2: routes through
|
|
/// <see cref="SelectionState.Select"/> — <see cref="OnSelectionTransition"/>
|
|
/// applies the display update, so this method no longer needs to search
|
|
/// <see cref="_vendor"/>'s items itself.
|
|
/// </summary>
|
|
private void ExamineItem(uint guid)
|
|
{
|
|
_selection.Select(guid, SelectionChangeSource.Vendor);
|
|
_itemInteraction.ExamineSelectedOrEnterMode(guid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6.2: reacts to ANY global selection change, not just ones this
|
|
/// panel originated — mirrors <c>ExternalContainerController.OnSelectionChanged</c>.
|
|
/// Shows this panel's own price/name text for the newly-selected guid
|
|
/// when it is one of <see cref="_vendor"/>'s current items; clears the
|
|
/// panel's display otherwise (a selection made in some OTHER panel while
|
|
/// the vendor window is open must not leave stale vendor pricing text
|
|
/// on screen).
|
|
/// </summary>
|
|
private void OnSelectionTransition(SelectionTransition transition)
|
|
{
|
|
_ = transition;
|
|
RefreshSelectionDisplay();
|
|
// F9 (Slice 6b/6c review): a click on an already-staged Buying/
|
|
// Selling row calls SelectionState.Select the same way an Items-tab
|
|
// row does (RebuildBuyingList/RebuildSellingList's own Clicked
|
|
// handlers), but those two lists are only ever REBUILT when their
|
|
// OWN staging list changes -- a pure selection change (no staging
|
|
// mutation) never repainted their cell.Selected flags, so the
|
|
// highlight silently failed to move onto a staged row. Update both
|
|
// strips' highlight in place on every selection transition, no
|
|
// matter which panel originated it -- mirrors RefreshSelectionDisplay's
|
|
// own "react to ANY global selection change" shape.
|
|
RefreshStagingSelectionHighlight();
|
|
}
|
|
|
|
/// <summary>See <see cref="OnSelectionTransition"/>'s F9 note.</summary>
|
|
private void RefreshStagingSelectionHighlight()
|
|
{
|
|
uint? selected = _selection.SelectedObjectId;
|
|
SetHighlight(_buyingList, selected);
|
|
SetHighlight(_sellingList, selected);
|
|
|
|
static void SetHighlight(UiItemList? list, uint? selectedGuid)
|
|
{
|
|
if (list is null) return;
|
|
for (int i = 0; i < list.GetNumUIItems(); i++)
|
|
{
|
|
if (list.GetItem(i) is { } cell)
|
|
cell.Selected = cell.ItemId == selectedGuid;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2 (Slice 6 review): retail's <c>gmVendorUI::RecvNotice_StackSliderChanged</c>
|
|
/// (<c>pc:203262-203278</c>, <c>0x004C4500</c>) — a registered listener
|
|
/// on the SAME global slider-changed notice
|
|
/// <see cref="SelectedObjectController"/> broadcasts, gated (in retail)
|
|
/// on the panel being visible and the globally-selected item being one
|
|
/// of the vendor's own. <see cref="RefreshSelectionDisplay"/> already
|
|
/// applies that same gate (it no-ops to <see cref="ClearSelectionDisplay"/>
|
|
/// when the current selection isn't a vendor item), so no separate
|
|
/// visibility check is needed here.
|
|
/// </summary>
|
|
private void OnSplitQuantityChanged() => RefreshSelectionDisplay();
|
|
|
|
private void RefreshSelectionDisplay()
|
|
{
|
|
uint? selected = _selection.SelectedObjectId;
|
|
if (selected is { } guid)
|
|
{
|
|
foreach (VendorShopItem item in _vendor.Items)
|
|
{
|
|
if (item.ItemGuid == guid)
|
|
{
|
|
ApplyItemDisplay(item);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
ClearSelectionDisplay();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6.2: retail's <c>VendorItemsUI::RemoveFromShop</c>
|
|
/// (<c>pc:202848-202850</c>, <c>0x004c3d4a</c>) clears the global
|
|
/// selection when a shop item leaves the vendor's list — this mirrors
|
|
/// that (and, transitively, every other panel's own
|
|
/// <c>OnObjectRemoved</c>) rather than a vendor-specific "on close, set
|
|
/// selected to 0" special case. <c>VendorShopItemMaterializer</c>
|
|
/// removing every materialized item on session close/reset/replace
|
|
/// (Slice 6.1) is therefore what actually drives "vendor session close
|
|
/// clears a vendor-owned selection."
|
|
/// </summary>
|
|
/// <summary>
|
|
/// F10 (Slice 6b/6c review): retail's two unstage-on-dispossession
|
|
/// sites, both reachable through this SAME <see cref="ClientObjectTable.ObjectRemoved"/>
|
|
/// notification in this architecture.
|
|
/// <para>
|
|
/// <b>Sell side</b> — <c>gmVendorUI::RecvNotice_ServerSaysMoveItem</c>
|
|
/// (<c>0x004c44a0</c>): a staged SELL item silently unstages once
|
|
/// <c>ACCWeenieObject::IsOwnedByPlayer</c> goes false for it (no notice
|
|
/// shown). The closest reachable proxy here is "the item left
|
|
/// <see cref="ClientObjectTable"/> entirely" — a strictly narrower
|
|
/// trigger than retail's "moved to ANY non-player container," but the
|
|
/// only one an item leaving the table for real (destroyed, traded away
|
|
/// and never re-registered, sold through a different path) reaches.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Buy side</b> — <c>VendorItemsUI</c>'s shop-list-removal notice
|
|
/// (<c>0x004c4246</c>, inside <c>gmVendorUI::HandleMousePresses</c>,
|
|
/// <c>pc:203165</c>: <c>"Removing %s from shopping list"</c>). A staged
|
|
/// BUY guid that drops out of the vendor's CURRENT stock (sold out by
|
|
/// someone else, or a different vendor superseded this session) is
|
|
/// retired from <see cref="ClientObjectTable"/> by
|
|
/// <c>VendorShopItemMaterializer</c>'s own retire pass on every
|
|
/// Opened/Refreshed/Closed/Reset transition — which fires this SAME
|
|
/// event, giving one unified site for both retail sources instead of a
|
|
/// separate "diff the vendor's item list" listener.
|
|
/// </para>
|
|
/// </summary>
|
|
private void OnObjectRemoved(ClientObject item)
|
|
{
|
|
if (_selection.SelectedObjectId == item.ObjectId)
|
|
{
|
|
_selection.Clear(
|
|
SelectionChangeSource.Vendor,
|
|
SelectionChangeReason.SelectedObjectRemoved);
|
|
}
|
|
|
|
// Sell side: silent, matching retail's own RecvNotice_ServerSaysMoveItem.
|
|
_sellStaging.Remove(item.ObjectId, -1);
|
|
|
|
// Buy side: retail's exact "Removing %s from shopping list" notice.
|
|
if (_buyStaging.Remove(item.ObjectId, -1))
|
|
{
|
|
string name = string.IsNullOrWhiteSpace(item.Name) ? "that item" : item.Name;
|
|
_systemMessage?.Invoke($"Removing {name} from shopping list");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// F2/F7 (Slice 6 review): the SINGLE quantity computation both the
|
|
/// display (<see cref="ApplyItemDisplay"/>) and the actual purchase
|
|
/// (<see cref="BuySelectedItem"/>) use — retail's
|
|
/// <c>gmVendorUI::BuySingleItem</c> (<c>pc:201661</c>,
|
|
/// <c>0x004C2820</c>): quantity 1 when the item's own authored stack
|
|
/// size is <c><= 1</c> (<c>pc:201674-201681</c>) — there is no split
|
|
/// UI for a non-stack item, so a leftover slider value from a
|
|
/// PREVIOUSLY-selected DIFFERENT stackable item must never leak into
|
|
/// this one — otherwise the CURRENT slider value via
|
|
/// <c>ItemHolder::GetObjectSplitSize</c> (<c>0x00586F00</c>).
|
|
/// <para>
|
|
/// R1 gate-finding fix, CORRECTED at the 2026-08-08 live-evidence
|
|
/// re-fix (register AP-169, second correction): the ceiling this gates
|
|
/// on is <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/> —
|
|
/// <see cref="VendorShopItem.MaxStackSize"/> FIRST (retail's own
|
|
/// vendor-owned quantity operand: the Buy cases at
|
|
/// <c>pc:203996</c>/<c>204086</c> gate on <c>pwd._maxStackSize > 1</c>
|
|
/// literally), falling back to <see cref="VendorShopItem.DescStackSize"/>
|
|
/// — the SAME resolution <see cref="VendorShopItemMaterializer"/> uses
|
|
/// to seed <see cref="ClientObject.StackSize"/> (and therefore the
|
|
/// toolbar slider's own ceiling, <c>SelectedObjectController</c>).
|
|
/// Using a narrower source here than the visible slider would let the
|
|
/// slider show a ceiling of 1000 while every Buy still sent quantity 1.
|
|
/// </para>
|
|
/// </summary>
|
|
private uint ResolveBuyQuantity(VendorShopItem item)
|
|
{
|
|
uint stackSize = (uint)VendorSplitPolicy.ResolveAuthoredStackSize(item.DescStackSize, item.MaxStackSize);
|
|
if (stackSize <= 1u)
|
|
return 1u;
|
|
|
|
uint selected = _selection.SelectedObjectId ?? item.ItemGuid;
|
|
return _splitQuantity.GetObjectSplitSize(item.ItemGuid, selected, stackSize);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cost sentence — <c>VendorItemsUI::UpdateItemsUI</c>'s tail
|
|
/// (<c>pc:202641-202790</c>). No-alt-currency: verb agreement ("costs"
|
|
/// for quantity <= 1, "cost" for > 1 — <c>pc:202759-202762</c>),
|
|
/// comma-grouped price/holdings (retail's <c>InsertCommas</c>, ported as
|
|
/// <c>ToString("N0")</c> with <see cref="CultureInfo.InvariantCulture"/>)
|
|
/// via the literal format <c>"%s %hsp (you have %hsp)"</c>
|
|
/// (<c>pc:202769</c>). Alt-currency: the FULL retail format string, read
|
|
/// directly off the data segment rather than the truncated call-site
|
|
/// view — <c>"This item costs %d %s. You have %d %s."</c>
|
|
/// (<c>acclient_2013_pseudo_c.txt:991314</c>) — this branch uses RAW
|
|
/// (not comma-grouped) integers, an asymmetry retail's own literal
|
|
/// format establishes, not an oversight here.
|
|
/// <para>
|
|
/// The player's primary-currency holding is retail's
|
|
/// <c>gmVendorUI::m_totalValue</c>, read from <c>PropertyInt.CoinValue</c>
|
|
/// (<c>pc:203494-203497</c>) via the SAME <see cref="ClientObjectTable"/>
|
|
/// generic int-property bundle every other PropertyInt-driven display
|
|
/// reads. The alt-currency holding is retail's
|
|
/// <c>shopVendorProfile->trade_num - m_last_sale</c>;
|
|
/// <c>m_last_sale</c> only changes on a completed Slice-6 purchase, so
|
|
/// with no purchase mechanism yet this port uses
|
|
/// <see cref="VendorShopProfile.AlternateCurrencyAmount"/> directly
|
|
/// (retail's <c>m_last_sale == 0</c> case — see the register, AP-161).
|
|
/// </para>
|
|
/// </summary>
|
|
private string BuildCostText(VendorShopProfile profile, int quantity, int price)
|
|
{
|
|
if (profile.AlternateCurrencyWcid != 0u)
|
|
{
|
|
return string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"This item costs {0} {1}. You have {2} {1}.",
|
|
price,
|
|
profile.AlternateCurrencyPluralName,
|
|
(int)profile.AlternateCurrencyAmount);
|
|
}
|
|
|
|
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}p (you have {2}p)",
|
|
verb,
|
|
price.ToString("N0", CultureInfo.InvariantCulture),
|
|
playerTotal.ToString("N0", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
private void ClearSelectionDisplay()
|
|
{
|
|
for (int i = 0; i < _itemList.GetNumUIItems(); i++)
|
|
{
|
|
if (_itemList.GetItem(i) is { } cell)
|
|
cell.Selected = false;
|
|
}
|
|
SetPlainText(_itemNameText, string.Empty);
|
|
SetPlainText(_itemCostText, string.Empty);
|
|
SetActionButtonsEnabled(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6b (AP-161 F8 residual closes): "Add to List" now enables with
|
|
/// selection exactly like Buy — the "Buying" tab's staging list is
|
|
/// implemented, so an enabled Add is no longer a dead affordance.
|
|
/// </summary>
|
|
private void SetActionButtonsEnabled(bool enabled)
|
|
{
|
|
_buyEnabledBySelection = enabled;
|
|
RecomputeBuyButtonEnabled();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6.3/6b: the Buy AND Add buttons' enabled state is the
|
|
/// CONJUNCTION of "is something selected" (<see cref="_buyEnabledBySelection"/>,
|
|
/// set by <see cref="SetActionButtonsEnabled"/>) and "is the shared
|
|
/// inventory/use gate free right now"
|
|
/// (<see cref="ItemInteractionController.CanMakeInventoryRequest"/>).
|
|
/// Add is selection-gated only (retail's <c>AddToBuyList</c> sends
|
|
/// nothing to the server — no busy-gate reason to disable it while a
|
|
/// Buy/Sell is in flight), but sharing this recompute keeps both buttons
|
|
/// consistent with a single call site. Called on every selection change
|
|
/// AND on every <see cref="ItemInteractionController.StateChanged"/>
|
|
/// tick, so Buy disables the instant <see cref="ItemInteractionController.TryBuy"/>
|
|
/// takes its reservation and re-enables on the matching completion — no
|
|
/// per-frame polling.
|
|
/// </summary>
|
|
private void RecomputeBuyButtonEnabled()
|
|
{
|
|
if (_buyButton is not null)
|
|
_buyButton.Enabled = _buyEnabledBySelection && _itemInteraction.CanMakeInventoryRequest;
|
|
if (_addButton is not null)
|
|
_addButton.Enabled = _buyEnabledBySelection;
|
|
}
|
|
|
|
private void OnInteractionStateChanged() => RecomputeBuyButtonEnabled();
|
|
|
|
/// <summary>
|
|
/// Slice 6.3: retail <c>gmVendorUI::BuySingleItem</c> (<c>pc:201661</c>).
|
|
/// Reads the CURRENT globally-selected shop item and dispatches a
|
|
/// single-item purchase through the shared use/inventory reservation,
|
|
/// using <see cref="ResolveBuyQuantity"/> — the SAME quantity
|
|
/// computation <see cref="ApplyItemDisplay"/> prices/names the selection
|
|
/// at (F2), so the amount actually sent always matches what was shown.
|
|
/// Client-side affordability/capacity pre-checks are deliberately NOT
|
|
/// ported (research doc's open question 1: the server is authoritative
|
|
/// either way and pre-checks are latency/UX polish, not correctness —
|
|
/// deferred as a fast follow-up if the round-trip lag on a refused
|
|
/// purchase is noticeable live).
|
|
/// </summary>
|
|
private void BuySelectedItem()
|
|
{
|
|
if (_selection.SelectedObjectId is not { } guid)
|
|
return;
|
|
|
|
VendorShopItem? selected = null;
|
|
foreach (VendorShopItem item in _vendor.Items)
|
|
{
|
|
if (item.ItemGuid == guid)
|
|
{
|
|
selected = item;
|
|
break;
|
|
}
|
|
}
|
|
if (selected is not { } shopItem)
|
|
return;
|
|
|
|
uint quantity = ResolveBuyQuantity(shopItem);
|
|
_itemInteraction.TryBuy(
|
|
_vendor.VendorId,
|
|
shopItem.ItemGuid,
|
|
(int)quantity,
|
|
_vendor.Profile.AlternateCurrencyWcid);
|
|
}
|
|
|
|
private bool TryFindShopItem(uint guid, out VendorShopItem shopItem)
|
|
{
|
|
foreach (VendorShopItem item in _vendor.Items)
|
|
{
|
|
if (item.ItemGuid == guid)
|
|
{
|
|
shopItem = item;
|
|
return true;
|
|
}
|
|
}
|
|
shopItem = default;
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6b: Items-tab "Add to List" — <c>gmVendorUI::HandleButtonClicks</c>
|
|
/// case <c>0x100000c3</c> (<c>pc:203970-203988</c>). Stages the
|
|
/// globally-selected shop item at <see cref="ResolveBuyQuantity"/> — the
|
|
/// SAME quantity computation the display and Buy button already share
|
|
/// (F2) — into the "Buying" tab's list. Sends NOTHING to the server
|
|
/// (<c>VendorItemsUI::AddToBuyList</c> is purely client-local).
|
|
/// F2 (Slice 6b/6c review): re-adding an already-staged item ACCUMULATES
|
|
/// rather than overwrites, and retail's 5000-unit cap on that
|
|
/// accumulate shows <see cref="VendorStagingList.TooMuchMessage"/> and
|
|
/// leaves the entry unchanged — see <see cref="VendorStagingList.Add"/>.
|
|
/// </summary>
|
|
private void AddSelectedToBuyList()
|
|
{
|
|
if (_selection.SelectedObjectId is not { } guid || !TryFindShopItem(guid, out VendorShopItem shopItem))
|
|
return;
|
|
|
|
uint quantity = ResolveBuyQuantity(shopItem);
|
|
if (_buyStaging.Add(shopItem.ItemGuid, (int)quantity) == VendorStagingAddOutcome.Capped)
|
|
_systemMessage?.Invoke(VendorStagingList.TooMuchMessage);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's <c>Buy Item</c>/<c>Clear Item</c> (Buying tab) shared
|
|
/// removal-amount rule (<c>pc:203996</c>/<c>204086</c>): stackable -> -1
|
|
/// (full removal), else 1. Retail tests the item's own
|
|
/// <c>pwd._maxStackSize</c> (the item TYPE's stack ceiling) — now a
|
|
/// byte-exact port (register AP-165 RETIRED 2026-08-08, R1 gate
|
|
/// finding): <see cref="VendorShopItem.MaxStackSize"/> threads the wire
|
|
/// field through directly, so this no longer needs the
|
|
/// <see cref="VendorShopItem.DescStackSize"/> substitute the row
|
|
/// originally filed.
|
|
/// </summary>
|
|
private static int BuyStagingRemovalAmount(VendorShopItem item) =>
|
|
(item.MaxStackSize ?? 1) > 1 ? -1 : 1;
|
|
|
|
/// <summary>
|
|
/// Slice 6b: "Buying" tab's "Buy Item" — retail case <c>0x100000c9</c>
|
|
/// (<c>pc:203989-204010</c>). Reuses the SAME immediate single-item
|
|
/// <c>BuySingleItem</c> path (<see cref="ItemInteractionController.TryBuy"/>)
|
|
/// the Items tab's own Buy button uses — reads the GLOBAL slider
|
|
/// quantity, not the staged entry's own quantity — then on a successful
|
|
/// dispatch removes the staged entry (<see cref="BuyStagingRemovalAmount"/>).
|
|
/// </summary>
|
|
private void BuyItemButtonPressed()
|
|
{
|
|
if (_selection.SelectedObjectId is not { } guid || !TryFindShopItem(guid, out VendorShopItem shopItem))
|
|
return;
|
|
|
|
uint quantity = ResolveBuyQuantity(shopItem);
|
|
if (_itemInteraction.TryBuy(
|
|
_vendor.VendorId,
|
|
shopItem.ItemGuid,
|
|
(int)quantity,
|
|
_vendor.Profile.AlternateCurrencyWcid))
|
|
{
|
|
_buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's exact affordability-failure notice — read from the
|
|
/// decompiled binary's data segment at <c>0x007b57b4</c> (BuyAllButtonPressed's
|
|
/// two guards below share this SAME string; see the class doc).
|
|
/// </summary>
|
|
private const string NotEnoughMoneyMessage = "You don't have enough money";
|
|
|
|
/// <summary>
|
|
/// Retail's exact capacity-failure notice — read from the decompiled
|
|
/// binary's data segment at <c>0x007b5750</c> (BuyAllButtonPressed's
|
|
/// two capacity guards below share this SAME string; see the class doc).
|
|
/// </summary>
|
|
private const string NotEnoughRoomMessage = "You must empty some slots in your backpack first";
|
|
|
|
/// <summary>
|
|
/// Slice 6b: "Buying" tab's "Buy All" — retail case <c>0x100000ca</c>
|
|
/// (<c>pc:204011-204079</c>, <c>0x004c5388</c>). Sends every staged
|
|
/// entry as ONE batched Buy (<see cref="ItemInteractionController.TryBuyAll"/>).
|
|
/// <para>
|
|
/// F1 (Slice 6b/6c review): retail's FOUR client-side pre-send guards
|
|
/// are ported here, in order, each returning with staging fully
|
|
/// intact on failure (amends AP-162's "no client-side pre-check"
|
|
/// claim — see the register):
|
|
/// </para>
|
|
/// <list type="number">
|
|
/// <item>pyreal affordability — transaction total vs. purse
|
|
/// (<c>pc:204017</c>: <c>m_transactionValue <= m_totalValue</c>).</item>
|
|
/// <item>alt-currency affordability — vs. held trade currency minus
|
|
/// <c>m_last_sale</c> (<c>pc:204032</c>). This session tracks no
|
|
/// <c>m_last_sale</c> credit yet (see the register's AP-161 residual),
|
|
/// so this uses the vendor's raw held count, retail's own
|
|
/// <c>m_last_sale == 0</c> case.</item>
|
|
/// <item>container-slot capacity (<c>pc:204053</c>:
|
|
/// <c>containerSlotsNeeded > player.ContainersCapacity - containersUsed</c>).</item>
|
|
/// <item>item-slot capacity (<c>pc:204067</c>: the same shape for
|
|
/// item slots).</item>
|
|
/// </list>
|
|
/// Both affordability guards share <see cref="NotEnoughMoneyMessage"/>;
|
|
/// both capacity guards share <see cref="NotEnoughRoomMessage"/> —
|
|
/// retail's own two distinct <c>StringInfo</c> literal sites collapse
|
|
/// to exactly these two strings (<c>pc:204020</c>/<c>204034</c> both
|
|
/// reference <c>0x007b57b4</c>; <c>pc:204056</c>/<c>204068</c> both
|
|
/// reference <c>0x007b5750</c> via the shared <c>label_4c5509</c>).
|
|
/// <para>
|
|
/// <b>Container-vs-item slot classification (register AP-168, narrowed
|
|
/// G1 gate-finding fix 2026-08-08).</b> Retail's own split tests a
|
|
/// bitfield bit this codebase does not currently thread onto
|
|
/// <see cref="VendorShopItem"/> (<c>gmVendorUI::InqListSlotCount</c>,
|
|
/// <c>pc:200038-200065</c>) — this port approximates "is this shop item
|
|
/// a container" with <see cref="ItemType.Container"/> instead, correct
|
|
/// for the ordinary case (a real backpack/pouch DOES carry that type
|
|
/// bit) but not byte-identical for the theoretical case of a
|
|
/// non-<c>Container</c>-typed item that still authors nonzero pack/side
|
|
/// capacities. This residual applies ONLY to the shop-stock side
|
|
/// (<see cref="ComputeBuySlotsNeeded"/>) — <see cref="VendorShopItem"/>
|
|
/// genuinely has no wire-carried classification field to read instead.
|
|
/// The player's-OWN-pack side (<see cref="CountPlayerContents"/>) no
|
|
/// longer shares this approximation: it now reads
|
|
/// <see cref="ClientObject.ContainerTypeHint"/> (retail's actual wire
|
|
/// <c>ContainerProperties</c>, already threaded onto every owned object)
|
|
/// first, matching retail's real <c>_itemsList</c>/<c>_containersList</c>
|
|
/// bucketing exactly for anything that ever received a hint. Live
|
|
/// testing showed the OLD dual-heuristic (also checking nonzero
|
|
/// <c>ItemsCapacity</c>/<c>ContainersCapacity</c>) could over-classify a
|
|
/// non-container object as an occupied container slot and false-block a
|
|
/// purchase with real free slots — see the register.
|
|
/// </para>
|
|
/// On a successful DISPATCH the whole staged list is flushed
|
|
/// UNCONDITIONALLY and immediately, matching retail's literal order:
|
|
/// <c>SendShopEvent(...)</c> then
|
|
/// <c>PackableList::Flush(&m_buyList)</c> (<c>pc:204075-204076</c>) —
|
|
/// the clear happens right after the send, not gated on the eventual
|
|
/// server response/UseDone.
|
|
/// </summary>
|
|
private void BuyAllButtonPressed()
|
|
{
|
|
if (_buyStaging.IsEmpty)
|
|
return;
|
|
|
|
var items = new List<(int Amount, uint ItemGuid)>(_buyStaging.Entries.Count);
|
|
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
|
items.Add((entry.Quantity, entry.ItemGuid));
|
|
|
|
VendorShopProfile profile = _vendor.Profile;
|
|
int transactionValue = ComputeBuyTransactionValue();
|
|
|
|
// Guards 1/2: pyreal vs. alt-currency affordability.
|
|
if (profile.AlternateCurrencyWcid == 0u)
|
|
{
|
|
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
|
|
if (transactionValue > playerTotal)
|
|
{
|
|
_systemMessage?.Invoke(NotEnoughMoneyMessage);
|
|
return;
|
|
}
|
|
}
|
|
else if (transactionValue > (int)profile.AlternateCurrencyAmount)
|
|
{
|
|
_systemMessage?.Invoke(NotEnoughMoneyMessage);
|
|
return;
|
|
}
|
|
|
|
// Guards 3/4: container-slot then item-slot capacity.
|
|
(int itemSlotsNeeded, int containerSlotsNeeded) = ComputeBuySlotsNeeded(items);
|
|
ClientObject? player = _objects.Get(_playerGuid());
|
|
(int itemsUsed, int containersUsed) = CountPlayerContents();
|
|
|
|
int freeContainerSlots = (player?.ContainersCapacity ?? 0) - containersUsed;
|
|
if (containerSlotsNeeded > freeContainerSlots)
|
|
{
|
|
_systemMessage?.Invoke(NotEnoughRoomMessage);
|
|
return;
|
|
}
|
|
int freeItemSlots = (player?.ItemsCapacity ?? 0) - itemsUsed;
|
|
if (itemSlotsNeeded > freeItemSlots)
|
|
{
|
|
_systemMessage?.Invoke(NotEnoughRoomMessage);
|
|
return;
|
|
}
|
|
|
|
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid))
|
|
_buyStaging.Clear();
|
|
}
|
|
|
|
/// <summary>F1: the SAME per-row price formula <see cref="ApplyItemDisplay"/> shows, summed over every staged entry.</summary>
|
|
private int ComputeBuyTransactionValue()
|
|
{
|
|
VendorShopProfile profile = _vendor.Profile;
|
|
int total = 0;
|
|
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
|
{
|
|
if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem item))
|
|
continue;
|
|
int perUnit = VendorPricing.PerUnitValue(item.Value ?? 0, item.DescStackSize);
|
|
total += VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, entry.Quantity);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3: the Selling tab's total-proceeds counterpart to
|
|
/// <see cref="ComputeBuyTransactionValue"/> — retail
|
|
/// <c>VendorSellUI::UpdateTransactionValue</c> (<c>pc:202380-202468</c>),
|
|
/// which prices each staged row via <c>VendorProfile::VendorBuyPrice</c>
|
|
/// (the rate the VENDOR pays when IT buys FROM the player — see
|
|
/// <see cref="VendorPricing"/>'s naming-inversion warning). Unlike the
|
|
/// Buying side (whose priced item lives in <see cref="_vendor"/>'s shop
|
|
/// list), a staged SELL entry's item is the PLAYER's OWN pack item —
|
|
/// <see cref="_objects"/>, the same source <see cref="RebuildSellingList"/>
|
|
/// already reads for icon/type data.
|
|
/// </summary>
|
|
private int ComputeSellTransactionValue()
|
|
{
|
|
VendorShopProfile profile = _vendor.Profile;
|
|
int total = 0;
|
|
foreach (VendorStagingEntry entry in _sellStaging.Entries)
|
|
{
|
|
if (_objects.Get(entry.ItemGuid) is not { } item)
|
|
continue;
|
|
int perUnit = VendorPricing.PerUnitValue(item.Value, item.StackSize);
|
|
total += VendorPricing.BuyPrice(perUnit, (uint)item.Type, profile.BuyPrice, entry.Quantity);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3 (user-requested retail presentation, closing AP-166's text half —
|
|
/// grand-gate live evidence: a selected vendor taper shows the Buying
|
|
/// tab's right-side summary "Buying 2 items worth 422p" / "You have
|
|
/// 23p"). Retail's exact literal, recovered BYTE-VERBATIM: the Sell side
|
|
/// is directly legible in the decompiled body of
|
|
/// <c>VendorSellUI::UpdateTransactionValue</c> (<c>pc:202458</c>,
|
|
/// <c>u"Selling %d %s worth %hsp"</c>); the Buy side's IDENTICALLY-SHAPED
|
|
/// literal is mis-attributed by the decompiler to a bogus vtable-slot
|
|
/// symbol at its own call site (<c>VendorBuyUI::UpdateTransactionValue</c>,
|
|
/// <c>pc:202290</c>) — recovered instead by reading the binary's own
|
|
/// data segment directly (<c>C:\Users\erikn\Downloads\acclient.exe</c>,
|
|
/// the Sept 2013 EoR build paired with <c>refs/acclient.pdb</c>), which
|
|
/// carries the wide string <c>"Buying %d %s worth %hsp"</c> at VA
|
|
/// <c>0x007b58bc</c> (Sell's own literal likewise resolves at
|
|
/// <c>0x007b5930</c>, confirming both strings byte-for-byte). The "p"
|
|
/// after <c>%hs</c> is a LITERAL pyreal-currency suffix character —
|
|
/// same convention as <see cref="BuildCostText"/>'s own
|
|
/// <c>"{0} {1}p (you have {2}p)"</c> — not part of the specifier.
|
|
/// Singular/plural ("item"/"items") gates on the STAGED COUNT (the sum
|
|
/// of every entry's quantity), matching retail's own per-row
|
|
/// <c>_stackSize</c> accumulator feeding the same singular/plural test.
|
|
/// <para>
|
|
/// Alt-currency (a rare trade-note vendor): the Buy side's exact literal
|
|
/// IS confirmed directly legible in the decompiled body
|
|
/// (<c>VendorBuyUI::UpdateTotalValue</c>, <c>pc:202344</c>,
|
|
/// <c>"You have %d %s."</c>) for the PURSE line; this method's alt-
|
|
/// currency LIST-line construction is a faithful EXTRAPOLATION of the
|
|
/// confirmed pyreal shape (dropping the "p" suffix, substituting the
|
|
/// currency's plural name for the value) — the exact alt-currency LIST
|
|
/// format string was not independently recovered byte-verbatim. See the
|
|
/// register, AP-166, for this narrow residual.
|
|
/// </para>
|
|
/// </summary>
|
|
private static string BuildTransactionListText(
|
|
string verb, int count, int totalValue, VendorShopProfile profile)
|
|
{
|
|
string noun = count == 1 ? "item" : "items";
|
|
if (profile.AlternateCurrencyWcid != 0u)
|
|
{
|
|
return string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"{0} {1} {2} worth {3} {4}",
|
|
verb,
|
|
count,
|
|
noun,
|
|
totalValue,
|
|
profile.AlternateCurrencyPluralName);
|
|
}
|
|
return string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"{0} {1} {2} worth {3}p",
|
|
verb,
|
|
count,
|
|
noun,
|
|
totalValue.ToString("N0", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3: shared "You have %hsp" purse-text builder — retail
|
|
/// <c>VendorBuyUI::UpdateTotalValue</c> (<c>pc:202366</c>,
|
|
/// <c>u"You have %hsp"</c>) and <c>VendorSellUI::UpdateTotalValue</c>
|
|
/// (<c>pc:202495</c>, the SAME literal <c>u"You have %hsp"</c>) — both
|
|
/// directly legible in the decompiled body, byte-identical. Comma
|
|
/// grouping matches retail's own <c>InsertCommas</c> call immediately
|
|
/// before this format runs. The alt-currency branch's literal
|
|
/// <c>"You have %d %s."</c> is directly legible at
|
|
/// <c>VendorBuyUI::UpdateTotalValue</c> (<c>pc:202344</c>); the Sell
|
|
/// side's alt-currency purse text was not independently traced but is
|
|
/// presumed identical by symmetry — both read the SAME
|
|
/// <c>shopVendorProfile->trade_num - m_last_sale</c> holding
|
|
/// <see cref="VendorShopProfile.AlternateCurrencyAmount"/> already
|
|
/// substitutes for elsewhere (see the register, AP-161's <c>m_last_sale</c>
|
|
/// residual, and AP-166 for this untraced half).
|
|
/// </summary>
|
|
private string BuildPurseText(VendorShopProfile profile)
|
|
{
|
|
if (profile.AlternateCurrencyWcid != 0u)
|
|
{
|
|
return string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"You have {0} {1}.",
|
|
(int)profile.AlternateCurrencyAmount,
|
|
profile.AlternateCurrencyPluralName);
|
|
}
|
|
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
|
|
return string.Format(
|
|
CultureInfo.InvariantCulture,
|
|
"You have {0}p",
|
|
playerTotal.ToString("N0", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3: repaints the Buying tab's own summary text — retail
|
|
/// <c>VendorBuyUI::Update</c> (<c>pc:202996-203005</c>) calls
|
|
/// <c>UpdateTransactionValue</c> then <c>UpdateTotalValue</c>
|
|
/// unconditionally on every staging mutation.
|
|
/// </summary>
|
|
private void UpdateBuyTransactionText()
|
|
{
|
|
if (_buyListText is null && _buyPurseText is null)
|
|
return;
|
|
|
|
VendorShopProfile profile = _vendor.Profile;
|
|
int count = _buyStaging.Entries.Sum(e => e.Quantity);
|
|
int totalValue = ComputeBuyTransactionValue();
|
|
if (_buyListText is not null)
|
|
SetPlainText(_buyListText, BuildTransactionListText("Buying", count, totalValue, profile));
|
|
if (_buyPurseText is not null)
|
|
SetPlainText(_buyPurseText, BuildPurseText(profile));
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3: the Selling tab's counterpart to <see cref="UpdateBuyTransactionText"/> —
|
|
/// retail <c>VendorSellUI::Update</c> (<c>pc:203009-203018</c>), same shape.
|
|
/// </summary>
|
|
private void UpdateSellTransactionText()
|
|
{
|
|
if (_sellListText is null && _sellPurseText is null)
|
|
return;
|
|
|
|
VendorShopProfile profile = _vendor.Profile;
|
|
int count = _sellStaging.Entries.Sum(e => e.Quantity);
|
|
int totalValue = ComputeSellTransactionValue();
|
|
if (_sellListText is not null)
|
|
SetPlainText(_sellListText, BuildTransactionListText("Selling", count, totalValue, profile));
|
|
if (_sellPurseText is not null)
|
|
SetPlainText(_sellPurseText, BuildPurseText(profile));
|
|
}
|
|
|
|
/// <summary>
|
|
/// R3: a player money change (purchase/sale/pickup/drop elsewhere) must
|
|
/// repaint BOTH tabs' purse line even when staging itself is unchanged —
|
|
/// retail's own purse text always reads the LIVE holding fresh (same as
|
|
/// <see cref="BuildCostText"/>'s own <c>PropertyInt.CoinValue</c> read),
|
|
/// with no separate "did staging change" gate.
|
|
/// </summary>
|
|
private void OnObjectMoneyChanged(ClientObject updated)
|
|
{
|
|
if (updated.ObjectId != _playerGuid())
|
|
return;
|
|
UpdateBuyTransactionText();
|
|
UpdateSellTransactionText();
|
|
}
|
|
|
|
/// <summary>
|
|
/// F1: port of <c>gmVendorUI::InqListSlotCount</c> (<c>pc:200038-200065</c>,
|
|
/// <c>0x004c0c10</c>) — see <see cref="BuyAllButtonPressed"/>'s own doc
|
|
/// comment for the container-classification approximation.
|
|
/// <para>
|
|
/// R1 gate-finding fix (2026-08-08): retail's stackable test at this
|
|
/// exact call site is <c>eax->pwd._maxStackSize <= 1</c>
|
|
/// (<c>pc:200052</c>) — literally <c>MaxStackSize</c>, never
|
|
/// <c>_stackSize</c>. This now reads <see cref="VendorShopItem.MaxStackSize"/>
|
|
/// directly, a byte-exact port now that the wire field is threaded
|
|
/// through (previously approximated with <c>DescStackSize</c>, which
|
|
/// ACE never populates for a browse-list row, so a stackable item was
|
|
/// always misclassified as non-stackable — see AP-169's sibling
|
|
/// finding).
|
|
/// </para>
|
|
/// </summary>
|
|
private (int ItemSlots, int ContainerSlots) ComputeBuySlotsNeeded(
|
|
IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
|
{
|
|
int itemSlots = 0, containerSlots = 0;
|
|
foreach ((int amount, uint guid) in items)
|
|
{
|
|
if (!TryFindShopItem(guid, out VendorShopItem item))
|
|
continue;
|
|
bool isContainer = ((item.ItemType ?? 0u) & (uint)ItemType.Container) != 0u;
|
|
bool stackable = (item.MaxStackSize ?? 1) > 1;
|
|
if (stackable)
|
|
{
|
|
if (isContainer) containerSlots += 1; else itemSlots += 1;
|
|
}
|
|
else
|
|
{
|
|
if (isContainer) containerSlots += amount; else itemSlots += amount;
|
|
}
|
|
}
|
|
return (itemSlots, containerSlots);
|
|
}
|
|
|
|
/// <summary>
|
|
/// F1 (G1 gate-finding fix, 2026-08-08): the player's CURRENT occupied
|
|
/// item/container slot counts. Retail's own <c>GetNumContainedItems</c>/
|
|
/// <c>GetNumContainedContainers</c> (<c>0x0058beb0</c>/<c>0x0058bec0</c>)
|
|
/// don't reclassify anything at count time — they just report the length
|
|
/// of two ALREADY-BUCKETED <c>IDList</c>s (<c>_itemsList</c>/
|
|
/// <c>_containersList</c>). The bucketing happens once, at INSERT time
|
|
/// (<c>ACCWeenieObject::ServerSaysContainID @ 0x0058be40</c>), from the
|
|
/// wire's own <c>ContainerProperties</c> field (<c>Item_ServerSaysContainId</c>
|
|
/// 0x0022's <c>ContainerType</c>; also carried by <c>ContentProfile</c> /
|
|
/// <c>PlayerDescription</c>'s per-entry container-kind byte) — a
|
|
/// None/Container/Foci discriminator the SERVER computes, not something
|
|
/// the client reverse-engineers from the item's own type bits.
|
|
/// <see cref="ClientObject.ContainerTypeHint"/> is exactly that wire
|
|
/// field, already threaded through every membership path
|
|
/// (<c>InitializeInventoryManifest</c>, <c>InventoryPutObjInContainer</c>,
|
|
/// <c>ViewContents</c>) and already used for this identical
|
|
/// container-vs-item question elsewhere
|
|
/// (<c>ClientObjectTable.IsContainerListMember</c>). This port previously
|
|
/// used ONLY the local <see cref="ItemType.Container"/>/capacity-field
|
|
/// heuristic here (AP-168) and never consulted the hint — live testing
|
|
/// showed that guessing wrong in the OVER-classify direction (a
|
|
/// non-container object whose capacity fields happen to read nonzero)
|
|
/// false-blocks a purchase with real free slots (#G1). The hint is now
|
|
/// authoritative when present; the heuristic is a narrower fallback
|
|
/// (Container-typed only, matching <see cref="ComputeBuySlotsNeeded"/>'s
|
|
/// single signal) for the rare object that reached the table without
|
|
/// ever threading a hint.
|
|
/// </summary>
|
|
private (int Items, int Containers) CountPlayerContents()
|
|
{
|
|
int items = 0, containers = 0;
|
|
foreach (uint guid in _objects.GetContents(_playerGuid()))
|
|
{
|
|
ClientObject? obj = _objects.Get(guid);
|
|
bool isContainer = obj is not null
|
|
&& (obj.ContainerTypeHint != 0u || (obj.Type & ItemType.Container) != 0);
|
|
if (isContainer) containers++; else items++;
|
|
}
|
|
return (items, containers);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6b: "Buying" tab's "Clear Item" — retail case <c>0x100000cb</c>
|
|
/// (<c>pc:204080-204094</c>). The EXACT SAME removal call "Buy Item"
|
|
/// makes (<c>label_4c52ea</c>) but WITHOUT buying first — pure removal,
|
|
/// no transaction.
|
|
/// </summary>
|
|
private void BuyClearItemButtonPressed()
|
|
{
|
|
if (_selection.SelectedObjectId is not { } guid)
|
|
return;
|
|
|
|
int amount = TryFindShopItem(guid, out VendorShopItem shopItem)
|
|
? BuyStagingRemovalAmount(shopItem)
|
|
: -1;
|
|
_buyStaging.Remove(guid, amount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail's exact refusal for a partial-stack Sell Item attempt,
|
|
/// read from the decompiled binary's data segment at
|
|
/// <c>0x007b57ec</c> (<c>gmVendorUI::SellSingleItem</c>,
|
|
/// <c>pc:201860-201864</c>).
|
|
/// </summary>
|
|
private const string CannotSellPartialStackMessage = "Cannot sell part of a stack";
|
|
|
|
/// <summary>
|
|
/// Slice 6c: "Selling" tab's "Sell Item" — retail case <c>0x100000d2</c>
|
|
/// (<c>pc:204101-204112</c>), calling <c>gmVendorUI::SellSingleItem</c>
|
|
/// (<c>pc:201808-201881</c>, <c>0x004c2b40</c>).
|
|
/// <para>
|
|
/// F13 (Slice 6b/6c review): retail reads <c>ACCWeenieObject::selectedID</c>
|
|
/// UNCONDITIONALLY — the GLOBAL selection, with NO "is this guid
|
|
/// actually staged" requirement at all. A prior version of this port
|
|
/// required a matching <see cref="_sellStaging"/> entry first; that
|
|
/// gate does not exist in retail (you can Sell Item something you
|
|
/// never dragged onto the Selling tab, exactly like the Items tab's
|
|
/// own single-item Buy button operates on the selection with no
|
|
/// staging requirement either).
|
|
/// </para>
|
|
/// <para>
|
|
/// F6 (Slice 6b/6c review, byte-verified): <c>SellSingleItem</c>
|
|
/// refuses a PARTIAL stack — the selected item's own split slider must
|
|
/// show the FULL stack (or the item must be non-stackable), else it
|
|
/// shows <see cref="CannotSellPartialStackMessage"/> and sends NOTHING
|
|
/// (<c>pc:201833-201864</c>: <c>_stackSize<=1 || splitSize>=maxSplitSize</c>
|
|
/// gates the send). On success it sends amount <c>1</c> LITERALLY
|
|
/// (<c>var_9c = 1</c>, <c>pc:201838</c>) — not the stack size — matching
|
|
/// retail's own send exactly rather than <see cref="VendorStagingEntry.Quantity"/>.
|
|
/// This method does NOT re-run <see cref="EvaluateSellAcceptability"/>'s
|
|
/// ownership/type/value gate — retail's own <c>SellSingleItem</c>
|
|
/// doesn't either at this call site (that gate is drag-time only,
|
|
/// <c>VendorSellUI::DragItemAcceptable</c>); the server remains
|
|
/// authoritative for a selection that was never legitimately
|
|
/// draggable.
|
|
/// </para>
|
|
/// <para>
|
|
/// Retail's own non-empty-container refusal branch inside
|
|
/// <c>SellSingleItem</c> (<c>pc:201818-201829</c>, a container-type
|
|
/// item with contents currently blocks a Sell Item attempt on the
|
|
/// CONTAINER itself with a distinct message) is NOT ported here — see
|
|
/// the register.
|
|
/// </para>
|
|
/// On a successful dispatch the item's own staged entry (if any) is
|
|
/// still removed unconditionally, mirroring retail's own
|
|
/// <c>RemoveProfileFromList(&m_sellList, selectedID, 0xffffffff)</c>
|
|
/// (<c>pc:204108</c>), which runs regardless of whether the sold item
|
|
/// was ever actually staged.
|
|
/// </summary>
|
|
private void SellItemButtonPressed()
|
|
{
|
|
if (_selection.SelectedObjectId is not { } guid || _objects.Get(guid) is not { } item)
|
|
return;
|
|
|
|
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
|
if (fullStack > 1)
|
|
{
|
|
uint live = _splitQuantity.GetObjectSplitSize(guid, guid, fullStack);
|
|
if (live < fullStack)
|
|
{
|
|
_systemMessage?.Invoke(CannotSellPartialStackMessage);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (1, guid) }))
|
|
_sellStaging.Remove(guid, -1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6c: "Selling" tab's "Sell All" — retail case <c>0x100000d3</c>
|
|
/// (<c>pc:204113-204129</c>). Sends every staged entry as ONE batched
|
|
/// Sell. On a successful dispatch the whole staged list is flushed
|
|
/// unconditionally and immediately (the shared tail <c>label_4c560a</c>
|
|
/// also reached by "Clear List" — <c>FlushSellListSellState</c> +
|
|
/// <c>PackableList::Flush(&m_sellList)</c>), the same optimistic
|
|
/// clear-right-after-send shape as <see cref="BuyAllButtonPressed"/>.
|
|
/// </summary>
|
|
private void SellAllButtonPressed()
|
|
{
|
|
if (_sellStaging.IsEmpty)
|
|
return;
|
|
|
|
var items = new List<(int Amount, uint ItemGuid)>(_sellStaging.Entries.Count);
|
|
foreach (VendorStagingEntry entry in _sellStaging.Entries)
|
|
items.Add((entry.Quantity, entry.ItemGuid));
|
|
|
|
if (_itemInteraction.TrySell(_vendor.VendorId, items))
|
|
_sellStaging.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Slice 6c: "Selling" tab's "Clear Item" — retail case <c>0x100000d4</c>
|
|
/// (<c>pc:204131-204137</c>): always a full removal (<c>0xffffffff</c>),
|
|
/// no transaction. Retail also clears the item's "pending sell"
|
|
/// highlight in the player's own inventory panel
|
|
/// (<c>VendorItemSetSellState</c>) — that cross-panel highlight is a
|
|
/// deliberately unported cosmetic (see the register).
|
|
/// </summary>
|
|
private void SellClearItemButtonPressed()
|
|
{
|
|
if (_selection.SelectedObjectId is not { } guid)
|
|
return;
|
|
_sellStaging.Remove(guid, -1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders <see cref="_buyStaging"/> into the "Buying" tab's own item
|
|
/// strip — icon cells identical in shape to the Items list's own (the
|
|
/// SAME <c>0x1000033A</c> cell-template prototype, per the G2 vendor-gate
|
|
/// finding), selectable so <see cref="BuyItemButtonPressed"/>/
|
|
/// <see cref="BuyClearItemButtonPressed"/> can read the globally-selected
|
|
/// guid. A staged guid that has left <see cref="_vendor"/>'s current shop
|
|
/// list (a rare race, e.g. mid-refresh) is skipped rather than crashing.
|
|
/// </summary>
|
|
private void RebuildBuyingList()
|
|
{
|
|
if (_buyingList is not { } list)
|
|
return;
|
|
|
|
uint? selectedGuid = _selection.SelectedObjectId;
|
|
using (list.DeferLayout())
|
|
{
|
|
list.Flush();
|
|
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
|
{
|
|
if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem shopItem))
|
|
continue;
|
|
|
|
uint icon = _resolveIcon(
|
|
(ItemType)(shopItem.ItemType ?? 0u),
|
|
shopItem.IconId,
|
|
shopItem.IconUnderlayId,
|
|
shopItem.IconOverlayId,
|
|
shopItem.Effects);
|
|
var cell = new UiItemSlot
|
|
{
|
|
SpriteResolve = list.SpriteResolve,
|
|
SlotIndex = list.GetNumUIItems(),
|
|
AllowDragSource = false,
|
|
};
|
|
cell.SetItem(shopItem.ItemGuid, icon);
|
|
cell.Selected = shopItem.ItemGuid == selectedGuid;
|
|
VendorShopItem captured = shopItem;
|
|
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
|
list.AddItem(cell);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders <see cref="_sellStaging"/> into the "Selling" tab's own item
|
|
/// strip. Unlike <see cref="RebuildBuyingList"/>, a staged sell entry's
|
|
/// icon/type data comes from <see cref="_objects"/> (the PLAYER's own
|
|
/// pack item), not <see cref="_vendor"/>'s shop list — the item was
|
|
/// dragged FROM the player's inventory, never authored as vendor stock.
|
|
/// </summary>
|
|
private void RebuildSellingList()
|
|
{
|
|
if (_sellingList is not { } list)
|
|
return;
|
|
|
|
uint? selectedGuid = _selection.SelectedObjectId;
|
|
using (list.DeferLayout())
|
|
{
|
|
list.Flush();
|
|
foreach (VendorStagingEntry entry in _sellStaging.Entries)
|
|
{
|
|
if (_objects.Get(entry.ItemGuid) is not { } item)
|
|
continue;
|
|
|
|
uint icon = _resolveIcon(
|
|
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
|
var cell = new UiItemSlot
|
|
{
|
|
SpriteResolve = list.SpriteResolve,
|
|
SlotIndex = list.GetNumUIItems(),
|
|
AllowDragSource = false,
|
|
};
|
|
cell.SetItem(item.ObjectId, icon);
|
|
cell.Selected = item.ObjectId == selectedGuid;
|
|
uint captured = item.ObjectId;
|
|
cell.Clicked = () => _selection.Select(captured, SelectionChangeSource.Vendor);
|
|
list.AddItem(cell);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── IItemListDragHandler (Slice 6c: the Selling tab's list is the SOLE
|
|
// drop target — gmVendorUI::HandleDropRelease routes every drag release
|
|
// anywhere in the panel through one IsAncestorOfMe(target, m_sellShopList)
|
|
// gate, pc:204229-204246) ──────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// The Selling list never sources a drag of its own — every staged cell
|
|
/// sets <c>AllowDragSource = false</c> (F3, Slice 6 review), the same
|
|
/// non-drag-source convention every vendor row uses — so
|
|
/// <see cref="UiItemSlot"/>'s drag-lift dispatch (which routes to the
|
|
/// SOURCE list's own registered handler) can never actually reach this
|
|
/// method in practice. Implemented as a no-op for interface completeness.
|
|
/// </summary>
|
|
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
|
{
|
|
}
|
|
|
|
public ItemDragAcceptance OnDragOver(
|
|
UiItemList targetList,
|
|
UiItemSlot targetCell,
|
|
ItemDragPayload payload)
|
|
{
|
|
if (!ReferenceEquals(targetList, _sellingList) || payload.ObjId == 0u)
|
|
return ItemDragAcceptance.Reject;
|
|
|
|
return EvaluateSellAcceptability(payload.ObjId, out _) == VendorSellRejection.None
|
|
? ItemDragAcceptance.Accept
|
|
: ItemDragAcceptance.Reject;
|
|
}
|
|
|
|
/// <summary>
|
|
/// F5 (Slice 6b/6c review): port of <c>gmVendorUI::UpdateDragOver</c>
|
|
/// (<c>0x004c03b0</c>, <c>pc:199542-199553</c>), driven by
|
|
/// <see cref="DragOverGlobalTimeSink"/>'s periodic pulse. While the
|
|
/// window is visible, the Selling page is NOT already the open one, and
|
|
/// a drag is currently live anywhere in the whole UI (retail's
|
|
/// <c>UIElementManager::s_pInstance->m_dragElement != 0</c>, here
|
|
/// <see cref="UiRoot.DragSource"/>), retail opens the Selling tab the
|
|
/// instant the pointer enters the WINDOW'S bounds — not any specific
|
|
/// list — making <see cref="_sellingList"/> a reachable drop target
|
|
/// without the player manually clicking the tab first.
|
|
/// <para>
|
|
/// <b>Citation correction.</b> Retail's own guard token is
|
|
/// <c>m_OpenPageToken != 0x100000cd</c> — the review that flagged this
|
|
/// finding described <c>0x100000cd</c> as "the Buying page," but this
|
|
/// controller's own <see cref="SellingPageId"/> constant is
|
|
/// <c>0x100000CD</c>, not <see cref="BuyingPageId"/>
|
|
/// (<c>0x100000C4</c>). The guard is "don't reopen the tab you're
|
|
/// already on," checked against the SELLING page specifically —
|
|
/// <see cref="_sellingPage"/>'s own <c>Visible</c> flag is the exact
|
|
/// analogue.
|
|
/// </para>
|
|
/// </summary>
|
|
private void PollDragOver()
|
|
{
|
|
if (_sellingPage.Visible) return;
|
|
if (!_window.IsVisible) return;
|
|
|
|
UiRoot? root = _itemsPage.FindRoot();
|
|
if (root?.DragSource is null) return;
|
|
|
|
System.Numerics.Vector2 pos = _window.OuterFrame.ScreenPosition;
|
|
float x0 = pos.X, y0 = pos.Y;
|
|
float x1 = x0 + _window.OuterFrame.Width, y1 = y0 + _window.OuterFrame.Height;
|
|
if (root.MouseX > x0 && root.MouseX < x1 && root.MouseY > y0 && root.MouseY < y1)
|
|
ShowTab(VendorPanelTab.Selling);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of <c>VendorSellUI::AcceptDragObject</c> (<c>pc:203866-203905</c>,
|
|
/// the non-silent release path that calls <c>DragItemAcceptable</c> with
|
|
/// <c>silent=0</c>, showing a rejection string) chained into
|
|
/// <c>VendorSellUI::AddItemToSell</c> (<c>pc:203546-203567</c>) on
|
|
/// success: auto-switch to the "Selling" tab, globally select the
|
|
/// dropped item, stage it. Purely client-local — sends nothing to the
|
|
/// server, matching the Buying tab's "Add to List".
|
|
/// </summary>
|
|
public void HandleDropRelease(
|
|
UiItemList targetList,
|
|
UiItemSlot targetCell,
|
|
ItemDragPayload payload)
|
|
{
|
|
if (!ReferenceEquals(targetList, _sellingList) || payload.ObjId == 0u)
|
|
return;
|
|
|
|
VendorSellRejection rejection = EvaluateSellAcceptability(payload.ObjId, out int quantity);
|
|
if (rejection != VendorSellRejection.None)
|
|
{
|
|
if (VendorSellAcceptability.MessageFor(rejection) is { } message)
|
|
_systemMessage?.Invoke(message);
|
|
return;
|
|
}
|
|
|
|
ShowTab(VendorPanelTab.Selling);
|
|
_selection.Select(payload.ObjId, SelectionChangeSource.Vendor);
|
|
_sellStaging.Add(payload.ObjId, quantity);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared drag-over/drop gate — <see cref="VendorSellAcceptability.Evaluate"/>
|
|
/// fed from the dragged item's own <see cref="ClientObject"/> state and
|
|
/// the open vendor's <see cref="VendorShopProfile"/> merchandise terms.
|
|
/// <paramref name="quantity"/> is the staged quantity a successful drop
|
|
/// would use.
|
|
/// <para>
|
|
/// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's
|
|
/// FULL current stack — retail's <c>VendorSellUI::AddItemToSell</c>
|
|
/// (<c>pc:203546-203567</c>) stages via <c>gmVendorUI::AddItem(...,
|
|
/// itemGuid, -1, ...)</c>, a LITERAL <c>-1</c> "full stack" sentinel
|
|
/// argument, never a slider read. A prior version of this port read the
|
|
/// LIVE split-quantity slider here instead (the Slice 6b/6c research
|
|
/// doc's Q4 section had flagged this exact source as an unverified
|
|
/// inferred analogy to the Buying tab's <c>AddToBuyList</c>) — that
|
|
/// inference is now known WRONG: Sell staging has no partial-quantity
|
|
/// feature in retail at all, unlike Buy. See
|
|
/// <c>VendorStagingList.Add</c>'s own doc comment for the Buy side's
|
|
/// (genuinely slider-driven) contrast.
|
|
/// </para>
|
|
/// </summary>
|
|
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
|
|
{
|
|
quantity = 1;
|
|
if (_objects.Get(itemGuid) is not { } item)
|
|
return VendorSellRejection.WrongType;
|
|
|
|
bool ownedByPlayer = _itemInteraction.IsOwnedByPlayer(itemGuid);
|
|
int containedItemCount = _objects.GetContents(itemGuid).Count;
|
|
int perUnitValue = VendorPricing.PerUnitValue(item.Value, item.StackSize);
|
|
|
|
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
|
|
ownedByPlayer,
|
|
containedItemCount,
|
|
(uint)item.Type,
|
|
perUnitValue,
|
|
_vendor.Profile.MerchandiseItemTypes,
|
|
_vendor.Profile.MerchandiseMinValue,
|
|
_vendor.Profile.MerchandiseMaxValue,
|
|
item.PublicWeenieBitfield ?? 0u);
|
|
|
|
if (rejection == VendorSellRejection.None)
|
|
quantity = (int)Math.Max(1, item.StackSize);
|
|
return rejection;
|
|
}
|
|
|
|
/// <summary>
|
|
/// G4/Slice 6b: 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, and only when no confirmation
|
|
/// is already up (<c>gmVendorUI::m_curDialogContext == 0</c>,
|
|
/// <c>pc:204155</c>), does retail show the confirmation dialog ("You have
|
|
/// not completed all transactions. Are you sure you want to leave this
|
|
/// vendor" — the exact string, read from the decompiled binary's data
|
|
/// segment at <c>0x007b5bd8</c>, resolving the Slice 6b/6c research doc's
|
|
/// truncated "…" citation). Its Yes callback
|
|
/// (<c>gmVendorUI::CloseVendorDialogCallback</c>, <c>pc:202104-202166</c>)
|
|
/// reaches <c>CM_Vendor::SendNotice_CloseVendor</c> — an internal
|
|
/// notice-bus fanout, not a network send (class doc's A.4 citation) —
|
|
/// and nothing in that call chain touches <c>m_buyList</c>/<c>m_sellList</c>,
|
|
/// so staging survives a "Yes, leave anyway" exactly like retail: the
|
|
/// window hides, the staged items are still there next time the vendor
|
|
/// is reopened. A "No" (or dismissing the dialog) leaves the window open
|
|
/// with staging untouched.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <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.
|
|
/// </remarks>
|
|
/// <summary>
|
|
/// F7 (Slice 6b/6c review, byte-verified): the decompiled pseudo-C's
|
|
/// declared string length (<c>0x53</c> wchar16) truncates the literal
|
|
/// mid-sentence, but the raw bytes immediately following it
|
|
/// (<c>0x007b5c7e</c>) are <c>3f 00</c> — UTF-16LE for <c>'?'</c> —
|
|
/// before the null terminator. Retail's data segment carries a trailing
|
|
/// question mark this port previously dropped.
|
|
/// </summary>
|
|
private const string CloseConfirmationMessage =
|
|
"You have not completed all transactions. Are you sure you want to leave this vendor?";
|
|
|
|
private void CloseButtonPressed()
|
|
{
|
|
if (_buyStaging.IsEmpty && _sellStaging.IsEmpty)
|
|
{
|
|
_window.Hide();
|
|
return;
|
|
}
|
|
|
|
// No dialog infra wired (a minimal test harness), or a confirmation
|
|
// is already up — retail's m_curDialogContext==0 gate (pc:204155).
|
|
if (_dialogs is null || _closeConfirmContext != 0u)
|
|
return;
|
|
|
|
_closeConfirmContext = _dialogs.MakeDialog(
|
|
RetailDialogData.Confirmation(CloseConfirmationMessage),
|
|
result =>
|
|
{
|
|
_closeConfirmContext = 0u;
|
|
if (result.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
|
_window.Hide();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// F8 (Slice 6b/6c review): shared by <see cref="Dispose"/> and the
|
|
/// session Closed/Reset arm of <see cref="OnVendorChanged"/> — a live
|
|
/// X-close confirmation dialog must not outlive the controller or the
|
|
/// vendor session it was asking about.
|
|
/// </summary>
|
|
private void DismissCloseConfirmationIfOpen()
|
|
{
|
|
if (_closeConfirmContext == 0u)
|
|
return;
|
|
_dialogs?.CloseDialog(_closeConfirmContext);
|
|
_closeConfirmContext = 0u;
|
|
}
|
|
|
|
private void ClearContent()
|
|
{
|
|
_presentCategories.Clear();
|
|
_selectedCategoryIndex = -1;
|
|
_typeMenu.Items = Array.Empty<UiMenu.MenuItem>();
|
|
_typeMenu.Selected = null;
|
|
_itemList.Flush();
|
|
// Local widget hygiene only — does NOT touch the global selection.
|
|
// This runs from the constructor (before any vendor is ever open)
|
|
// and cannot assume whatever SelectionState.SelectedObjectId
|
|
// currently holds belongs to this panel. The actual "vendor session
|
|
// close clears a vendor-owned selection" behavior is OnObjectRemoved
|
|
// reacting to VendorShopItemMaterializer's removal, not this method.
|
|
ClearSelectionDisplay();
|
|
// R3: reset the Buying/Selling summary text to its empty-staging
|
|
// shape (retail's own text is never simply blanked — UpdateTotalValue
|
|
// still renders "You have Np" etc. with a zero transaction) on both
|
|
// constructor-time setup and session close/reset.
|
|
UpdateBuyTransactionText();
|
|
UpdateSellTransactionText();
|
|
}
|
|
|
|
/// <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)
|
|
? Array.Empty<UiText.Line>()
|
|
: new[] { new UiText.Line(value, text.DefaultColor) };
|
|
text.LinesProvider = () => lines;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_vendor.Changed -= OnVendorChanged;
|
|
_selection.Changed -= OnSelectionTransition;
|
|
_objects.ObjectRemoved -= OnObjectRemoved;
|
|
_objects.ObjectUpdated -= OnObjectMoneyChanged;
|
|
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
|
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
|
_buyStaging.Changed -= RebuildBuyingList;
|
|
_buyStaging.Changed -= RefreshItemsTabAvailability;
|
|
_buyStaging.Changed -= UpdateBuyTransactionText;
|
|
_sellStaging.Changed -= RebuildSellingList;
|
|
_sellStaging.Changed -= UpdateSellTransactionText;
|
|
DismissCloseConfirmationIfOpen();
|
|
_dragOverSink.Parent?.RemoveChild(_dragOverSink);
|
|
RetailTabBinding.SetClick(_itemsTab, null);
|
|
RetailTabBinding.SetClick(_buyingTab, null);
|
|
RetailTabBinding.SetClick(_sellingTab, null);
|
|
_typeMenu.OnSelect = null;
|
|
_typeMenu.ButtonLabelProvider = null;
|
|
_itemList.ExamineItemRequested = null;
|
|
if (_close is not null)
|
|
_close.OnClick = null;
|
|
if (_buyButton is not null)
|
|
_buyButton.OnClick = null;
|
|
if (_addButton is not null)
|
|
_addButton.OnClick = null;
|
|
if (_buyItemButton is not null)
|
|
_buyItemButton.OnClick = null;
|
|
if (_buyAllButton is not null)
|
|
_buyAllButton.OnClick = null;
|
|
if (_buyClearItemButton is not null)
|
|
_buyClearItemButton.OnClick = null;
|
|
if (_buyClearListButton is not null)
|
|
_buyClearListButton.OnClick = null;
|
|
if (_sellItemButton is not null)
|
|
_sellItemButton.OnClick = null;
|
|
if (_sellAllButton is not null)
|
|
_sellAllButton.OnClick = null;
|
|
if (_sellClearItemButton is not null)
|
|
_sellClearItemButton.OnClick = null;
|
|
if (_sellClearListButton is not null)
|
|
_sellClearListButton.OnClick = null;
|
|
}
|
|
}
|