fix(ui): Slice 5.4 review corrections — the dropdown renders from its authored popup, retail cost semantics, auto-select, icon overlays
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
All nine review findings closed at root (one sub-item consciously
deferred):
F1 the category dropdown now draws: sprites/fonts wired and the popup
geometry read from the vendor menu's own authored popup LayoutDesc
0x21000043 (root 0x1000034F — correcting the review's 0x1000014F
transcription) per UIElement_Menu::MakePopup (pc:120705); chat's menu
is untouched and its tests prove it. The new test drives selection
through the REAL open/hit path the review flagged as bypassed.
F2+F3 the selected-item cost display ports VendorItemsUI::UpdateItemsUI
verbatim: quantity via the 0xDC41CB0 split-size mask (whole-stack for
ammo, per-unit for groceries/components; mask lives at the toolbar
SEEDING site pc:198784), plural names with retail's
fall-back-to-singular (pc:409056 — correcting the review's "name+s"
guess), full cost sentences with comma grouping and the player's coin
total, and Buy/Add buttons that disable without a selection.
F4 category switches auto-select the first filtered item (pc:201180).
F5 icon underlay/overlay/effects + plural name forwarded from the
already-parsed wire fields through VendorShopItem to the icon
composer. F6 a DIFFERENT vendor opens on its own first category;
same-vendor refresh preserves per the clamp. F7 scroll resets on
rebuild and authored empty slots fill; the right-click examine route
is consciously DEFERRED (shop items are not in ClientObjectTable and
the appraisal panel hard-requires it — documented, not faked).
F8 VendorState.Apply's fanout gets the same per-listener isolation as
Close/Reset. F9 AP-110/AP-161 wording corrected ("quantity-correct
pricing") and AP-161 rewritten to exactly the remaining conscious
gaps.
Clean-room complete solution with the #348 cursor fix in the same
tree: 11,334 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9d3df5f627
commit
e602f84be2
6 changed files with 696 additions and 35 deletions
File diff suppressed because one or more lines are too long
|
|
@ -2,7 +2,9 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Properties;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
@ -76,6 +78,90 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
public const uint BuyingPageId = 0x100000C4u;
|
||||
public const uint SellingPageId = 0x100000CDu;
|
||||
|
||||
/// <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>
|
||||
/// 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
|
||||
/// is a deliberate, bounded choice: retail's actual closed-button
|
||||
/// chrome is a separate two-piece label+arrow assembly
|
||||
/// (<c>0x1000034D</c> label + <c>0x1000034E</c> a 17x19 arrow cap with
|
||||
/// its own Normal/Highlight sprites <c>0x060012B1</c>/<c>0x060012B2</c>),
|
||||
/// and <see cref="UiMenu"/>'s existing single-texture 3-slice
|
||||
/// <c>DrawButtonFace</c> (end caps sized for chat's 46px LED-arrow
|
||||
/// texture) does not model a second overlaid arrow icon. Reworking
|
||||
/// <c>DrawButtonFace</c> into a two-piece label+arrow renderer would
|
||||
/// have meant redesigning UiMenu's draw model for a purely cosmetic
|
||||
/// gain while the actual reported bug (nothing renders at all) is
|
||||
/// fixed by this simpler substitution; the arrow glyph gap is recorded
|
||||
/// as a residual visual approximation (see the register, AP-161).
|
||||
/// </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;
|
||||
|
||||
/// <summary>
|
||||
/// F2 mask rule (Slice 5.4 review): the split-exempt <c>ItemType</c>
|
||||
/// mask <c>gmToolbarUI::HandleSelectionChanged</c> applies when seeding
|
||||
/// <c>GenItemHolder::splitSize</c> for a vendor-owned selection
|
||||
/// (<c>pc:198779-198790</c>, literal mask at <c>pc:198784</c>). Every
|
||||
/// row <see cref="VendorUiController"/> shows IS vendor-owned (its
|
||||
/// container is unconditionally the open vendor), so the "does this
|
||||
/// item belong to the open vendor" gate that precedes the mask check in
|
||||
/// retail's function is always true here and is not reproduced
|
||||
/// separately — see <see cref="VendorSplitSize"/>.
|
||||
/// </summary>
|
||||
private const uint SplitExemptMask = 0x0DC41CB0u;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's ordered category table, transcribed verbatim from
|
||||
/// <c>VendorItemsUI::OpenVendor</c>'s <c>AddTypeFilter</c> call chain
|
||||
|
|
@ -109,6 +195,8 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
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 UiElement _itemsPage;
|
||||
private readonly UiElement _buyingPage;
|
||||
private readonly UiElement _sellingPage;
|
||||
|
|
@ -120,6 +208,8 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
private readonly UiText _itemNameText;
|
||||
private readonly UiText _itemCostText;
|
||||
private readonly UiButton? _close;
|
||||
private readonly UiButton? _buyButton;
|
||||
private readonly UiButton? _addButton;
|
||||
|
||||
private readonly List<(string Label, ItemType Mask)> _presentCategories = new();
|
||||
private int _selectedCategoryIndex = -1;
|
||||
|
|
@ -130,6 +220,8 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
VendorState vendor,
|
||||
RetailWindowHandle window,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
UiElement itemsPage,
|
||||
UiElement buyingPage,
|
||||
UiElement sellingPage,
|
||||
|
|
@ -141,11 +233,19 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
UiMenu typeMenu,
|
||||
UiText itemNameText,
|
||||
UiText itemCostText,
|
||||
UiButton? close)
|
||||
UiButton? close,
|
||||
UiButton? buyButton,
|
||||
UiButton? addButton,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||
uint emptySlotSprite)
|
||||
{
|
||||
_vendor = vendor;
|
||||
_window = window;
|
||||
_resolveIcon = resolveIcon;
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_itemsPage = itemsPage;
|
||||
_buyingPage = buyingPage;
|
||||
_sellingPage = sellingPage;
|
||||
|
|
@ -157,18 +257,45 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
_itemNameText = itemNameText;
|
||||
_itemCostText = itemCostText;
|
||||
_close = close;
|
||||
_buyButton = buyButton;
|
||||
_addButton = addButton;
|
||||
|
||||
_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;
|
||||
_itemList.EmptySlotFactory = () => new UiItemSlot
|
||||
{
|
||||
SpriteResolve = _itemList.SpriteResolve,
|
||||
};
|
||||
if (itemScrollbar is not null)
|
||||
{
|
||||
itemScrollbar.Model = _itemList.Scroll;
|
||||
itemScrollbar.Horizontal = true;
|
||||
}
|
||||
|
||||
// F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers
|
||||
// (UiMenu draws nothing without SpriteResolve — see the popup
|
||||
// geometry class doc above) and the vendor-authored popup geometry
|
||||
// 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;
|
||||
_typeMenu.OnSelect = payload =>
|
||||
{
|
||||
if (payload is uint mask) SelectCategory(mask);
|
||||
|
|
@ -190,16 +317,37 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
_vendor.Changed += OnVendorChanged;
|
||||
}
|
||||
|
||||
/// <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="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>
|
||||
public static VendorUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
VendorState vendor,
|
||||
RetailWindowHandle window,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon)
|
||||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||
uint emptySlotSprite = 0u)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(vendor);
|
||||
ArgumentNullException.ThrowIfNull(window);
|
||||
ArgumentNullException.ThrowIfNull(resolveIcon);
|
||||
ArgumentNullException.ThrowIfNull(objects);
|
||||
ArgumentNullException.ThrowIfNull(playerGuid);
|
||||
ArgumentNullException.ThrowIfNull(resolveSprite);
|
||||
|
||||
if (layout.FindElement(ItemsPageId) is not { } itemsPage
|
||||
|| layout.FindElement(BuyingPageId) is not { } buyingPage
|
||||
|
|
@ -217,11 +365,15 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
|
||||
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;
|
||||
|
||||
return new VendorUiController(
|
||||
vendor,
|
||||
window,
|
||||
resolveIcon,
|
||||
objects,
|
||||
playerGuid,
|
||||
itemsPage,
|
||||
buyingPage,
|
||||
sellingPage,
|
||||
|
|
@ -233,7 +385,13 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
typeMenu,
|
||||
itemNameText,
|
||||
itemCostText,
|
||||
close);
|
||||
close,
|
||||
buyButton,
|
||||
addButton,
|
||||
datFont,
|
||||
debugFont,
|
||||
resolveSprite,
|
||||
emptySlotSprite);
|
||||
}
|
||||
|
||||
private enum VendorPanelTab { Items, Buying, Selling }
|
||||
|
|
@ -253,7 +411,25 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
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.
|
||||
_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.
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
RebuildCategories();
|
||||
_window.Show();
|
||||
|
|
@ -334,6 +510,19 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
/// <see cref="_presentCategories"/> (no vendor stock at all, or nothing
|
||||
/// selected) shows zero rows, matching retail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// F4/F7a (Slice 5.4 review): the tail of the SAME retail function
|
||||
/// (<c>pc:201180-201190</c>) — after every rebuild (fresh open OR an
|
||||
/// explicit category switch, both funnel through this method) the
|
||||
/// FIRST item that passed the filter becomes the display selection when
|
||||
/// the previous one didn't survive it, and the list unconditionally
|
||||
/// scrolls back to its start (<c>ScrollToShow(m_shopList, 0)</c>).
|
||||
/// Retail routes the selection through the global
|
||||
/// <c>ACCWeenieObject::selectedID</c>/<c>SetSelectedObject</c>; this
|
||||
/// keeps the existing PRIVATE <see cref="_selectedItemGuid"/> selection
|
||||
/// instead of wiring that global seam (deferred — see the register,
|
||||
/// AP-161).
|
||||
/// </remarks>
|
||||
private void RebuildItemList()
|
||||
{
|
||||
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
|
||||
|
|
@ -343,6 +532,7 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
|
||||
IReadOnlyList<VendorShopItem> items = _vendor.Items;
|
||||
bool selectionStillPresent = false;
|
||||
VendorShopItem? firstItem = null;
|
||||
|
||||
using (_itemList.DeferLayout())
|
||||
{
|
||||
|
|
@ -353,9 +543,19 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
{
|
||||
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
|
||||
|
||||
firstItem ??= item;
|
||||
if (item.ItemGuid == _selectedItemGuid) selectionStillPresent = true;
|
||||
|
||||
uint icon = _resolveIcon((ItemType)(item.ItemType ?? 0u), item.IconId, 0u, 0u, 0u);
|
||||
// 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,
|
||||
|
|
@ -371,17 +571,43 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
}
|
||||
|
||||
if (!selectionStillPresent)
|
||||
ClearSelection();
|
||||
{
|
||||
if (firstItem is { } first) SelectItem(first);
|
||||
else ClearSelection();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// Port of retail row selection display —
|
||||
/// <c>VendorItemsUI::UpdateItemsUI</c> (<c>0x004C38E0</c>,
|
||||
/// <c>pc:202539-202820</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// F2/F3 (Slice 5.4 review): the priced/named QUANTITY is retail's
|
||||
/// <c>ItemHolder::GetObjectSplitSize</c> (<c>0x00586F00</c>,
|
||||
/// <c>pc:401465-401477</c>) AS SEEDED for a vendor-shop item by
|
||||
/// <c>gmToolbarUI::HandleSelectionChanged</c>'s vendor branch — see
|
||||
/// <see cref="VendorSplitSize"/> for the exact mask citation. 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 SelectItem(VendorShopItem item)
|
||||
{
|
||||
_selectedItemGuid = item.ItemGuid;
|
||||
|
|
@ -392,16 +618,84 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
cell.Selected = cell.ItemId == _selectedItemGuid;
|
||||
}
|
||||
|
||||
SetPlainText(_itemNameText, item.Name ?? string.Empty);
|
||||
int quantity = VendorSplitSize(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: 1);
|
||||
string costText = profile.AlternateCurrencyWcid == 0u
|
||||
? price.ToString(CultureInfo.InvariantCulture)
|
||||
: $"{price.ToString(CultureInfo.InvariantCulture)} {profile.AlternateCurrencyPluralName}";
|
||||
SetPlainText(_itemCostText, costText);
|
||||
int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity);
|
||||
SetPlainText(_itemCostText, BuildCostText(profile, quantity, price));
|
||||
|
||||
SetActionButtonsEnabled(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The quantity retail prices/names a vendor-shop selection at —
|
||||
/// <c>gmToolbarUI::HandleSelectionChanged</c>'s vendor-owned branch
|
||||
/// (<c>pc:198779-198790</c>, mask literal at <c>pc:198784</c>): 1 unit
|
||||
/// if the item's type intersects <see cref="SplitExemptMask"/>, else its
|
||||
/// own authored stack size (0/absent treated as 1, matching
|
||||
/// <c>ItemHolder::GetObjectSplitSize</c>'s own <c>stackSize==0 -> 1</c>
|
||||
/// floor, <c>pc:401473-401476</c>).
|
||||
/// </summary>
|
||||
private static int VendorSplitSize(VendorShopItem item)
|
||||
{
|
||||
if (((item.ItemType ?? 0u) & SplitExemptMask) != 0u) return 1;
|
||||
return item.DescStackSize is { } size && size > 0 ? size : 1;
|
||||
}
|
||||
|
||||
/// <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";
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0} {1} (you have {2})",
|
||||
verb,
|
||||
price.ToString("N0", CultureInfo.InvariantCulture),
|
||||
playerTotal.ToString("N0", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
|
|
@ -414,6 +708,13 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
}
|
||||
SetPlainText(_itemNameText, string.Empty);
|
||||
SetPlainText(_itemCostText, string.Empty);
|
||||
SetActionButtonsEnabled(false);
|
||||
}
|
||||
|
||||
private void SetActionButtonsEnabled(bool enabled)
|
||||
{
|
||||
if (_buyButton is not null) _buyButton.Enabled = enabled;
|
||||
if (_addButton is not null) _addButton.Enabled = enabled;
|
||||
}
|
||||
|
||||
private void ClearContent()
|
||||
|
|
|
|||
|
|
@ -424,7 +424,18 @@ public static class GameEventWiring
|
|||
// supply-count field) -- VendorPricing.PerUnitValue's
|
||||
// divisor for turning Value's stack-total wire number
|
||||
// into a per-unit display price.
|
||||
item.Desc.StackSize);
|
||||
item.Desc.StackSize,
|
||||
// Slice 5.4 review fix F5: forward the icon overlay/
|
||||
// underlay/effects PublicWeenieDescParser already
|
||||
// captures, so a shop item's icon composites the same
|
||||
// way ExternalContainerController.CreateCell's does.
|
||||
item.Desc.IconUnderlayId,
|
||||
item.Desc.IconOverlayId,
|
||||
item.Desc.UiEffects,
|
||||
// Slice 5.4 review fix F2/F3: forward the plural name so
|
||||
// the browse panel can show "100 Arrows" instead of
|
||||
// fabricating a plural.
|
||||
item.Desc.PluralName);
|
||||
}
|
||||
|
||||
vendor?.Apply(p.Value.VendorGuid, profile, shopItems);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,25 @@ public readonly record struct VendorShopItem(
|
|||
// conditionally present (weenieFlags-gated) -- absent maps to null here,
|
||||
// matching retail's own zeroed-struct default of 0 for the same case
|
||||
// (see VendorPricing.PerUnitValue's <= 0 guard).
|
||||
int? DescStackSize = null);
|
||||
int? DescStackSize = null,
|
||||
// Review finding F5 (Slice 5.4 review): PublicWeenieDescBody already
|
||||
// carries these three (IconOverlayId/IconUnderlayId/UiEffects) — see
|
||||
// AcDream.Core.Net.Messages.PublicWeenieDescBody. Mirrors
|
||||
// ClientObjectTable's ClientObject.IconUnderlayId/IconOverlayId/Effects
|
||||
// naming exactly, so VendorUiController can forward them to
|
||||
// _resolveIcon the same way ExternalContainerController.CreateCell does
|
||||
// (item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId,
|
||||
// item.Effects). 0 = "not sent", same as ClientObject's convention.
|
||||
uint IconUnderlayId = 0u,
|
||||
uint IconOverlayId = 0u,
|
||||
uint Effects = 0u,
|
||||
// Review finding F2/F3 (Slice 5.4 review): retail's NAME_PLURAL display
|
||||
// (ACCWeenieObject::GetObjectName, pc:409056-409132) reads
|
||||
// PublicWeenieDesc::_plural_name -- wire AcDream.Core.Net.Messages.
|
||||
// PublicWeenieDescBody.PluralName. Null/empty is a real, retail-modeled
|
||||
// case (no plural authored): GetObjectName falls back to the singular
|
||||
// Name unchanged, not an auto-pluralized "Name+s".
|
||||
string? PluralName = null);
|
||||
|
||||
public enum VendorStateTransitionKind
|
||||
{
|
||||
|
|
@ -135,10 +153,31 @@ public sealed class VendorState
|
|||
Profile = profile;
|
||||
Items = items;
|
||||
|
||||
Changed?.Invoke(new VendorTransition(
|
||||
var transition = new VendorTransition(
|
||||
sameVendor ? VendorStateTransitionKind.Refreshed : VendorStateTransitionKind.Opened,
|
||||
previous,
|
||||
vendorGuid));
|
||||
vendorGuid);
|
||||
// Review finding F8 (Slice 5.4 review): match Close()'s per-listener
|
||||
// isolation + catch-and-log shape instead of a bare Invoke — Apply()
|
||||
// is called from the SAME per-frame inbound-message-dispatch boundary
|
||||
// (GameEventDispatcher.Dispatch's ApproachVendor handler) that
|
||||
// Close()'s doc comment already establishes must survive a broken
|
||||
// observer, and a bare Invoke lets the FIRST listener's exception
|
||||
// starve every listener registered after it (e.g. a plugin panel
|
||||
// wired after the retail VendorUiController).
|
||||
Action<VendorTransition>? listeners = Changed;
|
||||
if (listeners is not null)
|
||||
{
|
||||
foreach (Action<VendorTransition> listener in listeners.GetInvocationList())
|
||||
{
|
||||
try { listener(transition); }
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[VendorState] Apply() observer threw: {error.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Linq;
|
|||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Properties;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
|
|
@ -44,15 +45,26 @@ public sealed class VendorUiControllerTests
|
|||
layout,
|
||||
new VendorState(),
|
||||
window,
|
||||
static (_, _, _, _, _) => 0u);
|
||||
static (_, _, _, _, _) => 0u,
|
||||
new ClientObjectTable(),
|
||||
static () => 0u,
|
||||
datFont: null,
|
||||
debugFont: null,
|
||||
static _ => (0u, 0, 0));
|
||||
|
||||
Assert.NotNull(controller);
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
// F2/F3: a deterministic non-zero player coin total so the cost-text
|
||||
// "(you have ...)" tail is assertable.
|
||||
public const int DefaultPlayerCoinValue = 1500;
|
||||
private const uint PlayerGuid = 0x50000001u;
|
||||
|
||||
public readonly VendorState State = new();
|
||||
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
|
||||
public readonly ClientObjectTable Objects = new();
|
||||
public readonly UiItemList ItemList = new();
|
||||
public readonly UiScrollbar ItemScrollbar = new();
|
||||
public readonly UiMenu TypeMenu = new();
|
||||
|
|
@ -65,15 +77,27 @@ public sealed class VendorUiControllerTests
|
|||
public readonly UiElement BuyingPage = new TestElement();
|
||||
public readonly UiElement SellingPage = new TestElement();
|
||||
public readonly UiButton CloseButton;
|
||||
public readonly UiButton BuyButton;
|
||||
public readonly UiButton AddButton;
|
||||
public readonly RetailWindowHandle Window;
|
||||
public readonly VendorUiController Controller;
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[(uint)PropertyInt.CoinValue] = DefaultPlayerCoinValue;
|
||||
Objects.UpsertProperties(PlayerGuid, bundle);
|
||||
|
||||
var root = new TestElement { Width = 800f, Height = 110f };
|
||||
CloseButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.CloseId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
BuyButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.BuyButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
AddButton = new UiButton(
|
||||
new ElementInfo { Id = VendorUiController.AddButtonId, Type = 1 },
|
||||
static _ => (0u, 0, 0));
|
||||
|
||||
root.AddChild(CloseButton);
|
||||
root.AddChild(ItemsTab);
|
||||
|
|
@ -87,6 +111,8 @@ public sealed class VendorUiControllerTests
|
|||
ItemsPage.AddChild(TypeMenu);
|
||||
ItemsPage.AddChild(ItemNameText);
|
||||
ItemsPage.AddChild(ItemCostText);
|
||||
ItemsPage.AddChild(BuyButton);
|
||||
ItemsPage.AddChild(AddButton);
|
||||
|
||||
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
|
||||
{
|
||||
|
|
@ -102,6 +128,8 @@ public sealed class VendorUiControllerTests
|
|||
[VendorUiController.TypeFilterMenuId] = TypeMenu,
|
||||
[VendorUiController.ItemNameTextId] = ItemNameText,
|
||||
[VendorUiController.ItemCostTextId] = ItemCostText,
|
||||
[VendorUiController.BuyButtonId] = BuyButton,
|
||||
[VendorUiController.AddButtonId] = AddButton,
|
||||
});
|
||||
|
||||
Window = RetailWindowFrame.Mount(
|
||||
|
|
@ -121,14 +149,22 @@ public sealed class VendorUiControllerTests
|
|||
layout,
|
||||
State,
|
||||
Window,
|
||||
static (_, iconId, _, _, _) => iconId != 0u ? iconId + 1000u : 0u)!;
|
||||
// F5: sum every argument so a test can prove underlay/overlay/
|
||||
// effects were actually forwarded (iconId-only would silently
|
||||
// regress to dropping the last three).
|
||||
static (_, iconId, underlay, overlay, effects) => iconId + underlay + overlay + effects,
|
||||
Objects,
|
||||
static () => PlayerGuid,
|
||||
datFont: null,
|
||||
debugFont: null,
|
||||
static _ => (0u, 0, 0))!;
|
||||
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);
|
||||
float sellRate = 1.5f, uint altCurrency = 0u, string altName = "", uint altAmount = 0u) =>
|
||||
new(0u, 0u, 0u, false, 1.0f, sellRate, altCurrency, altAmount, altName);
|
||||
|
||||
private static string GetText(UiText text)
|
||||
=> string.Concat(text.LinesProvider().Select(line => line.Text));
|
||||
|
|
@ -202,13 +238,18 @@ public sealed class VendorUiControllerTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingItem_ShowsRetailCorrectPerUnitPrice_ForStackedItem()
|
||||
public void SelectingItem_NotInSplitExemptMask_ShowsWholeStackPriceAndFallbackPluralName()
|
||||
{
|
||||
// F2/F3 (Slice 5.4 review): MissileWeapon (0x100) does NOT intersect
|
||||
// the 0xDC41CB0 split-exempt mask, so retail prices/names the WHOLE
|
||||
// stack, not one unit — the review flagged the OLD test (asserting a
|
||||
// per-unit price + bare "Arrows") as wrong.
|
||||
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
|
||||
// per-unit = 1000/100 = 10. quantity = DescStackSize = 100 (not
|
||||
// exempt). SellPrice = ceil(2.0*10*100 - 0.1) = 2000
|
||||
// (VendorPricing.SellPrice, ShopSystem::SellPrice pc:702107).
|
||||
new VendorShopItem(
|
||||
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
||||
|
|
@ -217,17 +258,66 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
|
||||
Assert.Equal("Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal("20", GetText(h.ItemCostText));
|
||||
// No authored PluralName -> ACCWeenieObject::GetObjectName falls back
|
||||
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
|
||||
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {2000:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingItem_WithAlternateCurrency_AppendsCurrencyName()
|
||||
public void SelectingItem_InSplitExemptMask_ShowsPerUnitPriceAndSingularName()
|
||||
{
|
||||
// F2/F3: Food (0x20) DOES intersect the split-exempt mask, so
|
||||
// quantity is forced to 1 even though the item has a larger
|
||||
// authored stack — the mask-member case the review asked for.
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(sellRate: 2.0f), new[]
|
||||
{
|
||||
// perUnit = 500/50 = 10. quantity forced to 1 (Food is masked).
|
||||
// SellPrice = ceil(2.0*10*1 - 0.1) = 20.
|
||||
new VendorShopItem(
|
||||
FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 500,
|
||||
DescStackSize: 50),
|
||||
});
|
||||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
|
||||
Assert.Equal("Bread", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0} (you have {Harness.DefaultPlayerCoinValue:N0})",
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingItem_WithAuthoredPluralName_UsesItInsteadOfSingular()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
StackedItemGuid, -1, 3u, "Iron Key", (uint)ItemType.Key, 50u, 100,
|
||||
DescStackSize: 10, PluralName: "Iron Keys"),
|
||||
});
|
||||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
|
||||
Assert.Equal("10 Iron Keys", GetText(h.ItemNameText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingItem_WithAlternateCurrency_ShowsFullRetailCostSentence()
|
||||
{
|
||||
// F2/F3: the OLD implementation just appended the currency name after
|
||||
// a bare number ("50 Trade Notes"). Retail's actual alt-currency
|
||||
// sentence is a full two-clause sentence with a "you have" tail
|
||||
// (acclient_2013_pseudo_c.txt:991314), using RAW (non-comma-grouped)
|
||||
// integers — unlike the primary-currency branch.
|
||||
var h = new Harness();
|
||||
h.State.Apply(
|
||||
VendorGuid,
|
||||
Profile(sellRate: 1.0f, altCurrency: 0x12345678u, altName: "Trade Notes"),
|
||||
Profile(sellRate: 1.0f, altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 12345u),
|
||||
new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 50),
|
||||
|
|
@ -235,7 +325,52 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
h.ItemList.GetItem(0)!.Clicked?.Invoke();
|
||||
|
||||
Assert.Equal("50 Trade Notes", GetText(h.ItemCostText));
|
||||
// Armor (not in the split-exempt mask) with DescStackSize null ->
|
||||
// PerUnitValue returns Value unchanged (50); quantity = 1 (no
|
||||
// authored stack) -> SellPrice(50, Armor, 1.0, 1) = ceil(50-0.1) = 50.
|
||||
Assert.Equal(
|
||||
"This item costs 50 Trade Notes. You have 12345 Trade Notes.",
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoSelection_DisablesBuyAndAddButtons_SelectionEnablesThem()
|
||||
{
|
||||
var h = new Harness();
|
||||
Assert.False(h.BuyButton.Enabled);
|
||||
Assert.False(h.AddButton.Enabled);
|
||||
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
});
|
||||
|
||||
// F4 auto-selects the sole item on open, so the buttons are already
|
||||
// enabled — SetState(1), pc:202784-202789.
|
||||
Assert.True(h.BuyButton.Enabled);
|
||||
Assert.True(h.AddButton.Enabled);
|
||||
|
||||
// Closing clears the selection -> SetState(0xd), pc:202572-202577.
|
||||
h.State.Close();
|
||||
Assert.False(h.BuyButton.Enabled);
|
||||
Assert.False(h.AddButton.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShopItem_WithIconUnderlayOverlayEffects_ForwardsThemToResolveIcon()
|
||||
{
|
||||
// F5: PublicWeenieDescBody already carries these three; the review
|
||||
// flagged VendorUiController hardcoding 0u/0u/0u instead of
|
||||
// forwarding item.IconUnderlayId/IconOverlayId/Effects.
|
||||
var h = new Harness();
|
||||
var item = new VendorShopItem(
|
||||
ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, IconId: 200u, Value: 500,
|
||||
IconUnderlayId: 10u, IconOverlayId: 100u, Effects: 1000u);
|
||||
h.State.Apply(VendorGuid, Profile(), new[] { item });
|
||||
|
||||
// Harness's resolveIcon sums every argument (see its comment) — this
|
||||
// regresses to 200u alone if the three are dropped again.
|
||||
Assert.Equal(200u + 10u + 100u + 1000u, h.ItemList.GetItem(0)!.IconTexture);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -254,6 +389,158 @@ public sealed class VendorUiControllerTests
|
|||
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingDifferentCategory_AutoSelectsFirstFilteredItem()
|
||||
{
|
||||
// F4: VendorItemsUI::UpdateItemsList's tail (pc:201180-201190) —
|
||||
// after a category switch the first item that passed the filter
|
||||
// becomes the display selection, not a cleared blank.
|
||||
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 });
|
||||
|
||||
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
||||
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
||||
|
||||
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||
Assert.True(h.ItemList.GetItem(0)!.Selected);
|
||||
Assert.NotEqual(string.Empty, GetText(h.ItemNameText));
|
||||
Assert.NotEqual(string.Empty, GetText(h.ItemCostText));
|
||||
Assert.True(h.BuyButton.Enabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectingDifferentCategory_ResetsListScrollToStart()
|
||||
{
|
||||
// F7a: VendorItemsUI::UpdateItemsList's tail (pc:201186-201190) —
|
||||
// ScrollToShow(m_shopList, 0) fires unconditionally on every rebuild.
|
||||
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 });
|
||||
|
||||
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
||||
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
||||
|
||||
Assert.Equal(0, h.ItemList.Scroll.ScrollY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemList_FillsVisibleEmptySlots()
|
||||
{
|
||||
// F7b: mirrors ExternalContainerController.ConfigureList's empty-slot
|
||||
// fill so the authored 22-slot strip doesn't show blank space.
|
||||
var h = new Harness();
|
||||
|
||||
Assert.True(h.ItemList.FillVisibleEmptySlots);
|
||||
Assert.NotNull(h.ItemList.EmptySlotFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opened_WithDifferentVendor_ResetsToFirstPresentCategory_NotThePreviousVendors()
|
||||
{
|
||||
// F6: gmVendorUI::OpenVendor flushes sub-UIs when sameVendor==0
|
||||
// (pc:203664-203667) — a different vendor must not inherit whatever
|
||||
// category index the LAST vendor happened to have selected.
|
||||
const uint OtherVendorGuid = 0x70000020u;
|
||||
const uint OtherArmorGuid = 0x60000201u;
|
||||
const uint OtherFoodGuid = 0x60000202u;
|
||||
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
||||
});
|
||||
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
||||
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
||||
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
||||
|
||||
// A DIFFERENT vendor, which ALSO has >=2 categories (Armor then
|
||||
// Food, table order) so a stale numeric index (1) would silently
|
||||
// "validate" without the fix and land on Food again by coincidence
|
||||
// rather than by the vendor's own contents.
|
||||
h.State.Apply(OtherVendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(OtherArmorGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40),
|
||||
new VendorShopItem(OtherFoodGuid, -1, 5u, "Ale", (uint)ItemType.Food, 100u, 3),
|
||||
});
|
||||
|
||||
Assert.Equal("Armor", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
||||
Assert.Equal(OtherArmorGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refreshed_SameVendor_PreservesCategorySelection()
|
||||
{
|
||||
// F6 counterpart: gmVendorUI::OpenVendor's sameVendor==1 path does
|
||||
// NOT flush sub-UIs, so a same-vendor refresh (Slice 6 post-buy/sell)
|
||||
// must keep the player's chosen category.
|
||||
var h = new Harness();
|
||||
var items = new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
||||
};
|
||||
h.State.Apply(VendorGuid, Profile(), items);
|
||||
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
|
||||
h.TypeMenu.OnSelect!.Invoke(foodPayload);
|
||||
|
||||
// SAME vendor id re-approaches -> VendorStateTransitionKind.Refreshed.
|
||||
h.State.Apply(VendorGuid, Profile(), items);
|
||||
|
||||
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CategoryMenu_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry()
|
||||
{
|
||||
// F1: the popup never rendered because SpriteResolve/fonts/sprites
|
||||
// were never wired, and the geometry was chat's own authored values
|
||||
// instead of the vendor dropdown's. This drives the SAME UiEvent
|
||||
// hit-test path UiMenuTests uses (not a direct OnSelect call), and
|
||||
// reads the geometry back off the live widget instead of hardcoding
|
||||
// pixel offsets tied to a specific constant set.
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), new[]
|
||||
{
|
||||
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
|
||||
});
|
||||
|
||||
// Wiring: without these UiMenu.OnDrawOverlay early-returns (nothing
|
||||
// renders) and the button face never draws either.
|
||||
Assert.NotNull(h.TypeMenu.SpriteResolve);
|
||||
Assert.NotEqual(0u, h.TypeMenu.NormalSprite);
|
||||
Assert.NotEqual(0u, h.TypeMenu.ItemNormalSprite);
|
||||
Assert.NotEqual(0u, h.TypeMenu.ItemHighlightSprite);
|
||||
|
||||
// Geometry from the authored template (LayoutDesc 0x21000043's row
|
||||
// element 0x10000352 and ListBox 0x10000350), NOT chat's 7/17/191.
|
||||
Assert.Equal(6, h.TypeMenu.RowsPerColumn);
|
||||
Assert.Equal(18f, h.TypeMenu.RowHeight);
|
||||
Assert.Equal(100f, h.TypeMenu.ColumnWidth);
|
||||
|
||||
// Open via the real widget event path.
|
||||
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5)));
|
||||
|
||||
// "Food" is the 2nd present category in table order (Armor, then
|
||||
// Food) -> row index 1, column 0. Derive the click point from the
|
||||
// widget's own live geometry (mirrors UiMenu.OnEvent's own math)
|
||||
// rather than a hardcoded pixel constant.
|
||||
const int border = 5; // RetailChromeSprites.Border (UiMenu's private bevel thickness)
|
||||
float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border;
|
||||
const int targetRow = 1;
|
||||
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
|
||||
float ly = iy - outerH + border;
|
||||
|
||||
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, (int)ly)));
|
||||
|
||||
Assert.Equal("Food", h.TypeMenu.Items.Single(i => Equals(i.Payload, h.TypeMenu.Selected)).Label);
|
||||
Assert.Equal(FoodItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseButton_RoutesThroughVendorStateClose_NotADirectFieldWrite()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -140,6 +140,29 @@ public sealed class VendorStateTests
|
|||
Assert.Equal(0u, state.VendorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_ThrowingObserver_DoesNotPropagateAndDoesNotStarveOtherListeners()
|
||||
{
|
||||
// Slice 5.4 review finding F8: Apply() ran a bare Changed?.Invoke(...)
|
||||
// where every other production boundary in this class (Close's
|
||||
// Slice 5.3 review fix 3, matched here) isolates each listener with
|
||||
// GetInvocationList() + try/catch/log. Apply() runs from the SAME
|
||||
// per-frame inbound-message-dispatch boundary Close's doc comment
|
||||
// already establishes must survive a broken observer.
|
||||
var state = new VendorState();
|
||||
|
||||
bool secondObserverRan = false;
|
||||
state.Changed += _ => throw new InvalidOperationException("boom");
|
||||
state.Changed += _ => secondObserverRan = true;
|
||||
|
||||
Exception? thrown = Record.Exception(
|
||||
() => state.Apply(0x40000008u, default, Array.Empty<VendorShopItem>()));
|
||||
|
||||
Assert.Null(thrown);
|
||||
Assert.True(secondObserverRan);
|
||||
Assert.Equal(0x40000008u, state.VendorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue