feat(vendor): Slice 6 buy arc — shop items are real objects, vendor selection is THE selection, and Buy works (0x005F)
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
Three ordered pieces in one landing (the shared controller/composition files carry all three; the internal order was 6.1 -> 6.2 -> 6.3): 6.1 VendorShopItemMaterializer diff-merges the shop list into the live ClientObjectTable on VendorState transitions (so client-local close and session teardown retire the entries too) and never claims a guid it did not add — ACE's UniqueItemsForSale can re-list a guid a player once held (AP-163 files the collision-skip; no retail counterpart traced). Right-click examine on shop items now routes through the ordinary appraisal path — the 5.4 F7c blocker dissolves with the table entries. 6.2 SelectionChangeSource.Vendor: row clicks, auto-select, and examine all flow through the canonical SelectionState; the status bar and the existing byte-faithful StackSplitQuantityState slider light up unmodified. VendorSplitPolicy is the single 0xDC41CB0 mask owner; the slider VALUE seeds to 1 for exempt items while maxSplitSize keeps the stack (the splitSize/maxSplitSize distinction, research §B.3). Selection clears at retail's actual site — VendorItemsUI::RemoveFromShop (pc:202848), not a CloseVendor-level clear that does not exist. 6.3 BuildBuy (0x005F): vendorGuid, count, (i32 amount, u32 guid) pairs, and the trailing alternateCurrencyId the REAL client sends (CM_Vendor::Event_Buy pc:689288) though ACE's reader ignores it. TryBuy rides the EXISTING J5.2 one-request-at-a-time reservation and completes on UseDone; the Buy button disables while a request is in flight. The reconciliation round-trip (money property update, inventory CreateObject, ApproachVendor refresh -> panel rebuild) is proven by a synthetic-inbound test against existing machinery — no new owner. Register: AP-161 narrowed (selection + examine residuals close; staging/Sell remain; double-click-to-buy confirmed ABSENT from retail with negative evidence cited — we match retail). AP-162 files the conscious no-client-side-affordability-precheck deferral. Clean-room complete solution: 11,368 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c884a938e0
commit
97cf873870
19 changed files with 1577 additions and 65 deletions
|
|
@ -61,6 +61,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
private readonly Action<string>? _systemMessage;
|
||||
private readonly AutoWieldController _autoWield;
|
||||
private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
|
||||
// Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId.
|
||||
private readonly Action<uint, uint, int, uint>? _sendBuy;
|
||||
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
|
||||
private readonly InventoryTransactionState _transactions;
|
||||
|
||||
|
|
@ -101,7 +103,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
Action<uint>? requestExternalContainer = null,
|
||||
CombatState? combatState = null,
|
||||
Action<CombatMode>? sendChangeCombatMode = null,
|
||||
Action<uint, ItemUseRequestReservation>? requestUse = null)
|
||||
Action<uint, ItemUseRequestReservation>? requestUse = null,
|
||||
Action<uint, uint, int, uint>? sendBuy = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -131,6 +134,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
|
||||
_systemMessage = systemMessage;
|
||||
_requestUse = requestUse;
|
||||
_sendBuy = sendBuy;
|
||||
_interactionState = interactionState
|
||||
?? throw new ArgumentNullException(nameof(interactionState));
|
||||
_runtimeTransactions = runtimeTransactions
|
||||
|
|
@ -222,6 +226,43 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6.3: retail's Buy button — <c>gmVendorUI::BuySingleItem</c>
|
||||
/// (<c>pc:201661</c>, <c>0x004C2820</c>). Immediate single-item purchase
|
||||
/// of <paramref name="itemGuid"/> (a vendor shop item) at
|
||||
/// <paramref name="amount"/> units, with the vendor's own trade currency
|
||||
/// (0 = pyreal). Rides the EXISTING one-request-at-a-time reservation
|
||||
/// ordinary Use takes (research doc §A.4: every
|
||||
/// <c>HandleActionBuyItem</c> path ends in exactly one
|
||||
/// <c>SendUseDoneEvent()</c>, the SAME completion signal
|
||||
/// <c>RuntimeInteractionTransactionState.CompleteUse</c> already
|
||||
/// resolves via the wired <c>UseDone (0x01C7)</c> handler) — no second
|
||||
/// gate, and retail's client-side busy-count increment
|
||||
/// (<c>ClientUISystem::IncrementBusyCount</c>, <c>pc:201765</c>) is
|
||||
/// exactly what <see cref="EnsureInventoryRequestReady"/>'s
|
||||
/// <c>BusyCount == 0</c> check already guards for every other request.
|
||||
/// </summary>
|
||||
public bool TryBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId)
|
||||
{
|
||||
if (vendorGuid == 0u || itemGuid == 0u || amount <= 0 || _sendBuy is null)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
||||
ItemUseRequestReservation reservation = BeginUseRequestReservation();
|
||||
try
|
||||
{
|
||||
_sendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
reservation.CancelBeforeDispatch();
|
||||
throw;
|
||||
}
|
||||
reservation.MarkDispatched();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
|
||||
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
private readonly Action<uint> _sendQueryItemMana;
|
||||
private readonly StackSplitQuantityState _splitQuantity;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Func<uint, bool> _isVendorSplitExempt;
|
||||
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
||||
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
|
||||
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
|
||||
|
|
@ -126,7 +127,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
UiDatFont? datFont,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated)
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated,
|
||||
Func<uint, bool> isVendorSplitExempt)
|
||||
{
|
||||
_isHealthTarget = isHealthTarget;
|
||||
_isOwnedByPlayer = isOwnedByPlayer;
|
||||
|
|
@ -139,6 +141,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_sendQueryItemMana = sendQueryItemMana;
|
||||
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
|
||||
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
|
||||
_isVendorSplitExempt = isVendorSplitExempt
|
||||
?? throw new ArgumentNullException(nameof(isVendorSplitExempt));
|
||||
_unsubscribeHealthChanged = unsubscribeHealthChanged;
|
||||
_unsubscribeItemManaChanged = unsubscribeItemManaChanged;
|
||||
_unsubscribeObjectUpdated = unsubscribeObjectUpdated;
|
||||
|
|
@ -250,6 +254,18 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
/// <param name="stackSize">Returns the stack size for a guid (0 or 1 = non-stacked).</param>
|
||||
/// <param name="sendQueryHealth">Sends retail <c>QueryHealth (0x01BF)</c>; may be a no-op offline.</param>
|
||||
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
|
||||
/// <param name="isVendorSplitExempt">
|
||||
/// Slice 6.2: retail's <c>gmToolbarUI::HandleSelectionChanged</c> vendor
|
||||
/// branch (<c>pc:198779-198790</c>) — true when the selected guid is
|
||||
/// owned by the currently-open vendor (its <c>ClientObject.ContainerId</c>
|
||||
/// equals <c>VendorState.VendorId</c>) AND its type intersects
|
||||
/// <see cref="VendorSplitPolicy.SplitExemptMask"/>. When true, a stack
|
||||
/// seeds to quantity 1 instead of the full authored stack size — see
|
||||
/// <see cref="ApplySelection"/>. <c>VendorUiController.VendorSplitSize</c>
|
||||
/// answers the SAME question for vendor's own display text via the SAME
|
||||
/// <see cref="VendorSplitPolicy"/> helper, so the mask exists in exactly
|
||||
/// one place (composed at <c>InteractionRetainedUiComposition</c>).
|
||||
/// </param>
|
||||
public static SelectedObjectController Bind(
|
||||
ImportedLayout layout,
|
||||
SelectionState selection,
|
||||
|
|
@ -269,14 +285,16 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
UiDatFont? datFont,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated)
|
||||
Action<Action<ClientObject>> unsubscribeObjectUpdated,
|
||||
Func<uint, bool> isVendorSplitExempt)
|
||||
=> new SelectedObjectController(
|
||||
layout, selection,
|
||||
subscribeHealthChanged, unsubscribeHealthChanged,
|
||||
subscribeItemManaChanged, unsubscribeItemManaChanged,
|
||||
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
|
||||
sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
|
||||
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated);
|
||||
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated,
|
||||
isVendorSplitExempt);
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
|
||||
|
|
@ -335,11 +353,20 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
|
||||
// gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666:
|
||||
// stacks initialize to the full stack, show the numeric entry + horizontal
|
||||
// slider, and set the stacked selection state. Vendor-owned stack precedence
|
||||
// is intentionally absent until the vendor panel owns an active vendor id.
|
||||
// slider, and set the stacked selection state. Slice 6.2: the
|
||||
// vendor-owned branch (pc:198779-198790, mask literal pc:198784)
|
||||
// seeds splitSize (the INITIAL value) to 1 instead of the full stack
|
||||
// when the selection is owned by the currently-open vendor AND its
|
||||
// type intersects VendorSplitPolicy.SplitExemptMask — see the
|
||||
// isVendorSplitExempt parameter doc. maxSplitSize (the slider's
|
||||
// RANGE) is always the full authored stack size regardless of
|
||||
// exemption (research doc §B.3: "Sets GenItemHolder::splitSize =
|
||||
// seed, GenItemHolder::maxSplitSize = stackSize") — only the
|
||||
// starting VALUE differs, not the ceiling.
|
||||
if (stackSize > 1u)
|
||||
{
|
||||
_splitQuantity.Reset(stackSize);
|
||||
uint seed = _isVendorSplitExempt(g) ? 1u : stackSize;
|
||||
_splitQuantity.Reset(stackSize, initialValue: seed);
|
||||
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
|
||||
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Selection;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
@ -149,19 +150,6 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
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
|
||||
|
|
@ -197,6 +185,9 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly ItemInteractionController _itemInteraction;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly StackSplitQuantityState _splitQuantity;
|
||||
private readonly UiElement _itemsPage;
|
||||
private readonly UiElement _buyingPage;
|
||||
private readonly UiElement _sellingPage;
|
||||
|
|
@ -213,7 +204,11 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
|
||||
private readonly List<(string Label, ItemType Mask)> _presentCategories = new();
|
||||
private int _selectedCategoryIndex = -1;
|
||||
private uint _selectedItemGuid;
|
||||
// Slice 6.3: tracks "does the CURRENT selection permit Buy/Add" separately
|
||||
// from "is a Buy request currently in flight" (RecomputeBuyButtonEnabled
|
||||
// combines both for the Buy button specifically; Add is selection-only,
|
||||
// per contract decision 6 — staging stays unwired this pass).
|
||||
private bool _buyEnabledBySelection;
|
||||
private bool _disposed;
|
||||
|
||||
private VendorUiController(
|
||||
|
|
@ -222,6 +217,9 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
ItemInteractionController itemInteraction,
|
||||
SelectionState selection,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
UiElement itemsPage,
|
||||
UiElement buyingPage,
|
||||
UiElement sellingPage,
|
||||
|
|
@ -246,6 +244,9 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
_resolveIcon = resolveIcon;
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_itemInteraction = itemInteraction;
|
||||
_selection = selection;
|
||||
_splitQuantity = splitQuantity;
|
||||
_itemsPage = itemsPage;
|
||||
_buyingPage = buyingPage;
|
||||
_sellingPage = sellingPage;
|
||||
|
|
@ -276,6 +277,13 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
{
|
||||
SpriteResolve = _itemList.SpriteResolve,
|
||||
};
|
||||
// Slice 6.1: mirrors ExternalContainerController's own
|
||||
// right-click-examine wiring (UiItemSlot.OnEvent's RightClick case).
|
||||
// Now that shop items are materialized into ClientObjectTable (see
|
||||
// VendorShopItemMaterializer), AppraisalUiController.Apply's lookup
|
||||
// succeeds and this stops being a dead end — closes half of AP-161
|
||||
// finding #2.
|
||||
_itemList.ExamineItemRequested = ExamineItem;
|
||||
if (itemScrollbar is not null)
|
||||
{
|
||||
itemScrollbar.Model = _itemList.Scroll;
|
||||
|
|
@ -310,11 +318,40 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling));
|
||||
if (_close is not null)
|
||||
_close.OnClick = () => _vendor.Close();
|
||||
// Slice 6.3: retail gmVendorUI::HandleButtonClicks' 0x100000C2 case —
|
||||
// BuySingleItem(selectedID) — an immediate single-item purchase, no
|
||||
// staging list required (research doc §B.1).
|
||||
if (_buyButton is not null)
|
||||
_buyButton.OnClick = BuySelectedItem;
|
||||
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
ClearContent();
|
||||
|
||||
_vendor.Changed += OnVendorChanged;
|
||||
// Slice 6.2: SelectionState is now the AUTHORITY (research doc §B.4:
|
||||
// vendor-context selections flow through the SAME global
|
||||
// ACCWeenieObject::SetSelectedObject primitive as every other
|
||||
// origin) — this panel is a CONSUMER, mirroring
|
||||
// ExternalContainerController.OnSelectionChanged's shape exactly.
|
||||
_selection.Changed += OnSelectionTransition;
|
||||
// Slice 6.2: mirrors ExternalContainerController.OnObjectRemoved —
|
||||
// retail's VendorItemsUI::RemoveFromShop (pc:202848-202850,
|
||||
// 0x004c3d4a) clears the global selection when a shop item leaves
|
||||
// the list (there is no separate CloseVendor-level SetSelectedObject(0)
|
||||
// call; retail's ItemList_Flush on close does not touch selection
|
||||
// directly). Since VendorShopItemMaterializer removes every
|
||||
// materialized item from ClientObjectTable on Close/Reset/replace
|
||||
// (Slice 6.1), subscribing here gives "vendor session close clears
|
||||
// a vendor-owned selection" as a consequence of the SAME generic
|
||||
// mechanism every other panel already uses, not a vendor-specific
|
||||
// special case.
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
// Slice 6.3: mirrors ExternalContainerController's own
|
||||
// _itemInteraction.StateChanged subscription — the Buy button must
|
||||
// disable the instant a reservation is taken (BeginUseRequestReservation
|
||||
// increments BusyCount synchronously, before the wire send), and
|
||||
// re-enable on the matching UseDone/cancel, without polling.
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
}
|
||||
|
||||
/// <param name="objects">
|
||||
|
|
@ -325,6 +362,28 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
/// <c>ObjectTableWiring</c>'s <c>PrivateUpdatePropertyInt</c> routing.
|
||||
/// </param>
|
||||
/// <param name="playerGuid">Resolves the local player's guid to look up in <paramref name="objects"/>.</param>
|
||||
/// <param name="itemInteraction">
|
||||
/// Slice 6.1: the shared retail item interaction orchestrator — its
|
||||
/// <c>ExamineSelectedOrEnterMode</c> is what right-click-examine on a
|
||||
/// shop row routes through, mirroring
|
||||
/// <see cref="ExternalContainerController"/>'s own examine wiring.
|
||||
/// </param>
|
||||
/// <param name="selection">
|
||||
/// Slice 6.2: the canonical <see cref="SelectionState"/> — now the
|
||||
/// AUTHORITY for shop-row selection (row clicks, the F4 auto-select
|
||||
/// fallback, and right-click examine all call
|
||||
/// <see cref="SelectionState.Select"/> directly); this panel only
|
||||
/// listens and reacts, the same way every sibling panel does.
|
||||
/// </param>
|
||||
/// <param name="splitQuantity">
|
||||
/// Slice 6.3: the shared toolbar stack-quantity control (retail
|
||||
/// <c>GenItemHolder::splitSize</c>/<c>maxSplitSize</c>) — the Buy button
|
||||
/// reads the SAME live value <see cref="SelectedObjectController"/>
|
||||
/// seeds/the player adjusts via <c>ItemHolder::GetObjectSplitSize</c>
|
||||
/// (<c>0x00586F00</c>), matching retail's <c>BuySingleItem</c>
|
||||
/// (<c>pc:201674-201681</c>: quantity 1 if <c>_stackSize <= 1</c>,
|
||||
/// else the current slider value).
|
||||
/// </param>
|
||||
/// <param name="datFont">Retail dat font for the category dropdown's button/row labels.</param>
|
||||
/// <param name="debugFont">Fallback debug bitmap font (used when <paramref name="datFont"/> is null).</param>
|
||||
/// <param name="resolveSprite">Dat RenderSurface id → (GL tex handle, px width, px height).</param>
|
||||
|
|
@ -336,6 +395,9 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
ItemInteractionController itemInteraction,
|
||||
SelectionState selection,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||
|
|
@ -347,6 +409,9 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
ArgumentNullException.ThrowIfNull(resolveIcon);
|
||||
ArgumentNullException.ThrowIfNull(objects);
|
||||
ArgumentNullException.ThrowIfNull(playerGuid);
|
||||
ArgumentNullException.ThrowIfNull(itemInteraction);
|
||||
ArgumentNullException.ThrowIfNull(selection);
|
||||
ArgumentNullException.ThrowIfNull(splitQuantity);
|
||||
ArgumentNullException.ThrowIfNull(resolveSprite);
|
||||
|
||||
if (layout.FindElement(ItemsPageId) is not { } itemsPage
|
||||
|
|
@ -374,6 +439,9 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
resolveIcon,
|
||||
objects,
|
||||
playerGuid,
|
||||
itemInteraction,
|
||||
selection,
|
||||
splitQuantity,
|
||||
itemsPage,
|
||||
buyingPage,
|
||||
sellingPage,
|
||||
|
|
@ -517,11 +585,12 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
/// 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).
|
||||
/// Slice 6.2: retail routes the selection through the global
|
||||
/// <c>ACCWeenieObject::selectedID</c>/<c>SetSelectedObject</c>
|
||||
/// (<c>pc:201184</c>, confirmed to be the SAME primitive as the fallback
|
||||
/// select here) — this now calls <see cref="SelectionState.Select"/>
|
||||
/// instead of the retired private field, so the toolbar status bar and
|
||||
/// slider light up for the auto-selected item too.
|
||||
/// </remarks>
|
||||
private void RebuildItemList()
|
||||
{
|
||||
|
|
@ -531,6 +600,7 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
uint maskValue = (uint)activeMask;
|
||||
|
||||
IReadOnlyList<VendorShopItem> items = _vendor.Items;
|
||||
uint? selectedGuid = _selection.SelectedObjectId;
|
||||
bool selectionStillPresent = false;
|
||||
VendorShopItem? firstItem = null;
|
||||
|
||||
|
|
@ -544,7 +614,7 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
|
||||
|
||||
firstItem ??= item;
|
||||
if (item.ItemGuid == _selectedItemGuid) selectionStillPresent = true;
|
||||
if (item.ItemGuid == selectedGuid) selectionStillPresent = true;
|
||||
|
||||
// F5 (Slice 5.4 review): forward the icon underlay/
|
||||
// overlay/effects PublicWeenieDescParser already
|
||||
|
|
@ -562,9 +632,9 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
SlotIndex = _itemList.GetNumUIItems(),
|
||||
};
|
||||
cell.SetItem(item.ItemGuid, icon);
|
||||
cell.Selected = item.ItemGuid == _selectedItemGuid;
|
||||
cell.Selected = item.ItemGuid == selectedGuid;
|
||||
VendorShopItem captured = item;
|
||||
cell.Clicked = () => SelectItem(captured);
|
||||
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
||||
_itemList.AddItem(cell);
|
||||
}
|
||||
}
|
||||
|
|
@ -572,8 +642,10 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
|
||||
if (!selectionStillPresent)
|
||||
{
|
||||
if (firstItem is { } first) SelectItem(first);
|
||||
else ClearSelection();
|
||||
if (firstItem is { } first)
|
||||
_selection.Select(first.ItemGuid, SelectionChangeSource.Vendor);
|
||||
else
|
||||
_selection.Clear(SelectionChangeSource.Vendor);
|
||||
}
|
||||
|
||||
// F7a: unconditional scroll-to-start on every rebuild (retail only
|
||||
|
|
@ -585,7 +657,13 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
/// <summary>
|
||||
/// Port of retail row selection display —
|
||||
/// <c>VendorItemsUI::UpdateItemsUI</c> (<c>0x004C38E0</c>,
|
||||
/// <c>pc:202539-202820</c>).
|
||||
/// <c>pc:202539-202820</c>). Slice 6.2: called ONLY from
|
||||
/// <see cref="OnSelectionTransition"/>, once <paramref name="item"/> has
|
||||
/// already been confirmed to be the globally-selected guid — this method
|
||||
/// no longer writes the selection itself (<see cref="SelectionState"/>
|
||||
/// is the authority; row clicks, the F4 auto-select fallback, and
|
||||
/// right-click examine all call <see cref="SelectionState.Select"/>
|
||||
/// directly and let this method react).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// F2/F3 (Slice 5.4 review): the priced/named QUANTITY is retail's
|
||||
|
|
@ -608,14 +686,12 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
/// retail state pair (see <c>ToolbarController</c>'s
|
||||
/// <c>_useButton.Enabled</c>).
|
||||
/// </remarks>
|
||||
private void SelectItem(VendorShopItem item)
|
||||
private void ApplyItemDisplay(VendorShopItem item)
|
||||
{
|
||||
_selectedItemGuid = item.ItemGuid;
|
||||
|
||||
for (int i = 0; i < _itemList.GetNumUIItems(); i++)
|
||||
{
|
||||
if (_itemList.GetItem(i) is { } cell)
|
||||
cell.Selected = cell.ItemId == _selectedItemGuid;
|
||||
cell.Selected = cell.ItemId == item.ItemGuid;
|
||||
}
|
||||
|
||||
int quantity = VendorSplitSize(item);
|
||||
|
|
@ -635,20 +711,86 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
SetActionButtonsEnabled(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Right-click examine on a shop row — mirrors
|
||||
/// <c>ExternalContainerController.ExamineItem</c>'s "select then
|
||||
/// request appraisal" shape, matching retail's single-selection model
|
||||
/// (research doc §B.2: no dedicated double-click mechanism, plain
|
||||
/// select-then-act). Slice 6.2: routes through
|
||||
/// <see cref="SelectionState.Select"/> — <see cref="OnSelectionTransition"/>
|
||||
/// applies the display update, so this method no longer needs to search
|
||||
/// <see cref="_vendor"/>'s items itself.
|
||||
/// </summary>
|
||||
private void ExamineItem(uint guid)
|
||||
{
|
||||
_selection.Select(guid, SelectionChangeSource.Vendor);
|
||||
_itemInteraction.ExamineSelectedOrEnterMode(guid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6.2: reacts to ANY global selection change, not just ones this
|
||||
/// panel originated — mirrors <c>ExternalContainerController.OnSelectionChanged</c>.
|
||||
/// Shows this panel's own price/name text for the newly-selected guid
|
||||
/// when it is one of <see cref="_vendor"/>'s current items; clears the
|
||||
/// panel's display otherwise (a selection made in some OTHER panel while
|
||||
/// the vendor window is open must not leave stale vendor pricing text
|
||||
/// on screen).
|
||||
/// </summary>
|
||||
private void OnSelectionTransition(SelectionTransition transition)
|
||||
{
|
||||
_ = transition;
|
||||
uint? selected = _selection.SelectedObjectId;
|
||||
if (selected is { } guid)
|
||||
{
|
||||
foreach (VendorShopItem item in _vendor.Items)
|
||||
{
|
||||
if (item.ItemGuid == guid)
|
||||
{
|
||||
ApplyItemDisplay(item);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ClearSelectionDisplay();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6.2: retail's <c>VendorItemsUI::RemoveFromShop</c>
|
||||
/// (<c>pc:202848-202850</c>, <c>0x004c3d4a</c>) clears the global
|
||||
/// selection when a shop item leaves the vendor's list — this mirrors
|
||||
/// that (and, transitively, every other panel's own
|
||||
/// <c>OnObjectRemoved</c>) rather than a vendor-specific "on close, set
|
||||
/// selected to 0" special case. <c>VendorShopItemMaterializer</c>
|
||||
/// removing every materialized item on session close/reset/replace
|
||||
/// (Slice 6.1) is therefore what actually drives "vendor session close
|
||||
/// clears a vendor-owned selection."
|
||||
/// </summary>
|
||||
private void OnObjectRemoved(ClientObject item)
|
||||
{
|
||||
if (_selection.SelectedObjectId == item.ObjectId)
|
||||
{
|
||||
_selection.Clear(
|
||||
SelectionChangeSource.Vendor,
|
||||
SelectionChangeReason.SelectedObjectRemoved);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>).
|
||||
/// (<c>pc:198779-198790</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. Slice 6.2: delegates to
|
||||
/// <see cref="VendorSplitPolicy"/> — the single source of truth for the
|
||||
/// <c>0xDC41CB0</c> mask, also used by <c>SelectedObjectController</c>'s
|
||||
/// REAL seeding path (<c>InteractionRetainedUiComposition</c>'s
|
||||
/// <c>isVendorSplitExempt</c> delegate) so the mask exists in exactly
|
||||
/// one place.
|
||||
/// </summary>
|
||||
private static int VendorSplitSize(VendorShopItem item)
|
||||
{
|
||||
if (((item.ItemType ?? 0u) & SplitExemptMask) != 0u) return 1;
|
||||
return item.DescStackSize is { } size && size > 0 ? size : 1;
|
||||
}
|
||||
private static int VendorSplitSize(VendorShopItem item) =>
|
||||
VendorSplitPolicy.SeedQuantity((ItemType)(item.ItemType ?? 0u), item.DescStackSize);
|
||||
|
||||
/// <summary>
|
||||
/// Cost sentence — <c>VendorItemsUI::UpdateItemsUI</c>'s tail
|
||||
|
|
@ -698,9 +840,8 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
playerTotal.ToString("N0", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
private void ClearSelectionDisplay()
|
||||
{
|
||||
_selectedItemGuid = 0u;
|
||||
for (int i = 0; i < _itemList.GetNumUIItems(); i++)
|
||||
{
|
||||
if (_itemList.GetItem(i) is { } cell)
|
||||
|
|
@ -713,8 +854,65 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
|
||||
private void SetActionButtonsEnabled(bool enabled)
|
||||
{
|
||||
if (_buyButton is not null) _buyButton.Enabled = enabled;
|
||||
_buyEnabledBySelection = enabled;
|
||||
if (_addButton is not null) _addButton.Enabled = enabled;
|
||||
RecomputeBuyButtonEnabled();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6.3: the Buy button's enabled state is the CONJUNCTION of "is
|
||||
/// something selected" (<see cref="_buyEnabledBySelection"/>, set by
|
||||
/// <see cref="SetActionButtonsEnabled"/>) and "is the shared inventory/
|
||||
/// use gate free right now" (<see cref="ItemInteractionController.CanMakeInventoryRequest"/>).
|
||||
/// The Add button (staging, contract decision 6 — unwired this pass)
|
||||
/// stays selection-only. Called on every selection change AND on every
|
||||
/// <see cref="ItemInteractionController.StateChanged"/> tick, so the
|
||||
/// button disables the instant <see cref="ItemInteractionController.TryBuy"/>
|
||||
/// takes its reservation and re-enables on the matching completion —
|
||||
/// no per-frame polling.
|
||||
/// </summary>
|
||||
private void RecomputeBuyButtonEnabled()
|
||||
{
|
||||
if (_buyButton is not null)
|
||||
_buyButton.Enabled = _buyEnabledBySelection && _itemInteraction.CanMakeInventoryRequest;
|
||||
}
|
||||
|
||||
private void OnInteractionStateChanged() => RecomputeBuyButtonEnabled();
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6.3: retail <c>gmVendorUI::BuySingleItem</c> (<c>pc:201661</c>).
|
||||
/// Reads the CURRENT globally-selected shop item and the CURRENT split
|
||||
/// quantity, then dispatches a single-item purchase through the shared
|
||||
/// use/inventory reservation. Client-side affordability/capacity
|
||||
/// pre-checks are deliberately NOT ported (research doc's open question
|
||||
/// 1: the server is authoritative either way and pre-checks are latency/
|
||||
/// UX polish, not correctness — deferred as a fast follow-up if the
|
||||
/// round-trip lag on a refused purchase is noticeable live).
|
||||
/// </summary>
|
||||
private void BuySelectedItem()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } guid)
|
||||
return;
|
||||
|
||||
VendorShopItem? selected = null;
|
||||
foreach (VendorShopItem item in _vendor.Items)
|
||||
{
|
||||
if (item.ItemGuid == guid)
|
||||
{
|
||||
selected = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected is not { } shopItem)
|
||||
return;
|
||||
|
||||
uint stackSize = (uint)Math.Max(shopItem.DescStackSize ?? 1, 1);
|
||||
uint quantity = _splitQuantity.GetObjectSplitSize(shopItem.ItemGuid, guid, stackSize);
|
||||
_itemInteraction.TryBuy(
|
||||
_vendor.VendorId,
|
||||
shopItem.ItemGuid,
|
||||
(int)quantity,
|
||||
_vendor.Profile.AlternateCurrencyWcid);
|
||||
}
|
||||
|
||||
private void ClearContent()
|
||||
|
|
@ -724,7 +922,13 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
_typeMenu.Items = Array.Empty<UiMenu.MenuItem>();
|
||||
_typeMenu.Selected = null;
|
||||
_itemList.Flush();
|
||||
ClearSelection();
|
||||
// Local widget hygiene only — does NOT touch the global selection.
|
||||
// This runs from the constructor (before any vendor is ever open)
|
||||
// and cannot assume whatever SelectionState.SelectedObjectId
|
||||
// currently holds belongs to this panel. The actual "vendor session
|
||||
// close clears a vendor-owned selection" behavior is OnObjectRemoved
|
||||
// reacting to VendorShopItemMaterializer's removal, not this method.
|
||||
ClearSelectionDisplay();
|
||||
}
|
||||
|
||||
private static void SetPlainText(UiText text, string value)
|
||||
|
|
@ -740,12 +944,18 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_vendor.Changed -= OnVendorChanged;
|
||||
_selection.Changed -= OnSelectionTransition;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
RetailTabBinding.SetClick(_itemsTab, null);
|
||||
RetailTabBinding.SetClick(_buyingTab, null);
|
||||
RetailTabBinding.SetClick(_sellingTab, null);
|
||||
_typeMenu.OnSelect = null;
|
||||
_typeMenu.ButtonLabelProvider = null;
|
||||
_itemList.ExamineItemRequested = null;
|
||||
if (_close is not null)
|
||||
_close.OnClick = null;
|
||||
if (_buyButton is not null)
|
||||
_buyButton.OnClick = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,11 @@ public sealed record ToolbarRuntimeBindings(
|
|||
Action<uint> SendQueryHealth,
|
||||
Action<uint> SendQueryItemMana,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<uint, uint, int>? SendPutItemInContainer);
|
||||
Action<uint, uint, int>? SendPutItemInContainer,
|
||||
// Slice 6.2: composed at InteractionRetainedUiComposition from
|
||||
// d.Inventory.Vendor + d.Inventory.Objects — SelectedObjectController's
|
||||
// vendor-owned split-exempt-seed predicate (research doc §C.1).
|
||||
Func<uint, bool> IsVendorSplitExempt);
|
||||
|
||||
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
|
||||
|
||||
|
|
@ -170,7 +174,15 @@ public sealed record AppraisalRuntimeBindings(
|
|||
|
||||
public sealed record VendorRuntimeBindings(
|
||||
VendorState State,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon);
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
// Slice 6.1: shared examine routing — VendorUiController.ExamineItem
|
||||
// calls ItemInteraction.ExamineSelectedOrEnterMode the same way
|
||||
// ExternalContainerRuntimeBindings.ItemInteraction's consumer does.
|
||||
ItemInteractionController ItemInteraction,
|
||||
// Slice 6.2: the canonical selection authority — every sibling binding
|
||||
// record (Radar/Magic/Toolbar/Inventory/ExternalContainer) already
|
||||
// carries this; Vendor was the one outlier (research doc §C.1).
|
||||
SelectionState Selection);
|
||||
|
||||
public sealed record RetailUiRuntimeBindings(
|
||||
UiHost Host,
|
||||
|
|
@ -763,7 +775,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.DefaultFont,
|
||||
StackSplitQuantity,
|
||||
handler => b.Objects.ObjectUpdated += handler,
|
||||
handler => b.Objects.ObjectUpdated -= handler);
|
||||
handler => b.Objects.ObjectUpdated -= handler,
|
||||
b.IsVendorSplitExempt);
|
||||
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowHandle handle = RetailWindowFrame.Mount(
|
||||
|
|
@ -1994,6 +2007,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
b.ResolveIcon,
|
||||
_bindings.Inventory.Objects,
|
||||
_bindings.Inventory.PlayerGuid,
|
||||
b.ItemInteraction,
|
||||
b.Selection,
|
||||
StackSplitQuantity,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.DebugFont,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue