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
|
|
@ -375,6 +375,22 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
return false;
|
||||
activeSession.SendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId);
|
||||
return true;
|
||||
},
|
||||
// Slice 6b: "Buy All" — one batched 0x005F for every staged entry.
|
||||
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
|
||||
{
|
||||
if (session.CurrentSession is not { } activeSession || !session.IsInWorld)
|
||||
return false;
|
||||
activeSession.SendBuy(vendorGuid, items, alternateCurrencyId);
|
||||
return true;
|
||||
},
|
||||
// Slice 6c: Sell (0x0060) — both "Sell Item" and "Sell All" reuse this.
|
||||
sendSell: (vendorGuid, items) =>
|
||||
{
|
||||
if (session.CurrentSession is not { } activeSession || !session.IsInWorld)
|
||||
return false;
|
||||
activeSession.SendSell(vendorGuid, items);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -736,7 +752,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
d.Inventory.Vendor,
|
||||
iconComposer.GetIcon,
|
||||
itemInteraction,
|
||||
d.Actions.Selection),
|
||||
d.Actions.Selection,
|
||||
text => d.Communication.Chat.OnSystemMessage(text, 0x1Au)),
|
||||
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager),
|
||||
Confirmations: new ConfirmationRuntimeBindings(
|
||||
(type, context, accepted) =>
|
||||
|
|
|
|||
|
|
@ -219,6 +219,24 @@ internal sealed class SelectionInteractionController
|
|||
ItemUseRequestReservation? reservation)
|
||||
{
|
||||
CancelPendingApproach();
|
||||
// ItemHolder::UseObject @ 0x00588A80 has no distance/range check —
|
||||
// retail's client sends Use unconditionally regardless of range; the
|
||||
// walk-in is entirely server-driven (ACE's CreateMoveToChain,
|
||||
// Player_Move.cs:37-65) and arrives back as an ordinary broadcast
|
||||
// motion command (Q2, docs/research/2026-08-08-slice6b-vendor-
|
||||
// completion-research.md). This mirrors SendPickup's !IsCloseRange
|
||||
// branch below: kick off the SAME local client-predicted
|
||||
// MoveToObject animation for immediate visual feel, but never gate
|
||||
// the wire send on its arrival — unlike Pickup's close-range
|
||||
// TurnToObject branch, Use keeps sending immediately either way (the
|
||||
// existing RuntimeInteractionTransactionState.TryDispatchUse doc
|
||||
// comment: "consume the strict 0.2-second gate, send immediately").
|
||||
if (_query.TryGetApproach(serverGuid, out InteractionApproach approach)
|
||||
&& !approach.IsCloseRange)
|
||||
{
|
||||
_movement.BeginApproach(approach);
|
||||
}
|
||||
|
||||
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
||||
RuntimeInteractionDispatchResult result =
|
||||
_transactions.TryDispatchUse(
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ using System.Collections.Generic;
|
|||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound vendor GameActions. Slice 6.3: Buy (<c>0x005F</c>) only — Sell
|
||||
/// (<c>0x0060</c>) is research-only scope (Slice 6 research doc §A.3), not
|
||||
/// implemented here.
|
||||
/// Outbound vendor GameActions. Buy (<c>0x005F</c>, Slice 6.3) and Sell
|
||||
/// (<c>0x0060</c>, Slice 6b/6c) — both a batched item list; see
|
||||
/// <see cref="BuildSell"/> for the Sell-specific wire shape.
|
||||
///
|
||||
/// <para>
|
||||
/// Wire layout, confirmed FOUR ways with zero disagreement (research doc
|
||||
|
|
@ -55,6 +55,7 @@ public static class VendorRequests
|
|||
{
|
||||
public const uint GameActionEnvelope = 0xF7B1u;
|
||||
public const uint BuyOpcode = 0x005Fu;
|
||||
public const uint SellOpcode = 0x0060u;
|
||||
|
||||
/// <summary>
|
||||
/// Build a Buy GameAction for <paramref name="items"/> — retail's
|
||||
|
|
@ -107,4 +108,47 @@ public static class VendorRequests
|
|||
vendorGuid,
|
||||
new[] { (amount, itemGuid) },
|
||||
alternateCurrencyId);
|
||||
|
||||
/// <summary>
|
||||
/// Build a Sell GameAction for <paramref name="items"/> — retail's
|
||||
/// 2-argument <c>CM_Vendor::Event_Sell(vendorGuid, &list)</c>
|
||||
/// (<c>pc:689229</c>, <c>0x006AA000</c>). Slice 6b/6c research doc §A.3/
|
||||
/// §Q4: unlike Buy, Sell's body never writes a trailing field — no
|
||||
/// currency id, confirmed both by the retail decompiled sender and by
|
||||
/// ACE's reader (<c>GameActionSellItems.Handle</c> reads only
|
||||
/// <c>vendorGuid</c>, <c>numItems</c>, then per-item <c>amount</c>(i32)/
|
||||
/// <c>objectGuid</c>(u32)) and by Chorizite's/holtburger's independent
|
||||
/// generated <c>Vendor_Sell</c>/<c>SellActionData</c> shapes, neither of
|
||||
/// which carries an <c>AlternateCurrencyId</c> member at all. Used by
|
||||
/// both the "Sell Item" (a one-entry list) and "Sell All" (n-entry list)
|
||||
/// buttons — retail's own <c>Event_Sell</c> has no separate single-item
|
||||
/// opcode, unlike Buy's asymmetric client-side "immediate single" vs
|
||||
/// "batched all" naming.
|
||||
/// </summary>
|
||||
public static byte[] BuildSell(
|
||||
uint gameActionSequence,
|
||||
uint vendorGuid,
|
||||
IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
int itemCount = items.Count;
|
||||
byte[] body = new byte[20 + (itemCount * 8)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SellOpcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), vendorGuid);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), (uint)itemCount);
|
||||
|
||||
int offset = 20;
|
||||
for (int i = 0; i < itemCount; i++)
|
||||
{
|
||||
(int amount, uint itemGuid) = items[i];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(offset), amount);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset + 4), itemGuid);
|
||||
offset += 8;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2406,6 +2406,33 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, amount, itemGuid, alternateCurrencyId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6b: send a batched retail Buy (0x005F) — the "Buy All" path,
|
||||
/// one wire call for every staged entry. See <see cref="SendBuy(uint, uint, int, uint)"/>
|
||||
/// for the single-item convenience overload the "Items"/"Buying" tabs'
|
||||
/// immediate Buy buttons keep using unchanged.
|
||||
/// </summary>
|
||||
public void SendBuy(
|
||||
uint vendorGuid,
|
||||
IReadOnlyList<(int Amount, uint ItemGuid)> items,
|
||||
uint alternateCurrencyId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, items, alternateCurrencyId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6c: send retail Sell (0x0060) — <see cref="VendorRequests.BuildSell"/>.
|
||||
/// Used by both the "Sell Item" (one-entry list) and "Sell All" (n-entry
|
||||
/// list) buttons; Sell has no separate single-item opcode the way Buy
|
||||
/// does.
|
||||
/// </summary>
|
||||
public void SendSell(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(VendorRequests.BuildSell(seq, vendorGuid, items));
|
||||
}
|
||||
|
||||
/// <summary>Send retail IdentifyObject/Appraise (0x00C8).</summary>
|
||||
public void SendAppraise(uint targetGuid)
|
||||
{
|
||||
|
|
|
|||
137
src/AcDream.Core/Items/VendorSellAcceptability.cs
Normal file
137
src/AcDream.Core/Items/VendorSellAcceptability.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="VendorSellAcceptability.Evaluate"/> — retail
|
||||
/// <c>VendorSellUI::DragItemAcceptable</c>'s (<c>pc:201195-201307</c>,
|
||||
/// <c>0x004c20c0</c>) full gate chain: ownership, the non-empty-container
|
||||
/// bypass, then <c>VendorProfile::InqAcceptability</c>
|
||||
/// (<c>pc:484768-484797</c>, <c>0x005d1a90</c>).
|
||||
/// </summary>
|
||||
public enum VendorSellRejection
|
||||
{
|
||||
/// <summary>Acceptable — stage the drop.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary><c>ACCWeenieObject::IsOwnedByPlayer(esi) == 0</c> (<c>pc:201206</c>).</summary>
|
||||
NotOwnedByPlayer,
|
||||
|
||||
/// <summary>
|
||||
/// <c>InqAcceptability</c> returned the raw <c>item_types</c> bitmask
|
||||
/// (the type-mismatch/non-sellable-bit branch, <c>pc:005d1af8</c>) —
|
||||
/// the generic case in practice, since a genuine bitmask is almost never
|
||||
/// literally 1-4 (Slice 6b/6c research doc's open question #3).
|
||||
/// </summary>
|
||||
WrongType,
|
||||
|
||||
/// <summary>
|
||||
/// <c>InqAcceptability</c> literally returned 1 — retail's
|
||||
/// <c>DragItemAcceptable</c> switch has a case for it
|
||||
/// (<c>pc:201259-201267</c>), but nothing in <c>InqAcceptability</c>'s
|
||||
/// own body ever produces a literal 1 (only 0, 2, 3, the "too valuable"
|
||||
/// value, or the raw type bitmask) — ported for exact control-flow
|
||||
/// fidelity per CLAUDE.md's "do not simplify the switch" rule, not
|
||||
/// because it is known to be reachable.
|
||||
/// </summary>
|
||||
CannotBeSoldHere,
|
||||
|
||||
/// <summary><c>InqAcceptability</c> == 2: per-unit value is exactly zero (<c>pc:005d1ac3</c>).</summary>
|
||||
NoValue,
|
||||
|
||||
/// <summary>
|
||||
/// <c>InqAcceptability</c>'s "too valuable" branch (<c>pc:005d1add</c>):
|
||||
/// <c>max_value != -1 && value > max_value</c>.
|
||||
/// </summary>
|
||||
TooValuable,
|
||||
|
||||
/// <summary>
|
||||
/// <c>InqAcceptability</c> == 3: <c>min_value != -1 && value < min_value</c>
|
||||
/// (<c>pc:005d1af2</c>).
|
||||
/// </summary>
|
||||
TooCheap,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of <c>VendorSellUI::DragItemAcceptable</c> +
|
||||
/// <c>VendorProfile::InqAcceptability</c> — gates a Selling-tab drop AND its
|
||||
/// hover-preview cursor (the <c>silent</c> distinction is the caller's job:
|
||||
/// <see cref="Evaluate"/> always computes the same outcome, callers choose
|
||||
/// whether to surface <see cref="MessageFor"/>).
|
||||
/// </summary>
|
||||
public static class VendorSellAcceptability
|
||||
{
|
||||
/// <summary>Retail's -1/0xffffffff "no limit" sentinel for <c>min_value</c>/<c>max_value</c>.</summary>
|
||||
public const uint NoLimit = uint.MaxValue;
|
||||
|
||||
/// <param name="ownedByPlayer"><c>ACCWeenieObject::IsOwnedByPlayer(esi)</c>.</param>
|
||||
/// <param name="containedItemCount">
|
||||
/// <c>ACCWeenieObject::GetNumContainedItems(esi)</c> — a non-empty
|
||||
/// container (a bag with stuff in it) always passes, bypassing the
|
||||
/// type/value filter entirely (<c>pc:201229-201233</c>).
|
||||
/// </param>
|
||||
/// <param name="itemTypeMask">The dragged item's own <c>PublicWeenieDesc::_type</c>.</param>
|
||||
/// <param name="perUnitValue">
|
||||
/// The dragged item's per-unit value — <see cref="VendorPricing.PerUnitValue"/>
|
||||
/// applied to its own <c>Value</c>/<c>StackSize</c>, matching
|
||||
/// <c>InqAcceptability</c>'s own <c>_stackSize > 0 ? _value/_stackSize : _value</c>
|
||||
/// division (<c>pc:005d1ab2-005d1ab6</c>).
|
||||
/// </param>
|
||||
/// <param name="merchandiseItemTypes">The vendor's <c>VendorShopProfile.MerchandiseItemTypes</c>.</param>
|
||||
/// <param name="merchandiseMinValue">
|
||||
/// The vendor's <c>VendorShopProfile.MerchandiseMinValue</c> —
|
||||
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
|
||||
/// </param>
|
||||
/// <param name="merchandiseMaxValue">
|
||||
/// The vendor's <c>VendorShopProfile.MerchandiseMaxValue</c> —
|
||||
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
|
||||
/// </param>
|
||||
public static VendorSellRejection Evaluate(
|
||||
bool ownedByPlayer,
|
||||
int containedItemCount,
|
||||
uint itemTypeMask,
|
||||
int perUnitValue,
|
||||
uint merchandiseItemTypes,
|
||||
uint merchandiseMinValue,
|
||||
uint merchandiseMaxValue)
|
||||
{
|
||||
if (!ownedByPlayer)
|
||||
return VendorSellRejection.NotOwnedByPlayer;
|
||||
if (containedItemCount > 0)
|
||||
return VendorSellRejection.None;
|
||||
|
||||
if ((itemTypeMask & merchandiseItemTypes) == 0u)
|
||||
return VendorSellRejection.WrongType;
|
||||
|
||||
if (perUnitValue == 0)
|
||||
return VendorSellRejection.NoValue;
|
||||
|
||||
if (merchandiseMaxValue != NoLimit && perUnitValue > merchandiseMaxValue)
|
||||
return VendorSellRejection.TooValuable;
|
||||
|
||||
if (merchandiseMinValue != NoLimit && perUnitValue < merchandiseMinValue)
|
||||
return VendorSellRejection.TooCheap;
|
||||
|
||||
return VendorSellRejection.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's exact rejection strings, read directly out of the decompiled
|
||||
/// binary's data segment (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>,
|
||||
/// addresses <c>0x007b51a8</c>/<c>0x007b51e0</c>/<c>0x007b5230</c>/
|
||||
/// <c>0x007b5278</c>/<c>0x007b52cc</c>/<c>0x007b5308</c>) resolving the
|
||||
/// truncated "…" citations the Slice 6b/6c research doc quoted. Delivered
|
||||
/// via <c>ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...)</c> — the SAME
|
||||
/// system-message notice channel (<c>0x1a</c>) every other retail-ported
|
||||
/// transient string in this codebase already uses.
|
||||
/// </summary>
|
||||
public static string? MessageFor(VendorSellRejection rejection) => rejection switch
|
||||
{
|
||||
VendorSellRejection.None => null,
|
||||
VendorSellRejection.NotOwnedByPlayer => "You can only sell items you are carrying",
|
||||
VendorSellRejection.CannotBeSoldHere => "That item cannot be sold here",
|
||||
VendorSellRejection.NoValue => "That item has no value and cannot be sold",
|
||||
VendorSellRejection.TooCheap => "That item is too cheap to sell here",
|
||||
VendorSellRejection.TooValuable => "That item is too valuable to sell here",
|
||||
VendorSellRejection.WrongType => "You cannot sell that here",
|
||||
_ => "You cannot sell that here",
|
||||
};
|
||||
}
|
||||
98
src/AcDream.Core/Items/VendorStagingList.cs
Normal file
98
src/AcDream.Core/Items/VendorStagingList.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>One staged Buying/Selling-tab row — a shop/pack item guid plus a staged quantity.</summary>
|
||||
public readonly record struct VendorStagingEntry(uint ItemGuid, int Quantity);
|
||||
|
||||
/// <summary>
|
||||
/// Client-local staging list backing retail's <c>gmVendorUI::m_buyList</c>/
|
||||
/// <c>m_sellList</c> (both a <c>PackableList<ItemProfile></c>) — nothing here
|
||||
/// ever reaches the wire; only a batched Buy All/Sell All send reads
|
||||
/// <see cref="Entries"/> to build one wire payload. Shared by the Buying tab
|
||||
/// (<c>VendorBuyUI</c>) and the Selling tab (<c>VendorSellUI</c>) — retail keeps
|
||||
/// two nearly-parallel classes over the SAME <c>PackableList</c> shape (Slice
|
||||
/// 6b/6c research doc's open question #2); this is the one generic list both
|
||||
/// tab controllers own an instance of, rather than two near-duplicate types.
|
||||
/// </summary>
|
||||
public sealed class VendorStagingList
|
||||
{
|
||||
private readonly List<VendorStagingEntry> _entries = new();
|
||||
|
||||
public IReadOnlyList<VendorStagingEntry> Entries => _entries;
|
||||
public bool IsEmpty => _entries.Count == 0;
|
||||
|
||||
public event Action? Changed;
|
||||
|
||||
/// <summary>
|
||||
/// Port of the Buying tab's <c>VendorItemsUI::AddToBuyList</c> insertion
|
||||
/// (Slice 6b/6c research doc §Q3): stages <paramref name="quantity"/>
|
||||
/// units of <paramref name="itemGuid"/>. Re-adding an already-staged guid
|
||||
/// (e.g. pressing "Add to List" again after moving the slider) UPSERTS
|
||||
/// the entry to the new quantity rather than appending a duplicate row —
|
||||
/// retail's own <c>RemoveProfileFromList</c> looks up an entry BY GUID
|
||||
/// (a single match), which only holds if <c>AddToBuyList</c> never
|
||||
/// produces two rows for the same guid; the decomp excerpt available to
|
||||
/// this port does not show the insert side of that invariant directly,
|
||||
/// so this is a deliberate, documented inference from the removal side's
|
||||
/// single-match contract, not a byte-verified citation.
|
||||
/// </summary>
|
||||
public void Add(uint itemGuid, int quantity)
|
||||
{
|
||||
if (itemGuid == 0u || quantity <= 0)
|
||||
return;
|
||||
|
||||
int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid);
|
||||
if (index >= 0)
|
||||
_entries[index] = new VendorStagingEntry(itemGuid, quantity);
|
||||
else
|
||||
_entries.Add(new VendorStagingEntry(itemGuid, quantity));
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmVendorUI::RemoveProfileFromList</c> (<c>pc:200497-200537</c>,
|
||||
/// <c>0x004c1260</c>): <paramref name="amount"/> == -1 (retail's
|
||||
/// <c>0xffffffff</c>) or >= the staged quantity removes the WHOLE
|
||||
/// entry; otherwise decrements it in place. Returns <see langword="false"/>
|
||||
/// with no effect for an unstaged guid.
|
||||
/// </summary>
|
||||
public bool Remove(uint itemGuid, int amount)
|
||||
{
|
||||
int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid);
|
||||
if (index < 0)
|
||||
return false;
|
||||
|
||||
VendorStagingEntry entry = _entries[index];
|
||||
if (amount == -1 || amount >= entry.Quantity)
|
||||
_entries.RemoveAt(index);
|
||||
else
|
||||
_entries[index] = entry with { Quantity = entry.Quantity - amount };
|
||||
Changed?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGet(uint itemGuid, out VendorStagingEntry entry)
|
||||
{
|
||||
int index = _entries.FindIndex(e => e.ItemGuid == itemGuid);
|
||||
if (index < 0)
|
||||
{
|
||||
entry = default;
|
||||
return false;
|
||||
}
|
||||
entry = _entries[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Port of the unconditional <c>PackableList<ItemProfile>::Flush</c> calls
|
||||
/// ("Clear List" buttons, and the optimistic post-send clear both Buy All and Sell All
|
||||
/// perform immediately after their wire send — see the batched-send call sites).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
if (_entries.Count == 0)
|
||||
return;
|
||||
_entries.Clear();
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue