feat(ui): Slice 5.4 — the authored vendor browse panel (LayoutDesc 0x21000012)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The vendor window is retail's own: LayoutDesc 0x21000012, root 0x100000B7, found by enumerating all 101 layouts for the one containing both known tab controls and clinched by the root's Type 0x10000017 — the literal UIElement::RegisterElementClass id for gmVendorUI (pc:202075). Discovery evidence and the D0 read live in the research doc's new §B.4. D0 corrected two assumptions: retail's category "tabs" are a UiMenu DROPDOWN fed by a hardcoded 18-row ordered category table (ported bit-for-bit against our ItemType enum; list always scoped to exactly one category, first-present wins, selection preserved across refresh per retail's clamp), and the layout authors THREE tabs — Items (browse, this slice), Buying and Selling (staged-transaction review, Slice 6) — decision 4's "browse/Buy tab" names the Items tab retail's mode-2 OpenTab opens. The non-default tabs render and switch pages but stay inert, fenced in comments. VendorUiController mounts Items: category dropdown, icon-cell item row with the retained scrollbar, per-unit retail pricing via VendorPricing.SellPrice (the vendor-stock path VendorProfile:: VendorSellPrice feeds), name/cost on selection. The panel is a pure projection of VendorState — opens on populate, closes on clear; the close button's VendorState.Close() is its only permitted mutation. Nothing on the wire. AP-110 narrowed (vendor leaves the absent-panels list); AP-161 files the precise Slice-6 remainder (Buying/Selling unwired, Buy/Add buttons, InqAcceptability). Twelve controller tests on a real-dat fixture. Clean-room complete solution: 11,323 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
609a2dfda0
commit
c721830e71
10 changed files with 17024 additions and 2 deletions
File diff suppressed because one or more lines are too long
|
|
@ -701,3 +701,220 @@ Concretely, Slice 6 (not Slice 5) owns:
|
|||
(or add a precise successor row describing what remains absent — e.g.
|
||||
category filter tabs if #6 above is deferred) in the **same commit**
|
||||
that lands vendor browsing.
|
||||
|
||||
---
|
||||
|
||||
## §B.4 — D0 tab-filter read, 5.4 (2026-08-09)
|
||||
|
||||
Mandatory pre-UI read for Slice 5.4 (contract decision 6), grepping
|
||||
`VendorItemsUI::AddTypeFilter` (`0x004C05C0`) / `::ListContainsType`
|
||||
(`0x004C0D90`) plus their caller `VendorItemsUI::OpenVendor` (`0x004C16D0`)
|
||||
and the whole-panel `gmVendorUI::OpenVendor`/`PostInit`/constructor
|
||||
(`0x004C4BA0`/`0x004C09A0`/`0x004C2470`). This also resolves contract
|
||||
decision 7 (layout-id discovery) — the two findings are entangled since the
|
||||
D0 read is what proves the layout structure below.
|
||||
|
||||
### Layout identity: LayoutDesc `0x21000012`, root `0x100000B7`
|
||||
|
||||
Found via brute-force enumeration of every `LayoutDesc` in
|
||||
`client_local_English.dat` (`dats.GetAllIdsOfType<LayoutDesc>()`, 101
|
||||
entries) for the one whose descendant tree contains BOTH known tab-control
|
||||
ids `0x100000B9` and `0x100000BB` as siblings. Exactly one match. Cross-check
|
||||
per contract decision 7: `UIElement::RegisterElementClass(0x10000017,
|
||||
gmVendorUI::Create)` (`pc:202075`) — root `0x100000B7`'s own resolved `Type`
|
||||
is **`0x10000017`**, the literal retail class id for `gmVendorUI` itself.
|
||||
This is not circumstantial; it is the direct proof the root element found IS
|
||||
gmVendorUI's own instantiated root.
|
||||
|
||||
Root geometry: `800x110` at design position `(0,500)` inside an `800x600`
|
||||
canvas — a compact bottom-docked strip, the same shape family as
|
||||
`ExternalContainerController`'s `0x21000008`/`0x10000063` (not a large
|
||||
grid+icon browse window; AC's vendor UI is a horizontal single-row icon
|
||||
strip, matching general retail recollection).
|
||||
|
||||
Method: a throwaway scanner tool (`tools/VendorLayoutScan/`, not part of the
|
||||
shipped solution) using `DatCollection.GetAllIdsOfType<LayoutDesc>()` +
|
||||
recursive `ElementDesc.Children` search, then
|
||||
`LayoutImporter.ImportInfos(dats, layoutId, rootId)` (the SAME resolved
|
||||
inheritance pipeline production uses) to dump full resolved `Type` +
|
||||
`StateDesc.Properties` per element, including `DatStringResolver`-resolved
|
||||
`StringInfo` labels (property `0x17`). This is what nailed the exact
|
||||
tab→page mapping below with dat-authored-label proof, not inference.
|
||||
|
||||
### Tree shape (verified via the resolved-property dump, not just raw ElementDesc)
|
||||
|
||||
```
|
||||
0x100000B7 gmVendorUI root (Type 0x10000017), 800x110 @ design (0,500)
|
||||
├─ 0x100000D6 close/pushpin button (Type 1, icon-only, no label — plain X)
|
||||
├─ 0x100000B8 m_vendorPanel (Type 0x8 = UIElement_Panel; PostInit binds
|
||||
│ this via GetChildRecursive(this, 0x100000b8))
|
||||
│ ├─ 0x100000B9 tab, label "Items" (order 1, x=0)
|
||||
│ ├─ 0x100000BA tab, label "Buying" (order 2, x=92)
|
||||
│ ├─ 0x100000BB tab, label "Selling" (order 3, x=184)
|
||||
│ ├─ 0x100000BC page for tab B9 ("Items") == VendorItemsUI's content
|
||||
│ │ ├─ 0x100000BD m_shopList (Type 0x10000031 UiItemList, 710x32 —
|
||||
│ │ │ cell 32x32 from base 0x2100003D/0x10000339 ⇒ ~22-slot
|
||||
│ │ │ single-row horizontal strip, matches
|
||||
│ │ │ ExternalContainerController's list shape)
|
||||
│ │ ├─ 0x100000BE scrollbar (Type 0xB, horizontal, 710x16)
|
||||
│ │ ├─ 0x100000BF m_itemTypeMenu (Type 0x6 = UIElement_Menu, resolves
|
||||
│ │ │ to acdream's UiMenu via DatWidgetFactory's `6 =>
|
||||
│ │ │ new UiMenu()` — a FULLY IMPLEMENTED dropdown widget
|
||||
│ │ │ already, Items/Selected/OnSelect/ButtonLabelProvider)
|
||||
│ │ ├─ 0x100000C0 m_itemNameText
|
||||
│ │ ├─ 0x100000C1 m_itemCostText
|
||||
│ │ ├─ 0x100000C2 m_buyButton, label "Buy"
|
||||
│ │ └─ 0x100000C3 m_addButton, label "Add to List"
|
||||
│ ├─ 0x100000C4 page for tab BA ("Buying") == VendorBuyUI's content
|
||||
│ │ ├─ 0x100000C5 m_buyShopList (staged-to-buy list, NOT the shop's
|
||||
│ │ │ full stock — see below)
|
||||
│ │ ├─ 0x100000C6 scrollbar
|
||||
│ │ ├─ 0x100000C7/C8 m_buyListText / m_buyPurseText
|
||||
│ │ ├─ 0x100000C9 m_buyItemButton, label "Buy Item"
|
||||
│ │ ├─ 0x100000CA m_buyAllButton, label "Buy All"
|
||||
│ │ └─ 0x100000CB/CC m_buyClearItemButton "Clear Item" /
|
||||
│ │ m_buyClearListButton "Clear List"
|
||||
│ └─ 0x100000CD page for tab BB ("Selling") == VendorSellUI's content
|
||||
│ ├─ 0x100000CE m_sellShopList (staged-to-sell list)
|
||||
│ ├─ 0x100000CF scrollbar
|
||||
│ ├─ 0x100000D0/D1 m_sellListText / m_sellPurseText
|
||||
│ ├─ 0x100000D2 m_sellItemButton, label "Sell Item"
|
||||
│ ├─ 0x100000D3 m_sellAllButton, label "Sell All"
|
||||
│ └─ 0x100000D4/D5 m_sellClearItemButton / m_sellClearListButton,
|
||||
│ labels "Clear Item" / "Clear List"
|
||||
└─ 0x1000008D title/backdrop (Type 0x8, ZLevel 100 — drawn behind)
|
||||
```
|
||||
|
||||
Every id above is confirmed two ways: (1) `VendorItemsUI::VendorItemsUI`
|
||||
(`pc:199612`)/`VendorBuyUI::VendorBuyUI` (`pc:199717`)/
|
||||
`VendorSellUI::VendorSellUI` (`pc:199753`) bind these EXACT child ids via
|
||||
`UIElement::GetChildRecursive`; (2) the resolved-property dump shows the
|
||||
matching `0x17` StringInfo label on each button, so the id↔label pairing is
|
||||
read directly off the dat, not inferred from ctor ordering alone.
|
||||
|
||||
### Tab↔page semantics — corrects the contract's 2-tab assumption
|
||||
|
||||
The contract (decision 4, written before this read) assumed two tabs:
|
||||
"browse/Buy" (`0x100000B9`) and "Sell" (`0x100000BB`). The dat actually
|
||||
authors **three** tabs, and the dat-resolved labels settle their meaning
|
||||
precisely — this is NOT the Buy/Sell MODE switch the contract assumed, it is
|
||||
Items/Buying/Selling, three independent staging views:
|
||||
|
||||
- **"Items" (`0x100000B9`)** — `VendorItemsUI`: the vendor's full stock,
|
||||
filterable by category, each row clickable, with `Buy`/`Add to List`
|
||||
buttons. **This is Slice 5.4's browse view** — the only page with useful
|
||||
content before Slice 6 exists.
|
||||
- **"Buying" (`0x100000BA`)** — `VendorBuyUI`: review/confirm panel for
|
||||
`gmVendorUI.m_buyList`, a CLIENT-side staging list of items you've
|
||||
chosen to buy from the Items tab (`m_buyItemButton`/`m_buyAllButton` on
|
||||
the Items page add to it; this tab's own buttons commit/clear it). Empty
|
||||
on a fresh open (`m_buyList` starts empty — confirmed by
|
||||
`gmVendorUI::OpenVendor`'s unconditional `CloseVendor` on any previous
|
||||
session before rebuilding). Slice 6 territory.
|
||||
- **"Selling" (`0x100000BB`)** — `VendorSellUI`: the symmetric staging view
|
||||
for items dragged from your own inventory onto the vendor
|
||||
(`ItemInteractionPolicy.ItemPolicyActionKind.SellToVendor`, already
|
||||
ported, Slice 6 wires its UI consumption). Matches contract decision 4's
|
||||
"Sell tab... stays inert" almost exactly — its authored label is
|
||||
"Selling," not "Sell," but the semantic match is exact.
|
||||
|
||||
`gmVendorUI::OpenVendor` (`pc:203650`) confirms **all three sub-panels
|
||||
(`m_itemsUI`, `m_buyUI`, `m_sellUI`) refresh unconditionally on every
|
||||
`ApproachVendor`** (`this->m_itemsUI->vtable->OpenVendor(...)`;
|
||||
`m_sellUI->OpenVendor(...)`; `m_buyUI->OpenVendor(...)`, `pc:203852-203854`),
|
||||
regardless of which tab ends up visually open — only the VISIBLE tab is
|
||||
mode-dependent (`OpenTab(m_vendorPanel, 0x100000b9)` for mode 2,
|
||||
`0x100000bb` for mode 3, `pc:203791`/`203801` — **mode 2 opens tab
|
||||
`0x100000B9` ("Items"), confirming decision 4's "browse/Buy tab" language
|
||||
was describing this exact tab**, just informally — its authored name is
|
||||
"Items," not "Buy"). Slice 5.4 therefore: mounts all three tabs
|
||||
(matching the authored layout and `RetailTabBinding`'s existing tri-state
|
||||
pattern precedent, `SpellbookWindowController`'s Spell/Component tabs),
|
||||
defaults to "Items" selected/visible, and leaves "Buying"/"Selling" as
|
||||
present-but-unpopulated pages (no `VendorBuyUI`/`VendorSellUI` port this
|
||||
slice — both are squarely Slice 6, matching the contract's "no buy/sell
|
||||
actions" fence). This symmetric treatment — not just the contract's
|
||||
originally-anticipated single "Sell" tab — gets the "comment the fence"
|
||||
treatment for both non-default tabs.
|
||||
|
||||
### Category/type filter mechanism (the actual D0 answer)
|
||||
|
||||
`VendorItemsUI::AddTypeFilter(this, label, typeMask)` (`pc:199667`) does
|
||||
**not** create a visible tab button — it calls
|
||||
`UIElement_Menu::InsertTextItem(m_itemTypeMenu, label, m_numTypeFilters)`,
|
||||
i.e. it inserts a row into the dropdown menu at `0x100000BF`, tagging the
|
||||
new menu item with the type mask via a per-item property
|
||||
(`BaseProperty::SetPropertyName(&prop, 0x10000039)` then storing `typeMask`
|
||||
into it). "Category tabs" in the contract's phrasing means this dropdown,
|
||||
not additional tab buttons.
|
||||
|
||||
`VendorItemsUI::OpenVendor` (`pc:200779-201025`) rebuilds the dropdown from
|
||||
scratch on every open/refresh: flushes `m_shopList` and `m_itemTypeMenu`,
|
||||
then walks an **ordered, hardcoded 18-entry table**, calling
|
||||
`ListContainsType(shopItemProfileList, mask)` for each and only adding the
|
||||
filter (`AddTypeFilter`) if the vendor's stock contains a matching item.
|
||||
`ListContainsType` (`pc:200132-200159`) is a linear scan: `true` iff any
|
||||
shop item's `InqType() & mask != 0`. Table, in authored order, with the
|
||||
acdream `ItemType` composite that reproduces each literal retail mask bit
|
||||
for bit (verified against `src/AcDream.Core/Items/ClientObject.cs:26-75` —
|
||||
every bit accounted for, no gaps):
|
||||
|
||||
| # | Label | Retail mask | acdream `ItemType` |
|
||||
|---|---|---|---|
|
||||
| 1 | Armor | `0x2` | `Armor` |
|
||||
| 2 | Books, Paper | `0x2000` | `Writable` |
|
||||
| 3 | Clothing | `0x4` | `Clothing` |
|
||||
| 4 | Containers | `0x200` | `Container` |
|
||||
| 5 | Food | `0x20` | `Food` |
|
||||
| 6 | Gems | `0x800` | `Gem` |
|
||||
| 7 | Jewelry | `0x8` | `Jewelry` |
|
||||
| 8 | Keys, Tools | `0x20004000` | `TinkeringTool \| Key` |
|
||||
| 9 | Miscellaneous | `0x490` | `Useless \| Misc \| Creature` |
|
||||
| 10 | Services | `0x100000` | `Service` |
|
||||
| 11 | Spell Components | `0x1000` | `SpellComponents` |
|
||||
| 12 | Trade Notes | `0x40000` | `PromissoryNote` |
|
||||
| 13 | Weapons | `0x101` | `Weapon` (existing composite) |
|
||||
| 14 | Mana Stones | `0x80000` | `ManaStone` |
|
||||
| 15 | Magic Items | `0x8000` | `Caster` |
|
||||
| 16 | Alchemical Items | `0x4800000` | `CraftAlchemyIntermediate \| CraftAlchemyBase` |
|
||||
| 17 | Cooking Items | `0x400000` | `CraftCookingBase` |
|
||||
| 18 | Fletching Items | `0x9000000` | `CraftFletchingIntermediate \| CraftFletchingBase` |
|
||||
|
||||
After rebuilding, retail selects an index (`pc:201008-201022`): the
|
||||
PREVIOUS selected index if it's still `< newCount - 1` inclusive-clamp,
|
||||
otherwise clamps to `newCount - 1`, then floors at `0`. Net effect: first
|
||||
open (`GetSelectedIndex` returns `-1`, nothing selected yet) always lands on
|
||||
index `0` — the FIRST present category in table order, not "show
|
||||
everything." Pseudocode:
|
||||
|
||||
```
|
||||
selected = previousSelectedIndex // -1 on first open
|
||||
if (selected >= presentCount - 1) selected = presentCount - 1
|
||||
if (selected < 0) selected = 0
|
||||
```
|
||||
|
||||
`VendorItemsUI::UpdateItemsList(this, mask, notify)` (`pc:201029` on)
|
||||
confirms the filter is load-bearing, not cosmetic: it re-walks the FULL
|
||||
`shopItemProfileList` and inserts into `m_shopList` only items where
|
||||
`(activeMask & itemType) != 0`, where `activeMask` is either the explicit
|
||||
`mask` argument (`arg2 != 0`) or, when `arg2 == 0`, the CURRENTLY selected
|
||||
menu item's stored `0x10000039` property. **There is no "all types" state**
|
||||
— an empty/no selection filters everything out (`0 & anything == 0`), which
|
||||
is exactly why retail always force-selects index 0 after rebuilding. Slice
|
||||
5.4 ports this literally: the item list is always scoped to exactly one
|
||||
category; clicking a different dropdown entry re-filters via the stored
|
||||
mask on that entry, matching `UpdateItemsList`'s `arg2 != 0` explicit-mask
|
||||
branch.
|
||||
|
||||
### Verdict: bounded, in scope, no fallback needed
|
||||
|
||||
The full mechanism is an 18-row static table + a linear membership test
|
||||
reused per row + one dropdown-population pass + a selection-index clamp —
|
||||
small and precedented (`UiMenu` is a complete pre-existing widget;
|
||||
`SpellbookWindowController`'s `FilterButtons` array is the same "static
|
||||
mask table drives filterable UI" shape, just buttons instead of a
|
||||
dropdown). This does **not** trigger the "mechanism too large — STOP"
|
||||
clause; the flat-list fallback is not needed. The genuinely new information
|
||||
this read surfaced beyond the contract's decisions — three tabs instead of
|
||||
two, and the dropdown-vs-tab distinction for "category tabs" — is recorded
|
||||
above for the record, not treated as a scope escalation.
|
||||
|
|
|
|||
|
|
@ -704,6 +704,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
placement,
|
||||
amount),
|
||||
late.Selection.IsWithinExternalContainerUseRange),
|
||||
Vendor: new VendorRuntimeBindings(
|
||||
d.Inventory.Vendor,
|
||||
iconComposer.GetIcon),
|
||||
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager),
|
||||
Confirmations: new ConfirmationRuntimeBindings(
|
||||
(type, context, accepted) =>
|
||||
|
|
|
|||
450
src/AcDream.App/UI/Layout/VendorUiController.cs
Normal file
450
src/AcDream.App/UI/Layout/VendorUiController.cs
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
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 one mutation it may
|
||||
/// trigger is the close button calling <see cref="VendorState.Close"/> —
|
||||
/// going through the owner (client-local per research §A.4, nothing on the
|
||||
/// wire), never writing fields directly.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class VendorUiController : IRetainedPanelController
|
||||
{
|
||||
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;
|
||||
|
||||
/// <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 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;
|
||||
private readonly UiMenu _typeMenu;
|
||||
private readonly UiText _itemNameText;
|
||||
private readonly UiText _itemCostText;
|
||||
private readonly UiButton? _close;
|
||||
|
||||
private readonly List<(string Label, ItemType Mask)> _presentCategories = new();
|
||||
private int _selectedCategoryIndex = -1;
|
||||
private uint _selectedItemGuid;
|
||||
private bool _disposed;
|
||||
|
||||
private VendorUiController(
|
||||
VendorState vendor,
|
||||
RetailWindowHandle window,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
||||
UiElement itemsPage,
|
||||
UiElement buyingPage,
|
||||
UiElement sellingPage,
|
||||
UiElement itemsTab,
|
||||
UiElement buyingTab,
|
||||
UiElement sellingTab,
|
||||
UiItemList itemList,
|
||||
UiScrollbar? itemScrollbar,
|
||||
UiMenu typeMenu,
|
||||
UiText itemNameText,
|
||||
UiText itemCostText,
|
||||
UiButton? close)
|
||||
{
|
||||
_vendor = vendor;
|
||||
_window = window;
|
||||
_resolveIcon = resolveIcon;
|
||||
_itemsPage = itemsPage;
|
||||
_buyingPage = buyingPage;
|
||||
_sellingPage = sellingPage;
|
||||
_itemsTab = itemsTab;
|
||||
_buyingTab = buyingTab;
|
||||
_sellingTab = sellingTab;
|
||||
_itemList = itemList;
|
||||
_typeMenu = typeMenu;
|
||||
_itemNameText = itemNameText;
|
||||
_itemCostText = itemCostText;
|
||||
_close = close;
|
||||
|
||||
_itemList.Columns = 1;
|
||||
_itemList.SingleRow = true;
|
||||
_itemList.HorizontalScroll = true;
|
||||
_itemList.CellWidth = 32f;
|
||||
_itemList.CellHeight = 32f;
|
||||
if (itemScrollbar is not null)
|
||||
{
|
||||
itemScrollbar.Model = _itemList.Scroll;
|
||||
itemScrollbar.Horizontal = true;
|
||||
}
|
||||
|
||||
_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 = () => _vendor.Close();
|
||||
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
ClearContent();
|
||||
|
||||
_vendor.Changed += OnVendorChanged;
|
||||
}
|
||||
|
||||
public static VendorUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
VendorState vendor,
|
||||
RetailWindowHandle window,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(vendor);
|
||||
ArgumentNullException.ThrowIfNull(window);
|
||||
ArgumentNullException.ThrowIfNull(resolveIcon);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
UiButton? close = layout.FindElement(CloseId) as UiButton;
|
||||
UiScrollbar? itemScrollbar = layout.FindElement(ItemScrollbarId) as UiScrollbar;
|
||||
|
||||
return new VendorUiController(
|
||||
vendor,
|
||||
window,
|
||||
resolveIcon,
|
||||
itemsPage,
|
||||
buyingPage,
|
||||
sellingPage,
|
||||
itemsTab,
|
||||
buyingTab,
|
||||
sellingTab,
|
||||
itemList,
|
||||
itemScrollbar,
|
||||
typeMenu,
|
||||
itemNameText,
|
||||
itemCostText,
|
||||
close);
|
||||
}
|
||||
|
||||
private enum VendorPanelTab { Items, Buying, Selling }
|
||||
|
||||
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:
|
||||
case VendorStateTransitionKind.Refreshed:
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
RebuildCategories();
|
||||
_window.Show();
|
||||
break;
|
||||
case VendorStateTransitionKind.Closed:
|
||||
case VendorStateTransitionKind.Reset:
|
||||
ClearContent();
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
_window.Hide();
|
||||
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>
|
||||
private void RebuildItemList()
|
||||
{
|
||||
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
|
||||
? _presentCategories[_selectedCategoryIndex].Mask
|
||||
: default;
|
||||
uint maskValue = (uint)activeMask;
|
||||
|
||||
IReadOnlyList<VendorShopItem> items = _vendor.Items;
|
||||
bool selectionStillPresent = false;
|
||||
|
||||
using (_itemList.DeferLayout())
|
||||
{
|
||||
_itemList.Flush();
|
||||
if (maskValue != 0u)
|
||||
{
|
||||
foreach (VendorShopItem item in items)
|
||||
{
|
||||
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
|
||||
|
||||
if (item.ItemGuid == _selectedItemGuid) selectionStillPresent = true;
|
||||
|
||||
uint icon = _resolveIcon((ItemType)(item.ItemType ?? 0u), item.IconId, 0u, 0u, 0u);
|
||||
var cell = new UiItemSlot
|
||||
{
|
||||
SpriteResolve = _itemList.SpriteResolve,
|
||||
SlotIndex = _itemList.GetNumUIItems(),
|
||||
};
|
||||
cell.SetItem(item.ItemGuid, icon);
|
||||
cell.Selected = item.ItemGuid == _selectedItemGuid;
|
||||
VendorShopItem captured = item;
|
||||
cell.Clicked = () => SelectItem(captured);
|
||||
_itemList.AddItem(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectionStillPresent)
|
||||
ClearSelection();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of retail row selection — updates <c>m_itemNameText</c>/
|
||||
/// <c>m_itemCostText</c> with the retail-correct PER-UNIT price
|
||||
/// (<c>VendorProfile::VendorSellPrice</c>, quantity 1 — the vendor
|
||||
/// sells this item TO the player at the vendor's own sell rate;
|
||||
/// <see cref="VendorPricing.SellPrice"/>'s naming-inversion warning
|
||||
/// applies, see that type's doc comment).
|
||||
/// </summary>
|
||||
private void SelectItem(VendorShopItem item)
|
||||
{
|
||||
_selectedItemGuid = item.ItemGuid;
|
||||
|
||||
for (int i = 0; i < _itemList.GetNumUIItems(); i++)
|
||||
{
|
||||
if (_itemList.GetItem(i) is { } cell)
|
||||
cell.Selected = cell.ItemId == _selectedItemGuid;
|
||||
}
|
||||
|
||||
SetPlainText(_itemNameText, item.Name ?? string.Empty);
|
||||
|
||||
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: 1);
|
||||
string costText = profile.AlternateCurrencyWcid == 0u
|
||||
? price.ToString(CultureInfo.InvariantCulture)
|
||||
: $"{price.ToString(CultureInfo.InvariantCulture)} {profile.AlternateCurrencyPluralName}";
|
||||
SetPlainText(_itemCostText, costText);
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
{
|
||||
_selectedItemGuid = 0u;
|
||||
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);
|
||||
}
|
||||
|
||||
private void ClearContent()
|
||||
{
|
||||
_presentCategories.Clear();
|
||||
_selectedCategoryIndex = -1;
|
||||
_typeMenu.Items = Array.Empty<UiMenu.MenuItem>();
|
||||
_typeMenu.Selected = null;
|
||||
_itemList.Flush();
|
||||
ClearSelection();
|
||||
}
|
||||
|
||||
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;
|
||||
RetailTabBinding.SetClick(_itemsTab, null);
|
||||
RetailTabBinding.SetClick(_buyingTab, null);
|
||||
RetailTabBinding.SetClick(_sellingTab, null);
|
||||
_typeMenu.OnSelect = null;
|
||||
_typeMenu.ButtonLabelProvider = null;
|
||||
if (_close is not null)
|
||||
_close.OnClick = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +168,10 @@ public sealed record AppraisalRuntimeBindings(
|
|||
Action<uint, string> SendSetInscription,
|
||||
Action<string> DisplaySystemMessage);
|
||||
|
||||
public sealed record VendorRuntimeBindings(
|
||||
VendorState State,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon);
|
||||
|
||||
public sealed record RetailUiRuntimeBindings(
|
||||
UiHost Host,
|
||||
RetailUiAssets Assets,
|
||||
|
|
@ -184,6 +188,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
CharacterRuntimeBindings Character,
|
||||
InventoryRuntimeBindings Inventory,
|
||||
ExternalContainerRuntimeBindings ExternalContainer,
|
||||
VendorRuntimeBindings Vendor,
|
||||
RetailUiCursorBindings Cursor,
|
||||
ConfirmationRuntimeBindings Confirmations,
|
||||
AppraisalRuntimeBindings Appraisal,
|
||||
|
|
@ -255,6 +260,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountPlugins();
|
||||
MountInventory();
|
||||
MountExternalContainer();
|
||||
MountVendor();
|
||||
MountItemCooldowns();
|
||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||
BindToolbarPanelButtons();
|
||||
|
|
@ -272,6 +278,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
WindowNames.Combat,
|
||||
WindowNames.JumpPowerbar,
|
||||
WindowNames.ExternalContainer,
|
||||
WindowNames.Vendor,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -322,6 +329,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public InventoryController? InventoryPanelController { get; private set; }
|
||||
public RetailDialogFactory? DialogFactory { get; private set; }
|
||||
public ExternalContainerController? ExternalContainerController { get; private set; }
|
||||
public VendorUiController? VendorController { get; private set; }
|
||||
|
||||
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
|
|
@ -1897,6 +1905,75 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 5.4: the retail vendor "Items" browse tab (LayoutDesc
|
||||
/// 0x21000012, root 0x100000B7 — see
|
||||
/// docs/research/2026-08-08-slice5-vendor-browse-research.md §B.3/§B.4
|
||||
/// for the discovery + D0 category-filter read). Two-step mount
|
||||
/// (Mount then Bind then AttachController) matches
|
||||
/// <see cref="MountExternalContainer"/>: the controller needs the
|
||||
/// <see cref="RetailWindowHandle"/> to Show/Hide itself as
|
||||
/// <see cref="VendorState"/> opens/closes, so the handle must exist
|
||||
/// before the controller is constructed.
|
||||
/// </summary>
|
||||
private void MountVendor()
|
||||
{
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
VendorUiController.LayoutId,
|
||||
VendorUiController.RootId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[M4] vendor: LayoutDesc 0x21000012 root 0x100000B7 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Vendor,
|
||||
// Root 0x100000B7 authors only the tiled center surface
|
||||
// (backdrop element 0x1000008D provides its own fill); the
|
||||
// common floating-window bevel surrounds it — same shape as
|
||||
// ExternalContainerController's 0x21000008/0x10000063.
|
||||
Chrome = RetailWindowChrome.NineSlice,
|
||||
Left = root.Left,
|
||||
Top = root.Top,
|
||||
ContentWidth = root.Width,
|
||||
ContentHeight = root.Height,
|
||||
MinWidth = root.Width,
|
||||
MinHeight = root.Height,
|
||||
Visible = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ConstrainResizeToParent = true,
|
||||
DrawChromeCenter = false,
|
||||
});
|
||||
|
||||
VendorRuntimeBindings b = _bindings.Vendor;
|
||||
VendorController = VendorUiController.Bind(layout, b.State, handle, b.ResolveIcon);
|
||||
if (VendorController is null)
|
||||
{
|
||||
Console.WriteLine("[M4] vendor: required authored controls are missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
Host.WindowManager.AttachController(WindowNames.Vendor, VendorController);
|
||||
Console.WriteLine("[M4] retail vendor browse panel mounted from LayoutDesc 0x21000012.");
|
||||
}
|
||||
|
||||
private void MountItemCooldowns()
|
||||
{
|
||||
ItemCooldownAssets? assets;
|
||||
|
|
|
|||
|
|
@ -22,4 +22,5 @@ public static class WindowNames
|
|||
public const string MiniGame = "mini-game";
|
||||
public const string Vitae = "vitae";
|
||||
public const string Examination = "examination";
|
||||
public const string Vendor = "vendor";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,6 +183,12 @@ public static class FixtureLoader
|
|||
public static ElementInfo LoadMiniGameInfos()
|
||||
=> LoadInfos("mini_game_2100001E.json");
|
||||
|
||||
public static ImportedLayout LoadVendor()
|
||||
=> LayoutImporter.Build(LoadVendorInfos(), _ => (0u, 0, 0), null);
|
||||
|
||||
public static ElementInfo LoadVendorInfos()
|
||||
=> LoadInfos("vendor_21000012_100000B7.json");
|
||||
|
||||
// ── Shared loader ────────────────────────────────────────────────────────
|
||||
|
||||
private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ public sealed class RetailLayoutFixtureGenerator
|
|||
"examine_row_2100006B_10000166.json"),
|
||||
(AppraisalUiController.LayoutId, SpellExamineComponentTemplateFactory.TemplateId,
|
||||
"examine_component_2100006B_1000032E.json"),
|
||||
(VendorUiController.LayoutId, VendorUiController.RootId,
|
||||
"vendor_21000012_100000B7.json"),
|
||||
})
|
||||
{
|
||||
ElementInfo? panelPart = LayoutImporter.ImportInfos(dats, layoutId, rootId);
|
||||
|
|
|
|||
327
tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
Normal file
327
tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Slice 5.4 — mirrors <see cref="ExternalContainerControllerTests"/>'s
|
||||
/// harness shape (hand-built <see cref="ImportedLayout"/> over
|
||||
/// <see cref="RetailWindowFrame.Mount"/>) for the behavioral suite, plus one
|
||||
/// real-dat-fixture smoke test (mirroring
|
||||
/// <c>AppraisalUiControllerTests</c>'s use of <see cref="FixtureLoader"/>)
|
||||
/// that catches drift between the hardcoded ids in
|
||||
/// <see cref="VendorUiController"/> and the actual LayoutDesc 0x21000012.
|
||||
/// </summary>
|
||||
public sealed class VendorUiControllerTests
|
||||
{
|
||||
private const uint VendorGuid = 0x70000010u;
|
||||
private const uint ArmorItemGuid = 0x60000101u;
|
||||
private const uint FoodItemGuid = 0x60000102u;
|
||||
private const uint StackedItemGuid = 0x60000103u;
|
||||
|
||||
private sealed class TestElement : UiElement { }
|
||||
|
||||
[Fact]
|
||||
public void Bind_FromRealDatFixture_ResolvesAllRequiredControls()
|
||||
{
|
||||
ImportedLayout layout = FixtureLoader.LoadVendor();
|
||||
var screen = new UiRoot { Width = 1280f, Height = 800f };
|
||||
RetailWindowHandle window = RetailWindowFrame.Mount(
|
||||
screen,
|
||||
layout.Root,
|
||||
static _ => (0u, 0, 0),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = "vendor-fixture-smoke",
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Visible = false,
|
||||
});
|
||||
|
||||
VendorUiController? controller = VendorUiController.Bind(
|
||||
layout,
|
||||
new VendorState(),
|
||||
window,
|
||||
static (_, _, _, _, _) => 0u);
|
||||
|
||||
Assert.NotNull(controller);
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public readonly VendorState State = new();
|
||||
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
|
||||
public readonly UiItemList ItemList = new();
|
||||
public readonly UiScrollbar ItemScrollbar = new();
|
||||
public readonly UiMenu TypeMenu = new();
|
||||
public readonly UiText ItemNameText = new();
|
||||
public readonly UiText ItemCostText = new();
|
||||
public readonly UiText ItemsTab = new();
|
||||
public readonly UiText BuyingTab = new();
|
||||
public readonly UiText SellingTab = new();
|
||||
public readonly UiElement ItemsPage = new TestElement();
|
||||
public readonly UiElement BuyingPage = new TestElement();
|
||||
public readonly UiElement SellingPage = new TestElement();
|
||||
public readonly UiButton CloseButton;
|
||||
public readonly RetailWindowHandle Window;
|
||||
public readonly VendorUiController Controller;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var root = new TestElement { Width = 800f, Height = 110f };
|
||||
CloseButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.CloseId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
|
||||
root.AddChild(CloseButton);
|
||||
root.AddChild(ItemsTab);
|
||||
root.AddChild(BuyingTab);
|
||||
root.AddChild(SellingTab);
|
||||
root.AddChild(ItemsPage);
|
||||
root.AddChild(BuyingPage);
|
||||
root.AddChild(SellingPage);
|
||||
ItemsPage.AddChild(ItemList);
|
||||
ItemsPage.AddChild(ItemScrollbar);
|
||||
ItemsPage.AddChild(TypeMenu);
|
||||
ItemsPage.AddChild(ItemNameText);
|
||||
ItemsPage.AddChild(ItemCostText);
|
||||
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
[VendorUiController.CloseId] = CloseButton,
|
||||
[VendorUiController.ItemsTabId] = ItemsTab,
|
||||
[VendorUiController.BuyingTabId] = BuyingTab,
|
||||
[VendorUiController.SellingTabId] = SellingTab,
|
||||
[VendorUiController.ItemsPageId] = ItemsPage,
|
||||
[VendorUiController.BuyingPageId] = BuyingPage,
|
||||
[VendorUiController.SellingPageId] = SellingPage,
|
||||
[VendorUiController.ItemListId] = ItemList,
|
||||
[VendorUiController.ItemScrollbarId] = ItemScrollbar,
|
||||
[VendorUiController.TypeFilterMenuId] = TypeMenu,
|
||||
[VendorUiController.ItemNameTextId] = ItemNameText,
|
||||
[VendorUiController.ItemCostTextId] = ItemCostText,
|
||||
});
|
||||
|
||||
Window = RetailWindowFrame.Mount(
|
||||
Screen,
|
||||
root,
|
||||
static _ => (0u, 0, 0),
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Vendor,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Visible = false,
|
||||
Draggable = false,
|
||||
Resizable = false,
|
||||
});
|
||||
|
||||
Controller = VendorUiController.Bind(
|
||||
layout,
|
||||
State,
|
||||
Window,
|
||||
static (_, iconId, _, _, _) => iconId != 0u ? iconId + 1000u : 0u)!;
|
||||
Screen.WindowManager.AttachController(WindowNames.Vendor, Controller);
|
||||
}
|
||||
}
|
||||
|
||||
private static VendorShopProfile Profile(
|
||||
float sellRate = 1.5f, uint altCurrency = 0u, string altName = "") =>
|
||||
new(0u, 0u, 0u, false, 1.0f, sellRate, altCurrency, 0u, altName);
|
||||
|
||||
private static string GetText(UiText text)
|
||||
=> string.Concat(text.LinesProvider().Select(line => line.Text));
|
||||
|
||||
[Fact]
|
||||
public void Bind_WiresTheScrollbarToTheItemListsScrollModel()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
Assert.True(h.ItemScrollbar.Horizontal);
|
||||
Assert.Same(h.ItemList.Scroll, h.ItemScrollbar.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opened_ShowsWindowAndPopulatesFirstPresentCategoryInTableOrder()
|
||||
{
|
||||
var h = new Harness();
|
||||
var items = new[]
|
||||
{
|
||||
// Food is table entry #5, Armor is #1 — retail's fixed table
|
||||
// order, not insertion order, decides the default selection.
|
||||
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
};
|
||||
|
||||
h.State.Apply(VendorGuid, Profile(), items);
|
||||
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.True(h.ItemsPage.Visible);
|
||||
Assert.False(h.BuyingPage.Visible);
|
||||
Assert.False(h.SellingPage.Visible);
|
||||
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
||||
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closed_HidesWindowAndClearsListAndText()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
Assert.True(h.Window.IsVisible);
|
||||
Assert.NotEqual(string.Empty, GetText(h.ItemNameText));
|
||||
|
||||
h.State.Close();
|
||||
|
||||
Assert.False(h.Window.IsVisible);
|
||||
Assert.Equal(0, h.ItemList.GetNumUIItems());
|
||||
Assert.Equal(string.Empty, GetText(h.ItemNameText));
|
||||
Assert.Equal(string.Empty, GetText(h.ItemCostText));
|
||||
Assert.Empty(h.TypeMenu.Items);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_HidesWindowAndClearsContent()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.State.Reset();
|
||||
|
||||
Assert.False(h.Window.IsVisible);
|
||||
Assert.Equal(0, h.ItemList.GetNumUIItems());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingItem_ShowsRetailCorrectPerUnitPrice_ForStackedItem()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
|
||||
{
|
||||
// Value=1000 is the STACK-TOTAL wire value for a 100-unit stack;
|
||||
// per-unit = 1000/100 = 10. SellPrice = ceil(2.0*10*1 - 0.1) = 20
|
||||
// (VendorPricing.SellPrice, ShopSystem::SellPrice pc:702107).
|
||||
new VendorShopItem(
|
||||
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
||||
DescStackSize: 100),
|
||||
});
|
||||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
|
||||
Assert.Equal("Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal("20", GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingItem_WithAlternateCurrency_AppendsCurrencyName()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(
|
||||
VendorGuid,
|
||||
Profile(sellRate: 1.0f, altCurrency: 0x12345678u, altName: "Trade Notes"),
|
||||
new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 50),
|
||||
});
|
||||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
|
||||
Assert.Equal("50 Trade Notes", GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingDifferentCategory_FiltersItemListToThatCategoryOnly()
|
||||
{
|
||||
var h = new Harness();
|
||||
var armor = new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500);
|
||||
var food = new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5);
|
||||
h.State.Apply(VendorGuid, Profile(), new[] { armor, food });
|
||||
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); // default: Armor (table order)
|
||||
|
||||
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
||||
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
||||
|
||||
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
||||
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButton_RoutesThroughVendorStateClose_NotADirectFieldWrite()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.CloseButton.OnClick!.Invoke();
|
||||
|
||||
// The panel never mutates VendorState directly — the ONLY path from
|
||||
// the close button to a cleared session is VendorState.Close()
|
||||
// itself, so VendorId reads back 0 through the owner's own public
|
||||
// surface, not a private field poke.
|
||||
Assert.Equal(0u, h.State.VendorId);
|
||||
Assert.False(h.Window.IsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SellingTab_SwitchesPageButPopulatesNoSellContent()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.SellingTab.OnClick!.Invoke();
|
||||
|
||||
Assert.True(h.SellingPage.Visible);
|
||||
Assert.False(h.ItemsPage.Visible);
|
||||
Assert.False(h.BuyingPage.Visible);
|
||||
// Slice 6 fence: no sell-list/price/button wiring exists at all —
|
||||
// the page is exactly the authored-empty container it started as.
|
||||
Assert.Empty(h.SellingPage.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuyingTab_SwitchesPageButPopulatesNoBuyContent()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
h.BuyingTab.OnClick!.Invoke();
|
||||
|
||||
Assert.True(h.BuyingPage.Visible);
|
||||
Assert.False(h.ItemsPage.Visible);
|
||||
Assert.Empty(h.BuyingPage.Children);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemsTab_ReselectedAfterVisitingOtherTabs_ShowsBrowseListAgain()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
h.SellingTab.OnClick!.Invoke();
|
||||
|
||||
h.ItemsTab.OnClick!.Invoke();
|
||||
|
||||
Assert.True(h.ItemsPage.Visible);
|
||||
Assert.False(h.SellingPage.Visible);
|
||||
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
||||
}
|
||||
}
|
||||
15938
tests/AcDream.App.Tests/UI/Layout/fixtures/vendor_21000012_100000B7.json
Normal file
15938
tests/AcDream.App.Tests/UI/Layout/fixtures/vendor_21000012_100000B7.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue