feat(vendor): Slice 6b/6c — move-to-use, buy staging, selling; the vendor arc is functionally complete
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
C1 an out-of-range Use now approaches first via the existing client-predicted BeginApproach (Pickup's far-range shape mirrored; retail's ItemHolder::UseObject @0x00588A80 has no range check and the dispatch stays immediate). C2 Add-to-List stages into the Buying tab via VendorStagingList (RemoveProfileFromList's two shapes, pc:200497-200537), Buy All sends ONE batched 0x005F and flushes staging on send exactly as retail does (SendShopEvent -> Flush, pc:204075-204076 — not UseDone-gated), and X-close over a non-empty staging list shows retail's confirm string recovered verbatim from the binary data segment (0x007b5bd8) through the existing dialog factory. C3 the Selling tab's list is the sole drop target (retail's single IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports InqAcceptability with all rejection strings recovered verbatim from the raw data segment; the sell side prices with BuyPrice (retail's inverted naming: what the vendor PAYS) and 0x0060 carries no trailing currency field, unlike Buy. C4 the status-bar reproduction test PASSES against the production toolbar mount — retail's toolbar shows count + name with the split bar and NO price parenthetical (that figure is the vendor row's own cost text); no code change, the live gate referees. C5 pack order verified correct, untouched. Register: AP-161 narrowed to its two pre-existing cosmetic gaps; AP-162 extended over Buy All; AP-164 (non-sellable bitfield unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal test, bounded), AP-166 (purse text + pending-sell highlight cosmetic) filed. Clean-room complete solution: 11,482 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ab3146ba88
commit
92ea3977b6
18 changed files with 2578 additions and 78 deletions
|
|
@ -67,6 +67,11 @@ public sealed class ItemInteractionController : IDisposable
|
|||
// in world) — the bool return is what lets TryBuy release the
|
||||
// reservation instead of leaking BusyCount forever.
|
||||
private readonly Func<uint, uint, int, uint, bool>? _sendBuy;
|
||||
// Slice 6b/6c: vendorGuid, staged (amount, itemGuid) entries[, alternate
|
||||
// currency] -> true when the wire send actually happened. Same
|
||||
// dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell.
|
||||
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? _sendBuyAll;
|
||||
private readonly Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? _sendSell;
|
||||
private readonly RuntimeInteractionTransactionState _runtimeTransactions;
|
||||
private readonly InventoryTransactionState _transactions;
|
||||
|
||||
|
|
@ -108,7 +113,9 @@ public sealed class ItemInteractionController : IDisposable
|
|||
CombatState? combatState = null,
|
||||
Action<CombatMode>? sendChangeCombatMode = null,
|
||||
Action<uint, ItemUseRequestReservation>? requestUse = null,
|
||||
Func<uint, uint, int, uint, bool>? sendBuy = null)
|
||||
Func<uint, uint, int, uint, bool>? sendBuy = null,
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, uint, bool>? sendBuyAll = null,
|
||||
Func<uint, IReadOnlyList<(int Amount, uint ItemGuid)>, bool>? sendSell = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
|
|
@ -139,6 +146,8 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_systemMessage = systemMessage;
|
||||
_requestUse = requestUse;
|
||||
_sendBuy = sendBuy;
|
||||
_sendBuyAll = sendBuyAll;
|
||||
_sendSell = sendSell;
|
||||
_interactionState = interactionState
|
||||
?? throw new ArgumentNullException(nameof(interactionState));
|
||||
_runtimeTransactions = runtimeTransactions
|
||||
|
|
@ -289,6 +298,90 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6b: retail's "Buy All" button (<c>gmVendorUI::HandleButtonClicks</c>
|
||||
/// case <c>0x100000ca</c>, <c>pc:204011-204079</c>) — the ONE path that
|
||||
/// sends a multi-item Buy in a single wire call. Rides the SAME
|
||||
/// one-request-at-a-time use reservation <see cref="TryBuy"/> does.
|
||||
/// Retail's client-side affordability/pack-capacity pre-checks
|
||||
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported
|
||||
/// here either, for the same reason <see cref="TryBuy"/>'s own doc
|
||||
/// comment already gives for the single-item Buy: the server is
|
||||
/// authoritative either way (register AP-162, extended to this batched
|
||||
/// path rather than filing a second near-duplicate row).
|
||||
/// </summary>
|
||||
public bool TryBuyAll(
|
||||
uint vendorGuid,
|
||||
IReadOnlyList<(int Amount, uint ItemGuid)> items,
|
||||
uint alternateCurrencyId)
|
||||
{
|
||||
if (vendorGuid == 0u || items is null || items.Count == 0 || _sendBuyAll is null)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
||||
ItemUseRequestReservation reservation = BeginUseRequestReservation();
|
||||
bool dispatched;
|
||||
try
|
||||
{
|
||||
dispatched = _sendBuyAll(vendorGuid, items, alternateCurrencyId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
reservation.CancelBeforeDispatch();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!dispatched)
|
||||
{
|
||||
reservation.CancelBeforeDispatch();
|
||||
return false;
|
||||
}
|
||||
|
||||
reservation.MarkDispatched();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6c: retail's Sell path — both "Sell Item"
|
||||
/// (<c>gmVendorUI::HandleButtonClicks</c> case <c>0x100000d2</c>,
|
||||
/// <c>pc:204101-204112</c>, a one-entry list) and "Sell All" (case
|
||||
/// <c>0x100000d3</c>, <c>pc:204113-204129</c>, an n-entry list) reuse
|
||||
/// this single method — retail's own <c>CM_Vendor::Event_Sell</c> has no
|
||||
/// separate single-item opcode the way Buy does. Same reservation dance
|
||||
/// as <see cref="TryBuy"/>/<see cref="TryBuyAll"/>.
|
||||
/// </summary>
|
||||
public bool TrySell(
|
||||
uint vendorGuid,
|
||||
IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
||||
{
|
||||
if (vendorGuid == 0u || items is null || items.Count == 0 || _sendSell is null)
|
||||
return false;
|
||||
if (!EnsureInventoryRequestReady())
|
||||
return false;
|
||||
|
||||
ItemUseRequestReservation reservation = BeginUseRequestReservation();
|
||||
bool dispatched;
|
||||
try
|
||||
{
|
||||
dispatched = _sendSell(vendorGuid, items);
|
||||
}
|
||||
catch
|
||||
{
|
||||
reservation.CancelBeforeDispatch();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!dispatched)
|
||||
{
|
||||
reservation.CancelBeforeDispatch();
|
||||
return false;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ namespace AcDream.App.UI.Layout;
|
|||
/// from a direct field write here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class VendorUiController : IRetainedPanelController
|
||||
public sealed class VendorUiController : IRetainedPanelController, IItemListDragHandler
|
||||
{
|
||||
public const uint LayoutId = 0x21000012u;
|
||||
public const uint RootId = 0x100000B7u;
|
||||
|
|
@ -98,6 +98,22 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
public const uint SellingListId = 0x100000CEu;
|
||||
public const uint SellingScrollbarId = 0x100000CFu;
|
||||
|
||||
// Slice 6b: the "Buying" tab's staging-review buttons
|
||||
// (docs/research/2026-08-08-slice5-vendor-browse-research.md §B.4 D0
|
||||
// tree). All four are optional (nullable) the same way BuyButtonId/
|
||||
// AddButtonId are — a missing authored control degrades gracefully
|
||||
// instead of failing Bind.
|
||||
public const uint BuyItemButtonId = 0x100000C9u;
|
||||
public const uint BuyAllButtonId = 0x100000CAu;
|
||||
public const uint BuyClearItemButtonId = 0x100000CBu;
|
||||
public const uint BuyClearListButtonId = 0x100000CCu;
|
||||
|
||||
// Slice 6c: the "Selling" tab's staging-review buttons — same D0 tree.
|
||||
public const uint SellItemButtonId = 0x100000D2u;
|
||||
public const uint SellAllButtonId = 0x100000D3u;
|
||||
public const uint SellClearItemButtonId = 0x100000D4u;
|
||||
public const uint SellClearListButtonId = 0x100000D5u;
|
||||
|
||||
/// <summary>
|
||||
/// F1 (Slice 5.4 review): the category dropdown's authored popup.
|
||||
/// Retail <c>UIElement_Menu::MakePopup</c> (<c>0x0046D310</c>,
|
||||
|
|
@ -320,14 +336,33 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
private readonly UiButton? _close;
|
||||
private readonly UiButton? _buyButton;
|
||||
private readonly UiButton? _addButton;
|
||||
// Slice 6b: "Buying" tab staging-review buttons.
|
||||
private readonly UiButton? _buyItemButton;
|
||||
private readonly UiButton? _buyAllButton;
|
||||
private readonly UiButton? _buyClearItemButton;
|
||||
private readonly UiButton? _buyClearListButton;
|
||||
// Slice 6c: "Selling" tab staging-review buttons.
|
||||
private readonly UiButton? _sellItemButton;
|
||||
private readonly UiButton? _sellAllButton;
|
||||
private readonly UiButton? _sellClearItemButton;
|
||||
private readonly UiButton? _sellClearListButton;
|
||||
// Slice 6b/6c: retail's m_buyList/m_sellList — see VendorStagingList's doc comment.
|
||||
private readonly VendorStagingList _buyStaging = new();
|
||||
private readonly VendorStagingList _sellStaging = new();
|
||||
private readonly RetailDialogFactory? _dialogs;
|
||||
private readonly Action<string>? _systemMessage;
|
||||
|
||||
private readonly List<(string Label, ItemType Mask)> _presentCategories = new();
|
||||
private int _selectedCategoryIndex = -1;
|
||||
// Slice 6.3: 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).
|
||||
// Slice 6.3/6b: tracks "does the CURRENT selection permit Buy/Add"
|
||||
// separately from "is a Buy request currently in flight"
|
||||
// (RecomputeBuyButtonEnabled combines both for the Buy AND Add buttons —
|
||||
// Add to List is now wired (Slice 6b), no longer permanently disabled).
|
||||
private bool _buyEnabledBySelection;
|
||||
// Slice 6b: gmVendorUI::m_curDialogContext — a nonzero value means the
|
||||
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6
|
||||
// case only opens a NEW one when this is 0 (pc:204155).
|
||||
private uint _closeConfirmContext;
|
||||
private bool _disposed;
|
||||
|
||||
private VendorUiController(
|
||||
|
|
@ -357,6 +392,16 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
UiButton? close,
|
||||
UiButton? buyButton,
|
||||
UiButton? addButton,
|
||||
UiButton? buyItemButton,
|
||||
UiButton? buyAllButton,
|
||||
UiButton? buyClearItemButton,
|
||||
UiButton? buyClearListButton,
|
||||
UiButton? sellItemButton,
|
||||
UiButton? sellAllButton,
|
||||
UiButton? sellClearItemButton,
|
||||
UiButton? sellClearListButton,
|
||||
RetailDialogFactory? dialogs,
|
||||
Action<string>? systemMessage,
|
||||
UiDatFont? datFont,
|
||||
BitmapFont? debugFont,
|
||||
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||
|
|
@ -387,6 +432,16 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
_close = close;
|
||||
_buyButton = buyButton;
|
||||
_addButton = addButton;
|
||||
_buyItemButton = buyItemButton;
|
||||
_buyAllButton = buyAllButton;
|
||||
_buyClearItemButton = buyClearItemButton;
|
||||
_buyClearListButton = buyClearListButton;
|
||||
_sellItemButton = sellItemButton;
|
||||
_sellAllButton = sellAllButton;
|
||||
_sellClearItemButton = sellClearItemButton;
|
||||
_sellClearListButton = sellClearListButton;
|
||||
_dialogs = dialogs;
|
||||
_systemMessage = systemMessage;
|
||||
|
||||
_itemList.Columns = 1;
|
||||
_itemList.SingleRow = true;
|
||||
|
|
@ -440,6 +495,11 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
sellingScrollbar.Model = _sellingList.Scroll;
|
||||
sellingScrollbar.Horizontal = true;
|
||||
}
|
||||
// Slice 6c: gmVendorUI::HandleDropRelease routes EVERY drop in the
|
||||
// whole panel through one IsAncestorOfMe(target, m_sellShopList)
|
||||
// gate (pc:204229-204246) — the Selling tab's list is the ONLY drop
|
||||
// target. UiItemList.RegisterDragHandler is the structural analogue.
|
||||
_sellingList?.RegisterDragHandler(this);
|
||||
|
||||
// F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers
|
||||
// (UiMenu draws nothing without SpriteResolve — see the popup
|
||||
|
|
@ -502,6 +562,31 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
// staging list required (research doc §B.1).
|
||||
if (_buyButton is not null)
|
||||
_buyButton.OnClick = BuySelectedItem;
|
||||
// Slice 6b: 0x100000c3 — VendorItemsUI::AddToBuyList (research doc
|
||||
// §Q3). No longer permanently disabled (AP-161 F8 residual closes).
|
||||
if (_addButton is not null)
|
||||
_addButton.OnClick = AddSelectedToBuyList;
|
||||
// Slice 6b: the "Buying" tab's four staging buttons.
|
||||
if (_buyItemButton is not null)
|
||||
_buyItemButton.OnClick = BuyItemButtonPressed;
|
||||
if (_buyAllButton is not null)
|
||||
_buyAllButton.OnClick = BuyAllButtonPressed;
|
||||
if (_buyClearItemButton is not null)
|
||||
_buyClearItemButton.OnClick = BuyClearItemButtonPressed;
|
||||
if (_buyClearListButton is not null)
|
||||
_buyClearListButton.OnClick = () => _buyStaging.Clear();
|
||||
// Slice 6c: the "Selling" tab's four staging buttons.
|
||||
if (_sellItemButton is not null)
|
||||
_sellItemButton.OnClick = SellItemButtonPressed;
|
||||
if (_sellAllButton is not null)
|
||||
_sellAllButton.OnClick = SellAllButtonPressed;
|
||||
if (_sellClearItemButton is not null)
|
||||
_sellClearItemButton.OnClick = SellClearItemButtonPressed;
|
||||
if (_sellClearListButton is not null)
|
||||
_sellClearListButton.OnClick = () => _sellStaging.Clear();
|
||||
|
||||
_buyStaging.Changed += RebuildBuyingList;
|
||||
_sellStaging.Changed += RebuildSellingList;
|
||||
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
ClearContent();
|
||||
|
|
@ -595,7 +680,15 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||
uint emptySlotSprite = 0u,
|
||||
uint buyingEmptySlotSprite = 0u,
|
||||
uint sellingEmptySlotSprite = 0u)
|
||||
uint sellingEmptySlotSprite = 0u,
|
||||
// Slice 6b: the panel's own X-close confirmation when staging is
|
||||
// non-empty (Q3's close-button finding). Optional — absent gracefully
|
||||
// degrades the close gate to a plain hide (see CloseButtonPressed).
|
||||
RetailDialogFactory? dialogs = null,
|
||||
// Slice 6b/6c: InqAcceptability rejection strings + retail's two
|
||||
// Buy-All affordability/capacity transient errors (deliberately not
|
||||
// ported, register AP-162) share this sink.
|
||||
Action<string>? systemMessage = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(vendor);
|
||||
|
|
@ -632,6 +725,15 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
UiScrollbar? buyingScrollbar = layout.FindElement(BuyingScrollbarId) as UiScrollbar;
|
||||
UiItemList? sellingList = layout.FindElement(SellingListId) as UiItemList;
|
||||
UiScrollbar? sellingScrollbar = layout.FindElement(SellingScrollbarId) as UiScrollbar;
|
||||
// Slice 6b/6c: the eight staging-review buttons — all optional.
|
||||
UiButton? buyItemButton = layout.FindElement(BuyItemButtonId) as UiButton;
|
||||
UiButton? buyAllButton = layout.FindElement(BuyAllButtonId) as UiButton;
|
||||
UiButton? buyClearItemButton = layout.FindElement(BuyClearItemButtonId) as UiButton;
|
||||
UiButton? buyClearListButton = layout.FindElement(BuyClearListButtonId) as UiButton;
|
||||
UiButton? sellItemButton = layout.FindElement(SellItemButtonId) as UiButton;
|
||||
UiButton? sellAllButton = layout.FindElement(SellAllButtonId) as UiButton;
|
||||
UiButton? sellClearItemButton = layout.FindElement(SellClearItemButtonId) as UiButton;
|
||||
UiButton? sellClearListButton = layout.FindElement(SellClearListButtonId) as UiButton;
|
||||
|
||||
return new VendorUiController(
|
||||
vendor,
|
||||
|
|
@ -660,6 +762,16 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
close,
|
||||
buyButton,
|
||||
addButton,
|
||||
buyItemButton,
|
||||
buyAllButton,
|
||||
buyClearItemButton,
|
||||
buyClearListButton,
|
||||
sellItemButton,
|
||||
sellAllButton,
|
||||
sellClearItemButton,
|
||||
sellClearListButton,
|
||||
dialogs,
|
||||
systemMessage,
|
||||
datFont,
|
||||
debugFont,
|
||||
resolveSprite,
|
||||
|
|
@ -695,6 +807,11 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
// resetting here is sufficient — RebuildCategories' existing
|
||||
// clamp (selected<0 -> 0) then lands on the new vendor's
|
||||
// first present category, matching retail.
|
||||
// Slice 6b/6c: a different vendor's staged items are for a
|
||||
// shop the player is no longer looking at — clear both
|
||||
// staging lists the same way the category selection resets.
|
||||
_buyStaging.Clear();
|
||||
_sellStaging.Clear();
|
||||
_selectedCategoryIndex = -1;
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
RebuildCategories();
|
||||
|
|
@ -703,13 +820,24 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
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.
|
||||
// matching retail's sameVendor==1 path. Staging is NOT
|
||||
// cleared here: a Refreshed transition follows a Buy All/
|
||||
// Sell All send, which already flushed its own list
|
||||
// synchronously at send time (retail: PackableList::Flush
|
||||
// right after SendShopEvent, pc:204076/label_4c560a) — by
|
||||
// the time this fires the relevant list is already empty in
|
||||
// the normal flow, and the OTHER (untouched) list must
|
||||
// survive a refresh triggered by its sibling.
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
RebuildCategories();
|
||||
_window.Show();
|
||||
break;
|
||||
case VendorStateTransitionKind.Closed:
|
||||
case VendorStateTransitionKind.Reset:
|
||||
// Slice 6b/6c: session close/teardown clears staging WITH
|
||||
// the session (contract's C2/C3 close semantics).
|
||||
_buyStaging.Clear();
|
||||
_sellStaging.Clear();
|
||||
ClearContent();
|
||||
ShowTab(VendorPanelTab.Items);
|
||||
_window.Hide();
|
||||
|
|
@ -1128,39 +1256,37 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// F8 (Slice 6 review): "Add to List" (staging into the "Buying" tab,
|
||||
/// contract decision 6) has NO wired <c>OnClick</c> at all — before this
|
||||
/// fix it enabled on selection exactly like Buy, so it read as a working
|
||||
/// affordance that silently did nothing on click, which is worse than a
|
||||
/// disabled button (a disabled Add correctly signals "not available
|
||||
/// yet"; an enabled dead Add signals a bug). Permanently disabled until
|
||||
/// the "Buying" tab's staging list is actually implemented — see the
|
||||
/// register, AP-161.
|
||||
/// Slice 6b (AP-161 F8 residual closes): "Add to List" now enables with
|
||||
/// selection exactly like Buy — the "Buying" tab's staging list is
|
||||
/// implemented, so an enabled Add is no longer a dead affordance.
|
||||
/// </summary>
|
||||
private void SetActionButtonsEnabled(bool enabled)
|
||||
{
|
||||
_buyEnabledBySelection = enabled;
|
||||
if (_addButton is not null) _addButton.Enabled = false;
|
||||
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) is
|
||||
/// PERMANENTLY disabled instead (F8, Slice 6 review) — see
|
||||
/// <see cref="SetActionButtonsEnabled"/>. 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.
|
||||
/// Slice 6.3/6b: the Buy AND Add buttons' enabled state is the
|
||||
/// CONJUNCTION of "is something selected" (<see cref="_buyEnabledBySelection"/>,
|
||||
/// set by <see cref="SetActionButtonsEnabled"/>) and "is the shared
|
||||
/// inventory/use gate free right now"
|
||||
/// (<see cref="ItemInteractionController.CanMakeInventoryRequest"/>).
|
||||
/// Add is selection-gated only (retail's <c>AddToBuyList</c> sends
|
||||
/// nothing to the server — no busy-gate reason to disable it while a
|
||||
/// Buy/Sell is in flight), but sharing this recompute keeps both buttons
|
||||
/// consistent with a single call site. Called on every selection change
|
||||
/// AND on every <see cref="ItemInteractionController.StateChanged"/>
|
||||
/// tick, so Buy disables the instant <see cref="ItemInteractionController.TryBuy"/>
|
||||
/// takes its reservation and re-enables on the matching completion — no
|
||||
/// per-frame polling.
|
||||
/// </summary>
|
||||
private void RecomputeBuyButtonEnabled()
|
||||
{
|
||||
if (_buyButton is not null)
|
||||
_buyButton.Enabled = _buyEnabledBySelection && _itemInteraction.CanMakeInventoryRequest;
|
||||
if (_addButton is not null)
|
||||
_addButton.Enabled = _buyEnabledBySelection;
|
||||
}
|
||||
|
||||
private void OnInteractionStateChanged() => RecomputeBuyButtonEnabled();
|
||||
|
|
@ -1203,33 +1329,398 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
_vendor.Profile.AlternateCurrencyWcid);
|
||||
}
|
||||
|
||||
private bool TryFindShopItem(uint guid, out VendorShopItem shopItem)
|
||||
{
|
||||
foreach (VendorShopItem item in _vendor.Items)
|
||||
{
|
||||
if (item.ItemGuid == guid)
|
||||
{
|
||||
shopItem = item;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
shopItem = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G4 (vendor gate finding): port of retail's close/pushpin button
|
||||
/// handler — <c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c>
|
||||
/// case (<c>pc:204147-204182</c>). Retail branches on whether the
|
||||
/// Slice 6b: Items-tab "Add to List" — <c>gmVendorUI::HandleButtonClicks</c>
|
||||
/// case <c>0x100000c3</c> (<c>pc:203970-203988</c>). Stages the
|
||||
/// globally-selected shop item at <see cref="ResolveBuyQuantity"/> — the
|
||||
/// SAME quantity computation the display and Buy button already share
|
||||
/// (F2) — into the "Buying" tab's list. Sends NOTHING to the server
|
||||
/// (<c>VendorItemsUI::AddToBuyList</c> is purely client-local).
|
||||
/// </summary>
|
||||
private void AddSelectedToBuyList()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } guid || !TryFindShopItem(guid, out VendorShopItem shopItem))
|
||||
return;
|
||||
|
||||
uint quantity = ResolveBuyQuantity(shopItem);
|
||||
_buyStaging.Add(shopItem.ItemGuid, (int)quantity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>Buy Item</c>/<c>Clear Item</c> (Buying tab) shared
|
||||
/// removal-amount rule (<c>pc:203996</c>/<c>204086</c>): stackable -> -1
|
||||
/// (full removal), else 1. Retail tests the item's own
|
||||
/// <c>pwd._maxStackSize</c> (the item TYPE's stack ceiling) — a field
|
||||
/// <see cref="VendorShopItem"/> does not carry on the wire today. This
|
||||
/// substitutes <see cref="VendorShopItem.DescStackSize"/> (the item's
|
||||
/// CURRENT authored stack depth, already threaded through for pricing)
|
||||
/// as the stackability test instead; the two agree for every case that
|
||||
/// matters in practice (<c>DescStackSize <= 1</c> implies
|
||||
/// <c>MaxStackSize <= 1</c>) and disagree only for a vendor stocking a
|
||||
/// single unit of an otherwise-stackable item TYPE, where the worst case
|
||||
/// is a staged entry decrementing by one instead of clearing outright —
|
||||
/// a minor UI residue, not a money/wire-safety issue. See the register.
|
||||
/// </summary>
|
||||
private static int BuyStagingRemovalAmount(VendorShopItem item) =>
|
||||
(item.DescStackSize ?? 1) > 1 ? -1 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6b: "Buying" tab's "Buy Item" — retail case <c>0x100000c9</c>
|
||||
/// (<c>pc:203989-204010</c>). Reuses the SAME immediate single-item
|
||||
/// <c>BuySingleItem</c> path (<see cref="ItemInteractionController.TryBuy"/>)
|
||||
/// the Items tab's own Buy button uses — reads the GLOBAL slider
|
||||
/// quantity, not the staged entry's own quantity — then on a successful
|
||||
/// dispatch removes the staged entry (<see cref="BuyStagingRemovalAmount"/>).
|
||||
/// </summary>
|
||||
private void BuyItemButtonPressed()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } guid || !TryFindShopItem(guid, out VendorShopItem shopItem))
|
||||
return;
|
||||
|
||||
uint quantity = ResolveBuyQuantity(shopItem);
|
||||
if (_itemInteraction.TryBuy(
|
||||
_vendor.VendorId,
|
||||
shopItem.ItemGuid,
|
||||
(int)quantity,
|
||||
_vendor.Profile.AlternateCurrencyWcid))
|
||||
{
|
||||
_buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6b: "Buying" tab's "Buy All" — retail case <c>0x100000ca</c>
|
||||
/// (<c>pc:204011-204079</c>). Sends every staged entry as ONE batched
|
||||
/// Buy (<see cref="ItemInteractionController.TryBuyAll"/>). Retail's
|
||||
/// client-side affordability/pack-capacity pre-checks
|
||||
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported —
|
||||
/// see <c>TryBuyAll</c>'s own doc comment (register AP-162, extended
|
||||
/// rather than duplicated). On a successful DISPATCH the whole staged
|
||||
/// list is flushed UNCONDITIONALLY and immediately, matching retail's
|
||||
/// literal order: <c>SendShopEvent(...)</c> then
|
||||
/// <c>PackableList::Flush(&m_buyList)</c> (<c>pc:204075-204076</c>) —
|
||||
/// the clear happens right after the send, not gated on the eventual
|
||||
/// server response/UseDone.
|
||||
/// </summary>
|
||||
private void BuyAllButtonPressed()
|
||||
{
|
||||
if (_buyStaging.IsEmpty)
|
||||
return;
|
||||
|
||||
var items = new List<(int Amount, uint ItemGuid)>(_buyStaging.Entries.Count);
|
||||
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
||||
items.Add((entry.Quantity, entry.ItemGuid));
|
||||
|
||||
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, _vendor.Profile.AlternateCurrencyWcid))
|
||||
_buyStaging.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6b: "Buying" tab's "Clear Item" — retail case <c>0x100000cb</c>
|
||||
/// (<c>pc:204080-204094</c>). The EXACT SAME removal call "Buy Item"
|
||||
/// makes (<c>label_4c52ea</c>) but WITHOUT buying first — pure removal,
|
||||
/// no transaction.
|
||||
/// </summary>
|
||||
private void BuyClearItemButtonPressed()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } guid)
|
||||
return;
|
||||
|
||||
int amount = TryFindShopItem(guid, out VendorShopItem shopItem)
|
||||
? BuyStagingRemovalAmount(shopItem)
|
||||
: -1;
|
||||
_buyStaging.Remove(guid, amount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6c: "Selling" tab's "Sell Item" — retail case <c>0x100000d2</c>
|
||||
/// (<c>pc:204101-204112</c>). Sells the globally-selected staged item
|
||||
/// immediately (a one-entry <see cref="ItemInteractionController.TrySell"/>
|
||||
/// list using the entry's OWN staged quantity — Sell has no separate
|
||||
/// global-slider read the way Buy does), then on a successful dispatch
|
||||
/// removes the ENTIRE staged entry unconditionally — retail always
|
||||
/// passes <c>0xffffffff</c> here (<c>pc:204108</c>), unlike the Buy
|
||||
/// side's stackable-conditional amount.
|
||||
/// </summary>
|
||||
private void SellItemButtonPressed()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } guid
|
||||
|| !_sellStaging.TryGet(guid, out VendorStagingEntry entry))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (entry.Quantity, guid) }))
|
||||
_sellStaging.Remove(guid, -1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6c: "Selling" tab's "Sell All" — retail case <c>0x100000d3</c>
|
||||
/// (<c>pc:204113-204129</c>). Sends every staged entry as ONE batched
|
||||
/// Sell. On a successful dispatch the whole staged list is flushed
|
||||
/// unconditionally and immediately (the shared tail <c>label_4c560a</c>
|
||||
/// also reached by "Clear List" — <c>FlushSellListSellState</c> +
|
||||
/// <c>PackableList::Flush(&m_sellList)</c>), the same optimistic
|
||||
/// clear-right-after-send shape as <see cref="BuyAllButtonPressed"/>.
|
||||
/// </summary>
|
||||
private void SellAllButtonPressed()
|
||||
{
|
||||
if (_sellStaging.IsEmpty)
|
||||
return;
|
||||
|
||||
var items = new List<(int Amount, uint ItemGuid)>(_sellStaging.Entries.Count);
|
||||
foreach (VendorStagingEntry entry in _sellStaging.Entries)
|
||||
items.Add((entry.Quantity, entry.ItemGuid));
|
||||
|
||||
if (_itemInteraction.TrySell(_vendor.VendorId, items))
|
||||
_sellStaging.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6c: "Selling" tab's "Clear Item" — retail case <c>0x100000d4</c>
|
||||
/// (<c>pc:204131-204137</c>): always a full removal (<c>0xffffffff</c>),
|
||||
/// no transaction. Retail also clears the item's "pending sell"
|
||||
/// highlight in the player's own inventory panel
|
||||
/// (<c>VendorItemSetSellState</c>) — that cross-panel highlight is a
|
||||
/// deliberately unported cosmetic (see the register).
|
||||
/// </summary>
|
||||
private void SellClearItemButtonPressed()
|
||||
{
|
||||
if (_selection.SelectedObjectId is not { } guid)
|
||||
return;
|
||||
_sellStaging.Remove(guid, -1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders <see cref="_buyStaging"/> into the "Buying" tab's own item
|
||||
/// strip — icon cells identical in shape to the Items list's own (the
|
||||
/// SAME <c>0x1000033A</c> cell-template prototype, per the G2 vendor-gate
|
||||
/// finding), selectable so <see cref="BuyItemButtonPressed"/>/
|
||||
/// <see cref="BuyClearItemButtonPressed"/> can read the globally-selected
|
||||
/// guid. A staged guid that has left <see cref="_vendor"/>'s current shop
|
||||
/// list (a rare race, e.g. mid-refresh) is skipped rather than crashing.
|
||||
/// </summary>
|
||||
private void RebuildBuyingList()
|
||||
{
|
||||
if (_buyingList is not { } list)
|
||||
return;
|
||||
|
||||
uint? selectedGuid = _selection.SelectedObjectId;
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
||||
{
|
||||
if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem shopItem))
|
||||
continue;
|
||||
|
||||
uint icon = _resolveIcon(
|
||||
(ItemType)(shopItem.ItemType ?? 0u),
|
||||
shopItem.IconId,
|
||||
shopItem.IconUnderlayId,
|
||||
shopItem.IconOverlayId,
|
||||
shopItem.Effects);
|
||||
var cell = new UiItemSlot
|
||||
{
|
||||
SpriteResolve = list.SpriteResolve,
|
||||
SlotIndex = list.GetNumUIItems(),
|
||||
AllowDragSource = false,
|
||||
};
|
||||
cell.SetItem(shopItem.ItemGuid, icon);
|
||||
cell.Selected = shopItem.ItemGuid == selectedGuid;
|
||||
VendorShopItem captured = shopItem;
|
||||
cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders <see cref="_sellStaging"/> into the "Selling" tab's own item
|
||||
/// strip. Unlike <see cref="RebuildBuyingList"/>, a staged sell entry's
|
||||
/// icon/type data comes from <see cref="_objects"/> (the PLAYER's own
|
||||
/// pack item), not <see cref="_vendor"/>'s shop list — the item was
|
||||
/// dragged FROM the player's inventory, never authored as vendor stock.
|
||||
/// </summary>
|
||||
private void RebuildSellingList()
|
||||
{
|
||||
if (_sellingList is not { } list)
|
||||
return;
|
||||
|
||||
uint? selectedGuid = _selection.SelectedObjectId;
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
foreach (VendorStagingEntry entry in _sellStaging.Entries)
|
||||
{
|
||||
if (_objects.Get(entry.ItemGuid) is not { } item)
|
||||
continue;
|
||||
|
||||
uint icon = _resolveIcon(
|
||||
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||
var cell = new UiItemSlot
|
||||
{
|
||||
SpriteResolve = list.SpriteResolve,
|
||||
SlotIndex = list.GetNumUIItems(),
|
||||
AllowDragSource = false,
|
||||
};
|
||||
cell.SetItem(item.ObjectId, icon);
|
||||
cell.Selected = item.ObjectId == selectedGuid;
|
||||
uint captured = item.ObjectId;
|
||||
cell.Clicked = () => _selection.Select(captured, SelectionChangeSource.Vendor);
|
||||
list.AddItem(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── IItemListDragHandler (Slice 6c: the Selling tab's list is the SOLE
|
||||
// drop target — gmVendorUI::HandleDropRelease routes every drag release
|
||||
// anywhere in the panel through one IsAncestorOfMe(target, m_sellShopList)
|
||||
// gate, pc:204229-204246) ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The Selling list never sources a drag of its own — every staged cell
|
||||
/// sets <c>AllowDragSource = false</c> (F3, Slice 6 review), the same
|
||||
/// non-drag-source convention every vendor row uses — so
|
||||
/// <see cref="UiItemSlot"/>'s drag-lift dispatch (which routes to the
|
||||
/// SOURCE list's own registered handler) can never actually reach this
|
||||
/// method in practice. Implemented as a no-op for interface completeness.
|
||||
/// </summary>
|
||||
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
|
||||
{
|
||||
}
|
||||
|
||||
public ItemDragAcceptance OnDragOver(
|
||||
UiItemList targetList,
|
||||
UiItemSlot targetCell,
|
||||
ItemDragPayload payload)
|
||||
{
|
||||
if (!ReferenceEquals(targetList, _sellingList) || payload.ObjId == 0u)
|
||||
return ItemDragAcceptance.Reject;
|
||||
|
||||
return EvaluateSellAcceptability(payload.ObjId, out _) == VendorSellRejection.None
|
||||
? ItemDragAcceptance.Accept
|
||||
: ItemDragAcceptance.Reject;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>VendorSellUI::AcceptDragObject</c> (<c>pc:203866-203905</c>,
|
||||
/// the non-silent release path that calls <c>DragItemAcceptable</c> with
|
||||
/// <c>silent=0</c>, showing a rejection string) chained into
|
||||
/// <c>VendorSellUI::AddItemToSell</c> (<c>pc:203546-203567</c>) on
|
||||
/// success: auto-switch to the "Selling" tab, globally select the
|
||||
/// dropped item, stage it. Purely client-local — sends nothing to the
|
||||
/// server, matching the Buying tab's "Add to List".
|
||||
/// </summary>
|
||||
public void HandleDropRelease(
|
||||
UiItemList targetList,
|
||||
UiItemSlot targetCell,
|
||||
ItemDragPayload payload)
|
||||
{
|
||||
if (!ReferenceEquals(targetList, _sellingList) || payload.ObjId == 0u)
|
||||
return;
|
||||
|
||||
VendorSellRejection rejection = EvaluateSellAcceptability(payload.ObjId, out int quantity);
|
||||
if (rejection != VendorSellRejection.None)
|
||||
{
|
||||
if (VendorSellAcceptability.MessageFor(rejection) is { } message)
|
||||
_systemMessage?.Invoke(message);
|
||||
return;
|
||||
}
|
||||
|
||||
ShowTab(VendorPanelTab.Selling);
|
||||
_selection.Select(payload.ObjId, SelectionChangeSource.Vendor);
|
||||
_sellStaging.Add(payload.ObjId, quantity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared drag-over/drop gate — <see cref="VendorSellAcceptability.Evaluate"/>
|
||||
/// fed from the dragged item's own <see cref="ClientObject"/> state and
|
||||
/// the open vendor's <see cref="VendorShopProfile"/> merchandise terms.
|
||||
/// <paramref name="quantity"/> is the staged quantity a successful drop
|
||||
/// would use: 1 for a non-stack item, else the LIVE split-quantity
|
||||
/// slider value for the dragged item — the SAME read
|
||||
/// <c>ExternalContainerController.HandleDropRelease</c> already uses
|
||||
/// (<c>StackSplitQuantityState.GetObjectSplitSize</c> only returns the
|
||||
/// slider value for the CURRENTLY selected item; both
|
||||
/// <c>InventoryController</c> and <c>PaperdollController</c> already
|
||||
/// select the dragged item on LIFT — <c>ItemList_BeginDrag @
|
||||
/// 0x004E32D0</c> — so by the time a drop reaches here the dragged item
|
||||
/// is already the global selection in the realistic flow). The Slice
|
||||
/// 6b/6c research doc's Q4 section flags this exact quantity source as
|
||||
/// an inferred analogy to the Buying tab's <c>AddToBuyList</c>, not a
|
||||
/// byte-verified citation for the Selling side specifically — see
|
||||
/// <c>VendorStagingList.Add</c>'s own doc comment.
|
||||
/// </summary>
|
||||
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
|
||||
{
|
||||
quantity = 1;
|
||||
if (_objects.Get(itemGuid) is not { } item)
|
||||
return VendorSellRejection.WrongType;
|
||||
|
||||
bool ownedByPlayer = _itemInteraction.IsOwnedByPlayer(itemGuid);
|
||||
int containedItemCount = _objects.GetContents(itemGuid).Count;
|
||||
int perUnitValue = VendorPricing.PerUnitValue(item.Value, item.StackSize);
|
||||
|
||||
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
|
||||
ownedByPlayer,
|
||||
containedItemCount,
|
||||
(uint)item.Type,
|
||||
perUnitValue,
|
||||
_vendor.Profile.MerchandiseItemTypes,
|
||||
_vendor.Profile.MerchandiseMinValue,
|
||||
_vendor.Profile.MerchandiseMaxValue);
|
||||
|
||||
if (rejection == VendorSellRejection.None)
|
||||
{
|
||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||
quantity = fullStack > 1
|
||||
? (int)_splitQuantity.GetObjectSplitSize(itemGuid, _selection.SelectedObjectId ?? 0u, fullStack)
|
||||
: 1;
|
||||
}
|
||||
return rejection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G4/Slice 6b: port of retail's close/pushpin button handler —
|
||||
/// <c>gmVendorUI::HandleButtonClicks</c>'s <c>0x100000d6</c> case
|
||||
/// (<c>pc:204147-204182</c>). Retail branches on whether the
|
||||
/// Buying/Selling staging lists (<c>m_buyList</c>/<c>m_sellList</c>) hold
|
||||
/// anything uncommitted: with nothing pending it calls ONLY
|
||||
/// <c>this->vtable->SetVisible(0)</c> — a plain window hide, NOT
|
||||
/// <c>gmVendorUI::CloseVendor</c> (<c>pc:202080</c>, the range-watcher-
|
||||
/// unregister/session-teardown function <see cref="VendorState.Close"/>
|
||||
/// ports). Only when something IS pending does retail show a
|
||||
/// confirmation dialog ("You have not completed all transactions...")
|
||||
/// whose Yes callback (<c>gmVendorUI::CloseVendorDialogCallback</c>,
|
||||
/// <c>pc:202104-202166</c>) is what actually reaches
|
||||
/// <c>CM_Vendor::SendNotice_CloseVendor</c> — itself an internal
|
||||
/// notice-bus fanout to local UI listeners, not a network send (see the
|
||||
/// class doc's A.4 citation: retail's close path never puts anything on
|
||||
/// the wire either way).
|
||||
/// <para>
|
||||
/// This controller's staging lists are ALWAYS empty (Slice 6 territory —
|
||||
/// the "Buying"/"Selling" tabs render but are never populated, see the
|
||||
/// class doc's "Three tabs, not two" note), so retail's
|
||||
/// <c>m_buyList.head == 0 && m_sellList.head == 0</c> condition
|
||||
/// is vacuously true for every close today — the confirmation-dialog
|
||||
/// branch has no reachable case yet and is deliberately not ported;
|
||||
/// revisit once staging lands.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ports). Only when something IS pending, and only when no confirmation
|
||||
/// is already up (<c>gmVendorUI::m_curDialogContext == 0</c>,
|
||||
/// <c>pc:204155</c>), does retail show the confirmation dialog ("You have
|
||||
/// not completed all transactions. Are you sure you want to leave this
|
||||
/// vendor" — the exact string, read from the decompiled binary's data
|
||||
/// segment at <c>0x007b5bd8</c>, resolving the Slice 6b/6c research doc's
|
||||
/// truncated "…" citation). Its Yes callback
|
||||
/// (<c>gmVendorUI::CloseVendorDialogCallback</c>, <c>pc:202104-202166</c>)
|
||||
/// reaches <c>CM_Vendor::SendNotice_CloseVendor</c> — an internal
|
||||
/// notice-bus fanout, not a network send (class doc's A.4 citation) —
|
||||
/// and nothing in that call chain touches <c>m_buyList</c>/<c>m_sellList</c>,
|
||||
/// so staging survives a "Yes, leave anyway" exactly like retail: the
|
||||
/// window hides, the staged items are still there next time the vendor
|
||||
/// is reopened. A "No" (or dismissing the dialog) leaves the window open
|
||||
/// with staging untouched.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Behavior change from the prior port.</b> This button used to call
|
||||
/// <see cref="VendorState.Close"/> directly — a full session teardown
|
||||
/// (VendorId/Profile/Items cleared, every materialized shop item
|
||||
|
|
@ -1245,9 +1736,32 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
/// refresh-in-place path (<see cref="VendorStateTransitionKind.Refreshed"/>,
|
||||
/// which preserves the player's category selection) instead of a full
|
||||
/// from-scratch <see cref="VendorStateTransitionKind.Opened"/> reopen.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void CloseButtonPressed() => _window.Hide();
|
||||
/// </remarks>
|
||||
private const string CloseConfirmationMessage =
|
||||
"You have not completed all transactions. Are you sure you want to leave this vendor";
|
||||
|
||||
private void CloseButtonPressed()
|
||||
{
|
||||
if (_buyStaging.IsEmpty && _sellStaging.IsEmpty)
|
||||
{
|
||||
_window.Hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// No dialog infra wired (a minimal test harness), or a confirmation
|
||||
// is already up — retail's m_curDialogContext==0 gate (pc:204155).
|
||||
if (_dialogs is null || _closeConfirmContext != 0u)
|
||||
return;
|
||||
|
||||
_closeConfirmContext = _dialogs.MakeDialog(
|
||||
RetailDialogData.Confirmation(CloseConfirmationMessage),
|
||||
result =>
|
||||
{
|
||||
_closeConfirmContext = 0u;
|
||||
if (result.GetBoolean(RetailDialogProperty.ConfirmationResult))
|
||||
_window.Hide();
|
||||
});
|
||||
}
|
||||
|
||||
private void ClearContent()
|
||||
{
|
||||
|
|
@ -1315,6 +1829,13 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
||||
_buyStaging.Changed -= RebuildBuyingList;
|
||||
_sellStaging.Changed -= RebuildSellingList;
|
||||
if (_closeConfirmContext != 0u)
|
||||
{
|
||||
_dialogs?.CloseDialog(_closeConfirmContext);
|
||||
_closeConfirmContext = 0u;
|
||||
}
|
||||
RetailTabBinding.SetClick(_itemsTab, null);
|
||||
RetailTabBinding.SetClick(_buyingTab, null);
|
||||
RetailTabBinding.SetClick(_sellingTab, null);
|
||||
|
|
@ -1325,5 +1846,23 @@ public sealed class VendorUiController : IRetainedPanelController
|
|||
_close.OnClick = null;
|
||||
if (_buyButton is not null)
|
||||
_buyButton.OnClick = null;
|
||||
if (_addButton is not null)
|
||||
_addButton.OnClick = null;
|
||||
if (_buyItemButton is not null)
|
||||
_buyItemButton.OnClick = null;
|
||||
if (_buyAllButton is not null)
|
||||
_buyAllButton.OnClick = null;
|
||||
if (_buyClearItemButton is not null)
|
||||
_buyClearItemButton.OnClick = null;
|
||||
if (_buyClearListButton is not null)
|
||||
_buyClearListButton.OnClick = null;
|
||||
if (_sellItemButton is not null)
|
||||
_sellItemButton.OnClick = null;
|
||||
if (_sellAllButton is not null)
|
||||
_sellAllButton.OnClick = null;
|
||||
if (_sellClearItemButton is not null)
|
||||
_sellClearItemButton.OnClick = null;
|
||||
if (_sellClearListButton is not null)
|
||||
_sellClearListButton.OnClick = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,11 @@ public sealed record VendorRuntimeBindings(
|
|||
// 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);
|
||||
SelectionState Selection,
|
||||
// Slice 6b/6c: InqAcceptability's rejection strings, delivered on the
|
||||
// SAME notice-0x1a system-message channel AppraisalRuntimeBindings'
|
||||
// own DisplaySystemMessage already uses.
|
||||
Action<string>? DisplaySystemMessage = null);
|
||||
|
||||
public sealed record RetailUiRuntimeBindings(
|
||||
UiHost Host,
|
||||
|
|
@ -2029,7 +2033,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_bindings.Assets.ResolveSprite,
|
||||
emptySlotSprite,
|
||||
buyingEmptySlotSprite,
|
||||
sellingEmptySlotSprite);
|
||||
sellingEmptySlotSprite,
|
||||
// Slice 6b/6c: the X-close staging confirmation and
|
||||
// InqAcceptability rejection strings.
|
||||
DialogFactory,
|
||||
b.DisplaySystemMessage);
|
||||
if (VendorController is null)
|
||||
{
|
||||
Console.WriteLine("[M4] vendor: required authored controls are missing.");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue