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

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:
Erik 2026-08-08 11:43:11 +02:00
parent ab3146ba88
commit 92ea3977b6
18 changed files with 2578 additions and 78 deletions

File diff suppressed because one or more lines are too long

View file

@ -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) =>

View file

@ -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(

View file

@ -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

View file

@ -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 &lt;= 1</c> implies
/// <c>MaxStackSize &lt;= 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(&amp;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(&amp;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-&gt;vtable-&gt;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 &amp;&amp; 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;
}
}

View file

@ -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.");

View file

@ -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, &amp;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;
}
}

View file

@ -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)
{

View 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 &amp;&amp; value &gt; max_value</c>.
/// </summary>
TooValuable,
/// <summary>
/// <c>InqAcceptability</c> == 3: <c>min_value != -1 &amp;&amp; value &lt; 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 &gt; 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",
};
}

View 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&lt;ItemProfile&gt;</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 &gt;= 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&lt;ItemProfile&gt;::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();
}
}

View file

@ -430,19 +430,95 @@ public sealed class SelectionInteractionControllerTests
Assert.Equal(0, h.Items.BusyCount);
}
/// <summary>
/// C1 (Slice 6b move-to-use, docs/research/2026-08-08-slice6b-vendor-
/// completion-research.md Q2): an out-of-range Use kicks off the SAME
/// local client-predicted MoveToObject approach Pickup's far-range
/// branch already installs, giving the walk immediate visual feel. The
/// wire send is never gated on arrival — retail's
/// <c>ItemHolder::UseObject @ 0x00588A80</c> has no range check and
/// sends unconditionally, so the dispatch and the approach both happen
/// at click time, in that order. A later natural MoveTo completion must
/// not re-dispatch (Use has no post-arrival token the way Pickup does).
/// </summary>
[Fact]
public void FarUseSendsImmediatelyWithoutClientApproachAndDoesNotRetry()
public void FarUseApproachesThenDispatchesImmediatelyAndDoesNotRetryOnArrival()
{
var h = new Harness();
h.SetApproach(closeRange: false);
h.Controller.SendUse(Target);
PlayerInteractionMovementSinkAssertSingleApproach(h, Target);
Assert.Equal(new[] { Target }, h.Transport.Uses);
h.Controller.OnNaturalMoveToComplete();
Assert.Empty(h.Movement.Approaches);
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
/// <summary>
/// C1 cancellation coverage: a second far Use command (the player picked
/// a new target, i.e. "moved on") supersedes the first local approach
/// cleanly — no exception, no missing/duplicated dispatch, no leaked
/// pending-pickup state (Use never arms one).
/// </summary>
[Fact]
public void NewFarUseCommandSupersedesThePreviousApproachCleanly()
{
const uint otherTarget = 0x7000_0099u;
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = otherTarget,
Name = "Other",
Type = ItemType.Creature,
Useability = ItemUseability.Remote,
});
h.SetApproach(closeRange: false);
h.Controller.SendUse(Target);
h.SetApproach(closeRange: false, serverGuid: otherTarget);
h.Controller.SendUse(otherTarget);
Assert.Equal(2, h.Movement.Approaches.Count);
Assert.Equal(Target, h.Movement.Approaches[0].Target.ServerGuid);
Assert.Equal(otherTarget, h.Movement.Approaches[1].Target.ServerGuid);
Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target, otherTarget }, h.Transport.Uses);
}
/// <summary>
/// C1 cancellation coverage: the underlying MoveTo controller cancelling
/// out from under a far Use's local approach (player moved away with
/// WASD, or any other source of <see cref="WeenieError"/>) must not
/// retract or duplicate the Use, which already went out unconditionally
/// at click time — Use holds no pending-pickup state for
/// <c>OnMoveToCancelled</c> to touch.
/// </summary>
[Fact]
public void MovingAwayDuringAFarUseApproachDoesNotAffectTheAlreadyDispatchedUse()
{
var h = new Harness();
h.SetApproach(closeRange: false);
h.Controller.SendUse(Target);
h.Controller.OnMoveToCancelled(WeenieError.ActionCancelled);
h.Controller.OnNaturalMoveToComplete();
Assert.Equal(new[] { Target }, h.Transport.Uses);
}
private static void PlayerInteractionMovementSinkAssertSingleApproach(
Harness h, uint expectedTarget)
{
InteractionApproach approach = Assert.Single(h.Movement.Approaches);
Assert.Equal(expectedTarget, approach.Target.ServerGuid);
}
[Fact]
public void CarriedDirectUseBypassesWorldApproachAndWaitsForUseDone()
{

View file

@ -33,6 +33,12 @@ public sealed class ItemInteractionControllerTests
// TryBuy must see this as false and release the reservation
// rather than mark it dispatched for a request nothing sent.
public bool SendBuySucceeds = true;
// Slice 6b/6c: the batched Buy All / Sell send delegates — same
// "no live session" no-op simulation shape as SendBuySucceeds.
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
public bool SendBuyAllSucceeds = true;
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
public bool SendSellSucceeds = true;
public readonly List<string> Toasts = new();
public readonly List<string> SystemMessages = new();
public readonly List<CombatMode> CombatModeRequests = new();
@ -107,6 +113,20 @@ public sealed class ItemInteractionControllerTests
return false;
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
return true;
},
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
{
if (!SendBuyAllSucceeds)
return false;
BuyAlls.Add((vendorGuid, items, alternateCurrencyId));
return true;
},
sendSell: (vendorGuid, items) =>
{
if (!SendSellSucceeds)
return false;
Sells.Add((vendorGuid, items));
return true;
});
}
@ -2308,4 +2328,182 @@ public sealed class ItemInteractionControllerTests
Assert.Single(h.Buys);
Assert.Equal(1, h.Controller.BusyCount);
}
// ── Slice 6b: TryBuyAll ──────────────────────────────────────────────
[Fact]
public void TryBuyAll_Succeeds_SendsTheBatchAndTakesTheSharedUseReservation()
{
var h = new Harness();
var items = new (int Amount, uint ItemGuid)[]
{
(1, 0x50002000u),
(25, 0x50002001u),
};
bool result = h.Controller.TryBuyAll(0x40001000u, items, alternateCurrencyId: 0u);
Assert.True(result);
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> sent, uint currency) = Assert.Single(h.BuyAlls);
Assert.Equal(0x40001000u, vendorGuid);
Assert.Equal(items, sent);
Assert.Equal(0u, currency);
Assert.Equal(1, h.Controller.BusyCount);
}
[Fact]
public void TryBuyAll_RidesTheSameOneRequestAtATimeGateAsOrdinaryUse()
{
var h = new Harness();
h.Controller.IncrementBusyCount();
bool result = h.Controller.TryBuyAll(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u);
Assert.False(result);
Assert.Empty(h.BuyAlls);
}
[Theory]
[InlineData(0u)]
[InlineData(0x40001000u)]
public void TryBuyAll_ZeroVendorOrEmptyList_IsRejectedWithoutTakingAReservation(uint vendorGuid)
{
var h = new Harness();
var items = vendorGuid == 0u
? new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }
: Array.Empty<(int Amount, uint ItemGuid)>();
bool result = h.Controller.TryBuyAll(vendorGuid, items, 0u);
Assert.False(result);
Assert.Empty(h.BuyAlls);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void TryBuyAll_CompleteUse_ReleasesTheReservation()
{
var h = new Harness();
Assert.True(h.Controller.TryBuyAll(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u));
Assert.Equal(1, h.Controller.BusyCount);
h.Controller.CompleteUse(0);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void TryBuyAll_NoSessionToSendOn_ReleasesTheReservation()
{
var h = new Harness();
h.SendBuyAllSucceeds = false;
bool result = h.Controller.TryBuyAll(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) }, 0u);
Assert.False(result);
Assert.Empty(h.BuyAlls);
Assert.Equal(0, h.Controller.BusyCount);
}
// ── Slice 6c: TrySell ────────────────────────────────────────────────
[Fact]
public void TrySell_Succeeds_SendsTheBatchAndTakesTheSharedUseReservation()
{
var h = new Harness();
var items = new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) };
bool result = h.Controller.TrySell(0x40001000u, items);
Assert.True(result);
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> sent) = Assert.Single(h.Sells);
Assert.Equal(0x40001000u, vendorGuid);
Assert.Equal(items, sent);
Assert.Equal(1, h.Controller.BusyCount);
}
[Fact]
public void TrySell_RidesTheSameOneRequestAtATimeGateAsOrdinaryUse()
{
var h = new Harness();
h.Controller.IncrementBusyCount();
bool result = h.Controller.TrySell(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) });
Assert.False(result);
Assert.Empty(h.Sells);
}
[Fact]
public void TrySell_ASecondSellWhileTheFirstIsInFlight_IsRejected()
{
var h = new Harness();
Assert.True(h.Controller.TrySell(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }));
bool second = h.Controller.TrySell(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) });
Assert.False(second);
Assert.Single(h.Sells);
}
[Theory]
[InlineData(0u)]
[InlineData(0x40001000u)]
public void TrySell_ZeroVendorOrEmptyList_IsRejectedWithoutTakingAReservation(uint vendorGuid)
{
var h = new Harness();
var items = vendorGuid == 0u
? new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }
: Array.Empty<(int Amount, uint ItemGuid)>();
bool result = h.Controller.TrySell(vendorGuid, items);
Assert.False(result);
Assert.Empty(h.Sells);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void TrySell_CompleteUse_ReleasesTheReservationAndReenablesFurtherRequests()
{
var h = new Harness();
Assert.True(h.Controller.TrySell(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) }));
Assert.Equal(1, h.Controller.BusyCount);
h.Controller.CompleteUse(0);
Assert.Equal(0, h.Controller.BusyCount);
Assert.True(h.Controller.TrySell(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) }));
Assert.Equal(2, h.Sells.Count);
}
[Fact]
public void TrySell_NoSessionToSendOn_ReleasesTheReservation_AndASubsequentSellWorks()
{
var h = new Harness();
h.SendSellSucceeds = false;
bool result = h.Controller.TrySell(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003000u) });
Assert.False(result);
Assert.Empty(h.Sells);
Assert.Equal(0, h.Controller.BusyCount);
h.SendSellSucceeds = true;
bool second = h.Controller.TrySell(
0x40001000u, new (int Amount, uint ItemGuid)[] { (1, 0x50003001u) });
Assert.True(second);
Assert.Single(h.Sells);
Assert.Equal(1, h.Controller.BusyCount);
}
}

View file

@ -645,4 +645,117 @@ public class SelectedObjectControllerTests
Assert.False(healthMeterEl.Visible);
Assert.Empty(h.QueryHealthCalls);
}
// ══════════════════════════════════════════════════════════════════════
// C4 (Slice 6b/6c contract, Q5 — user evidence is the axiom): reproduce
// the user's live-session report of a missing "(250,000)" figure near a
// selected vendor stack, against the REAL production wiring — a real
// ClientObjectTable + VendorState + VendorSplitPolicy (mirroring
// InteractionRetainedUiComposition's exact IsVendorSplitExempt/
// StackSize/name lambdas verbatim, not the fake dictionary-backed
// Harness the rest of this file uses), mounted onto the real dat-
// fixture toolbar layout (FixtureLoader.LoadToolbar(), LayoutDesc
// 0x21000016) through the production SelectedObjectController.Bind
// entry point.
//
// docs/research/2026-08-08-slice6b-vendor-completion-research.md Q5
// concluded from static reading that retail's toolbar strip never
// shows a price/value suffix in the object name (only "{count}
// {name}") and that a "(250,000)" figure belongs to the VENDOR ROW's
// own price text (VendorUiController._itemCostText), a separate
// widget — with every acdream link in the chain (name formatting,
// slider seed, the shared VendorSplitPolicy mask, the materializer's
// field mapping) already matching that shape on paper. This test
// settles whether that is genuinely what the user saw (expected: this
// test passes, and the caller stops here per the contract, reporting
// this as the live-probe evidence) or whether the real wiring chain
// has a defect static reading could not see (expected: this test
// fails, and the failure is what gets fixed).
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation()
{
const uint vendorGuid = 0x70000010u;
const uint tradeNotesGuid = 0x60009001u;
ImportedLayout layout = FixtureLoader.LoadToolbar();
var objects = new ClientObjectTable();
var vendor = new VendorState();
var selection = new SelectionState();
var splitQuantity = new StackSplitQuantityState();
// A materialized vendor shop item — VendorShopItemMaterializer.
// ToWeenieData's exact shape: StackSize is the item's own
// authored per-unit stack depth (its DescStackSize wire field),
// ContainerId is the vendor's own guid.
objects.AddOrUpdate(new ClientObject
{
ObjectId = tradeNotesGuid,
Name = "Trade Note",
PluralName = "Trade Notes",
Type = ItemType.PromissoryNote,
StackSize = 250,
ContainerId = vendorGuid,
});
vendor.Apply(
vendorGuid,
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
Array.Empty<VendorShopItem>());
SelectedObjectController controller = SelectedObjectController.Bind(
layout,
selection,
subscribeHealthChanged: _ => { },
unsubscribeHealthChanged: _ => { },
subscribeItemManaChanged: _ => { },
unsubscribeItemManaChanged: _ => { },
isHealthTarget: _ => false,
isOwnedByPlayer: _ => false,
// Production's EXACT name resolver (InteractionRetainedUiComposition.cs:676).
name: guid => objects.Get(guid)?.GetAppropriateName(),
healthPercent: _ => 0f,
hasHealth: _ => false,
// Production's EXACT stackSize resolver (InteractionRetainedUiComposition.cs:679-680).
stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0),
sendQueryHealth: _ => { },
manaPercent: _ => 0f,
sendQueryItemMana: _ => { },
datFont: null,
splitQuantity: splitQuantity,
subscribeObjectUpdated: _ => { },
unsubscribeObjectUpdated: _ => { },
// Production's EXACT isVendorSplitExempt predicate, verbatim
// from InteractionRetainedUiComposition.cs:698-702.
isVendorSplitExempt: guid =>
vendor.VendorId != 0u
&& objects.Get(guid) is { } vendorCandidate
&& vendorCandidate.ContainerId == vendor.VendorId
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
selection.Select(tradeNotesGuid, SelectionChangeSource.Vendor);
var nameElement = layout.FindElement(SelectedObjectController.NameId);
Assert.NotNull(nameElement);
UiText nameLabel = Assert.Single(nameElement!.Children.OfType<UiText>());
string renderedName = string.Concat(
nameLabel.LinesProvider().Select(static line => line.Text));
var slider = Assert.IsType<UiScrollbar>(
layout.FindElement(SelectedObjectController.StackSizeSliderId));
// Retail's toolbar name text: "{stackSize} {name}" — count is the
// raw authored stack (250), independent of the vendor-exempt SEED.
// No parenthetical value anywhere in this string.
Assert.Equal("250 Trade Notes", renderedName);
Assert.DoesNotContain("250,000", renderedName);
Assert.DoesNotContain("(", renderedName);
// The slider is visible (a real multi-unit stack) and seeds to 1 —
// PromissoryNote intersects VendorSplitPolicy.SplitExemptMask.
Assert.True(slider.Visible);
Assert.Equal(1u, splitQuantity.Value);
Assert.Equal(250u, splitQuantity.Maximum);
controller.Dispose();
}
}

View file

@ -24,6 +24,16 @@ public sealed class VendorUiControllerTests
private const uint ArmorItemGuid = 0x60000101u;
private const uint FoodItemGuid = 0x60000102u;
private const uint StackedItemGuid = 0x60000103u;
// Slice 6b: a second same-category shop item, so two DIFFERENT items
// both appear in the SAME category-filtered Items list simultaneously
// (Armor and Food are different table entries and can't both show at
// once without touching the category dropdown).
private const uint AnotherArmorItemGuid = 0x60000104u;
// Slice 6c: player-OWNED pack items (never vendor stock) dragged onto
// the Selling tab.
private const uint PlayerOwnedArmorGuid = 0x60000201u;
private const uint PlayerOwnedWeaponGuid = 0x60000202u;
private const uint PlayerOwnedArmorGuid2 = 0x60000205u;
private sealed class TestElement : UiElement { }
@ -172,11 +182,28 @@ public sealed class VendorUiControllerTests
public readonly UiButton CloseButton;
public readonly UiButton BuyButton;
public readonly UiButton AddButton;
// Slice 6b: "Buying" tab staging widgets.
public readonly UiItemList BuyingList = new();
public readonly UiButton BuyItemButton;
public readonly UiButton BuyAllButton;
public readonly UiButton BuyClearItemButton;
public readonly UiButton BuyClearListButton;
// Slice 6c: "Selling" tab staging widgets.
public readonly UiItemList SellingList = new();
public readonly UiButton SellItemButton;
public readonly UiButton SellAllButton;
public readonly UiButton SellClearItemButton;
public readonly UiButton SellClearListButton;
public readonly RetailWindowHandle Window;
public readonly VendorUiController Controller;
public readonly List<uint> Examines = new();
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
public readonly List<string> SystemMessages = new();
public readonly ItemInteractionController ItemInteraction;
public readonly RetailDialogFactory Dialogs;
public ImportedLayout? ShownDialog;
public Harness()
{
@ -194,6 +221,30 @@ public sealed class VendorUiControllerTests
AddButton = new UiButton(
new ElementInfo { Id = VendorUiController.AddButtonId, Type = 1 },
static _ => (0u, 0, 0));
BuyItemButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyItemButtonId, Type = 1 },
static _ => (0u, 0, 0));
BuyAllButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyAllButtonId, Type = 1 },
static _ => (0u, 0, 0));
BuyClearItemButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyClearItemButtonId, Type = 1 },
static _ => (0u, 0, 0));
BuyClearListButton = new UiButton(
new ElementInfo { Id = VendorUiController.BuyClearListButtonId, Type = 1 },
static _ => (0u, 0, 0));
SellItemButton = new UiButton(
new ElementInfo { Id = VendorUiController.SellItemButtonId, Type = 1 },
static _ => (0u, 0, 0));
SellAllButton = new UiButton(
new ElementInfo { Id = VendorUiController.SellAllButtonId, Type = 1 },
static _ => (0u, 0, 0));
SellClearItemButton = new UiButton(
new ElementInfo { Id = VendorUiController.SellClearItemButtonId, Type = 1 },
static _ => (0u, 0, 0));
SellClearListButton = new UiButton(
new ElementInfo { Id = VendorUiController.SellClearListButtonId, Type = 1 },
static _ => (0u, 0, 0));
root.AddChild(CloseButton);
root.AddChild(ItemsTab);
@ -209,6 +260,16 @@ public sealed class VendorUiControllerTests
ItemsPage.AddChild(ItemCostText);
ItemsPage.AddChild(BuyButton);
ItemsPage.AddChild(AddButton);
BuyingPage.AddChild(BuyingList);
BuyingPage.AddChild(BuyItemButton);
BuyingPage.AddChild(BuyAllButton);
BuyingPage.AddChild(BuyClearItemButton);
BuyingPage.AddChild(BuyClearListButton);
SellingPage.AddChild(SellingList);
SellingPage.AddChild(SellItemButton);
SellingPage.AddChild(SellAllButton);
SellingPage.AddChild(SellClearItemButton);
SellingPage.AddChild(SellClearListButton);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
@ -226,6 +287,16 @@ public sealed class VendorUiControllerTests
[VendorUiController.ItemCostTextId] = ItemCostText,
[VendorUiController.BuyButtonId] = BuyButton,
[VendorUiController.AddButtonId] = AddButton,
[VendorUiController.BuyingListId] = BuyingList,
[VendorUiController.BuyItemButtonId] = BuyItemButton,
[VendorUiController.BuyAllButtonId] = BuyAllButton,
[VendorUiController.BuyClearItemButtonId] = BuyClearItemButton,
[VendorUiController.BuyClearListButtonId] = BuyClearListButton,
[VendorUiController.SellingListId] = SellingList,
[VendorUiController.SellItemButtonId] = SellItemButton,
[VendorUiController.SellAllButtonId] = SellAllButton,
[VendorUiController.SellClearItemButtonId] = SellClearItemButton,
[VendorUiController.SellClearListButtonId] = SellClearListButton,
});
Window = RetailWindowFrame.Mount(
@ -255,8 +326,21 @@ public sealed class VendorUiControllerTests
{
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
return true;
},
sendBuyAll: (vendorGuid, items, alternateCurrencyId) =>
{
BuyAlls.Add((vendorGuid, items, alternateCurrencyId));
return true;
},
sendSell: (vendorGuid, items) =>
{
Sells.Add((vendorGuid, items));
return true;
});
Dialogs = new RetailDialogFactory(Screen, _ =>
ShownDialog = FixtureLoader.LoadConfirmationDialog());
Controller = VendorUiController.Bind(
layout,
State,
@ -272,7 +356,9 @@ public sealed class VendorUiControllerTests
SplitQuantity,
datFont: null,
debugFont: null,
static _ => (0u, 0, 0))!;
static _ => (0u, 0, 0),
dialogs: Dialogs,
systemMessage: SystemMessages.Add)!;
Screen.WindowManager.AttachController(WindowNames.Vendor, Controller);
}
}
@ -281,6 +367,17 @@ public sealed class VendorUiControllerTests
float sellRate = 1.5f, uint altCurrency = 0u, string altName = "", uint altAmount = 0u) =>
new(0u, 0u, 0u, false, 1.0f, sellRate, altCurrency, altAmount, altName);
/// <summary>
/// Slice 6c: a profile shaped for <see cref="VendorSellAcceptability"/>
/// coverage — <see cref="Profile"/>'s all-zero merchandise fields would
/// reject every real item (MaxValue=0 rejects anything with value &gt; 0).
/// </summary>
private static VendorShopProfile SellProfile(
uint merchandiseItemTypes,
uint minValue = 0u,
uint maxValue = VendorSellAcceptability.NoLimit) =>
new(merchandiseItemTypes, minValue, maxValue, false, 1.0f, 1.5f, 0u, 0u, "");
private static string GetText(UiText text)
=> string.Concat(text.LinesProvider().Select(line => line.Text));
@ -528,13 +625,11 @@ public sealed class VendorUiControllerTests
}
[Fact]
public void AddButton_IsPermanentlyDisabled_RegardlessOfSelection()
public void AddButton_EnablesWithSelection_NowThatStagingIsWired()
{
// F8 (Slice 6 review): "Add to List" has no wired OnClick at all
// (staging into the "Buying" tab is deferred, contract decision 6)
// — an enabled button that silently does nothing on click is a
// dead-affordance bug, worse than a disabled one. It must never
// enable, with or without a selection.
// Slice 6b (AP-161 F8 residual closes): "Add to List" now stages
// into the "Buying" tab and enables with selection exactly like Buy
// — an enabled Add is no longer a dead affordance.
var h = new Harness();
Assert.False(h.AddButton.Enabled);
@ -543,8 +638,8 @@ public sealed class VendorUiControllerTests
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// F4/F6 auto-selects the sole item on open -- Add stays disabled.
Assert.False(h.AddButton.Enabled);
// F4/F6 auto-selects the sole item on open -- Add now enables too.
Assert.True(h.AddButton.Enabled);
h.State.Close();
Assert.False(h.AddButton.Enabled);
@ -957,7 +1052,7 @@ public sealed class VendorUiControllerTests
}
[Fact]
public void SellingTab_SwitchesPageButPopulatesNoSellContent()
public void SellingTab_SwitchesPageAndStartsWithAnEmptyStagedSellList()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
@ -970,13 +1065,15 @@ public sealed class VendorUiControllerTests
Assert.True(h.SellingPage.Visible);
Assert.False(h.ItemsPage.Visible);
Assert.False(h.BuyingPage.Visible);
// Slice 6 fence: no sell-list/price/button wiring exists at all —
// the page is exactly the authored-empty container it started as.
Assert.Empty(h.SellingPage.Children);
// Slice 6c: the Selling tab's own widgets are wired now (staging
// list + four buttons), but nothing is STAGED without a drag/drop —
// the list itself stays empty.
Assert.NotEmpty(h.SellingPage.Children);
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void BuyingTab_SwitchesPageButPopulatesNoBuyContent()
public void BuyingTab_SwitchesPageAndStartsWithAnEmptyStagedBuyList()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
@ -988,7 +1085,11 @@ public sealed class VendorUiControllerTests
Assert.True(h.BuyingPage.Visible);
Assert.False(h.ItemsPage.Visible);
Assert.Empty(h.BuyingPage.Children);
// Slice 6b: the Buying tab's own widgets are wired now (staging list
// + four buttons), but nothing is STAGED until "Add to List" is
// pressed — the list itself stays empty.
Assert.NotEmpty(h.BuyingPage.Children);
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
@ -1237,4 +1338,552 @@ public sealed class VendorUiControllerTests
Assert.Equal(string.Empty, GetText(h.ItemNameText));
Assert.False(h.BuyButton.Enabled);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6b — Buying tab staging (Add to List, Buy Item, Buy All, Clear)
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void AddToBuyList_StagesTheSelectedItem()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// F4/F6 auto-selects the sole item on open.
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId);
}
[Fact]
public void AddToBuyList_ReAddingTheSameItemUpsertsRatherThanDuplicatingTheRow()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
[Fact]
public void AddToBuyList_NothingSelected_IsANoOp()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
h.AddButton.OnClick!.Invoke();
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
h.ItemList.GetItem(1)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(2, h.BuyingList.GetNumUIItems());
h.BuyAllButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items, uint currency) =
Assert.Single(h.BuyAlls);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(
new (int Amount, uint ItemGuid)[] { (1, ArmorItemGuid), (1, AnotherArmorItemGuid) },
items);
Assert.Equal(0u, currency);
// Retail flushes m_buyList immediately after the send, not gated on
// a server response (pc:204075-204076) — see BuyAllButtonPressed's
// own doc comment.
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void BuyAllButton_WithNothingStaged_IsANoOp()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
}
[Fact]
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
h.BuyItemButton.OnClick!.Invoke();
Assert.Equal(new[] { (VendorGuid, ArmorItemGuid, 1, 0u) }, h.Buys);
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void BuyClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutBuying()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
h.ItemList.GetItem(1)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(2, h.BuyingList.GetNumUIItems());
// AnotherArmorItemGuid is currently selected (last clicked).
h.BuyClearItemButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId);
Assert.Empty(h.Buys);
}
[Fact]
public void BuyClearListButton_ClearsEveryStagedEntryWithoutBuying()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.BuyClearListButton.OnClick!.Invoke();
Assert.Equal(0, h.BuyingList.GetNumUIItems());
Assert.Empty(h.Buys);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6c — Selling tab drag-to-sell staging
// ══════════════════════════════════════════════════════════════════════
private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1)
{
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = $"Item {guid:X8}",
Type = type,
Value = value,
StackSize = stackSize,
});
h.Objects.MoveItem(guid, Harness.PlayerGuid, h.Objects.GetContents(Harness.PlayerGuid).Count);
}
private static ItemDragPayload DragFromInventory(uint guid) =>
new(guid, ItemDragSource.Inventory, 0, new UiItemSlot());
[Fact]
public void OnDragOver_TargetIsNotTheSellingList_RejectsRegardlessOfAcceptability()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
ItemDragAcceptance result = h.Controller.OnDragOver(
h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(ItemDragAcceptance.Reject, result);
}
[Fact]
public void OnDragOver_AcceptableItemOverSellingList_Accepts()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
ItemDragAcceptance result = h.Controller.OnDragOver(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(ItemDragAcceptance.Accept, result);
}
[Fact]
public void OnDragOver_UnacceptableItemOverSellingList_RejectsSilently()
{
var h = new Harness();
// Vendor only deals in Armor -- a Weapon is a type mismatch.
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100);
ItemDragAcceptance result = h.Controller.OnDragOver(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
Assert.Equal(ItemDragAcceptance.Reject, result);
// silent=1 on hover -- no rejection string yet (VendorSellUI::
// OnItemListDragOver, pc:201320-201339).
Assert.Empty(h.SystemMessages);
}
[Fact]
public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.True(h.SellingPage.Visible);
Assert.False(h.ItemsPage.Visible);
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
Assert.Empty(h.SystemMessages);
}
[Fact]
public void HandleDropRelease_WrongTargetList_IsIgnored()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
// gmVendorUI::HandleDropRelease's IsAncestorOfMe gate — a drop on
// ANY other list in the panel (here, the Items list) is a structural
// no-op, never reaching AcceptDragObject.
h.Controller.HandleDropRelease(
h.ItemList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void HandleDropRelease_UnacceptableType_ShowsTheGenericRejectionMessageAndDoesNotStage()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.Weapon, 100);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
}
[Fact]
public void HandleDropRelease_NoValueItem_ShowsTheNoValueRejectionMessage()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, value: 0);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "That item has no value and cannot be sold" }, h.SystemMessages);
}
[Fact]
public void HandleDropRelease_NotOwnedByPlayer_ShowsTheOwnershipRejectionMessage()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
// Registered but never moved into the player's own container --
// ContainerId/WielderId both stay 0, so IsOwnedByPlayer is false.
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = PlayerOwnedArmorGuid,
Name = "Someone else's chainmail",
Type = ItemType.Armor,
Value = 100,
StackSize = 1,
});
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages);
}
[Fact]
public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2));
Assert.Equal(2, h.SellingList.GetNumUIItems());
h.SellAllButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(
new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid), (1, PlayerOwnedArmorGuid2) },
items);
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void SellAllButton_WithNothingStaged_IsANoOp()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
h.SellAllButton.OnClick!.Invoke();
Assert.Empty(h.Sells);
}
[Fact]
public void SellItemButton_SellsTheSelectedStagedItemAndRemovesItUnconditionallyOnSuccess()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.SellItemButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items);
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
MakePlayerOwned(h, PlayerOwnedArmorGuid2, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid2));
Assert.Equal(2, h.SellingList.GetNumUIItems());
// PlayerOwnedArmorGuid2 is currently selected (last dropped).
h.SellClearItemButton.OnClick!.Invoke();
Assert.Equal(1, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.Empty(h.Sells);
}
[Fact]
public void SellClearListButton_ClearsEveryStagedEntryWithoutSelling()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
h.SellClearListButton.OnClick!.Invoke();
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Empty(h.Sells);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6b/6c — X-close staging confirmation + session-boundary clears
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void CloseButtonPressed_WithNoStaging_HidesImmediatelyWithoutADialog()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
Assert.True(h.Window.IsVisible);
h.CloseButton.OnClick!.Invoke();
Assert.False(h.Window.IsVisible);
Assert.False(h.Dialogs.IsOpen);
}
[Fact]
public void CloseButtonPressed_WithStagedBuyItems_ShowsConfirmDialogInsteadOfHidingImmediately()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.CloseButton.OnClick!.Invoke();
Assert.True(h.Window.IsVisible);
Assert.True(h.Dialogs.IsOpen);
Assert.NotNull(h.ShownDialog);
// Exact retail string, read from the decompiled binary's data
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment.
Assert.Equal(
"You have not completed all transactions. Are you sure you want to leave this vendor",
string.Join(" ", Assert.IsType<UiText>(h.ShownDialog!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
}
[Fact]
public void CloseConfirmDialog_Accepted_HidesTheWindowAndLeavesStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.CloseButton.OnClick!.Invoke();
Assert.IsType<UiButton>(h.ShownDialog!.FindElement(
RetailConfirmationDialogView.AcceptButtonId)).OnClick!();
Assert.False(h.Window.IsVisible);
Assert.False(h.Dialogs.IsOpen);
// Retail's CloseVendorDialogCallback never touches m_buyList/
// m_sellList -- staging survives so the next open shows it again.
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
[Fact]
public void CloseConfirmDialog_Rejected_KeepsTheWindowOpenAndStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.CloseButton.OnClick!.Invoke();
Assert.IsType<UiButton>(h.ShownDialog!.FindElement(
RetailConfirmationDialogView.RejectButtonId)).OnClick!();
Assert.True(h.Window.IsVisible);
Assert.False(h.Dialogs.IsOpen);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
[Fact]
public void CloseButtonPressed_WhileAConfirmationIsAlreadyUp_DoesNotOpenASecondOne()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.CloseButton.OnClick!.Invoke();
Assert.Equal(1, h.Dialogs.ActiveCount);
h.CloseButton.OnClick!.Invoke();
Assert.Equal(1, h.Dialogs.ActiveCount);
}
[Fact]
public void SessionClose_ClearsBothStagingLists()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.State.Close();
Assert.Equal(0, h.BuyingList.GetNumUIItems());
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
[Fact]
public void SessionReset_ClearsBothStagingLists()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.State.Reset();
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void OpeningADifferentVendor_ClearsStaleStagingFromThePreviousVendor()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
const uint otherVendor = 0x70000099u;
h.State.Apply(otherVendor, Profile(), new[]
{
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
});
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
[Fact]
public void RefreshedTransition_SameVendor_DoesNotClearAnUntouchedStagingList()
{
var h = new Harness();
var items = new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
};
h.State.Apply(VendorGuid, Profile(), items);
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
// Same vendor id re-approaching -- sameVendor==1, a Refreshed
// transition (e.g. post buy/sell ApproachVendor refresh).
h.State.Apply(VendorGuid, Profile(), items);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
}

View file

@ -124,4 +124,75 @@ public sealed class VendorRequestsTests
Assert.Equal(viaList, viaSingle);
}
// ---- Slice 6c: BuildSell (0x0060) — no trailing currency field ---------
[Fact]
public void BuildSell_SingleItem_WritesEnvelopeSequenceOpcodeVendorCountAndItemWithNoTrailer()
{
byte[] body = VendorRequests.BuildSell(
gameActionSequence: 9,
vendorGuid: 0x40001000u,
items: new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) });
// envelope(4) + seq(4) + opcode(4) + vendorGuid(4) + itemCount(4)
// + 1*(amount(4)+guid(4)) = 28. Note: 4 bytes SHORTER than the
// equivalent single-item Buy payload (32) — Sell has no trailing
// alternateCurrencyId.
Assert.Equal(28, body.Length);
Assert.Equal(VendorRequests.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
Assert.Equal(9u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(VendorRequests.SellOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x0060u, VendorRequests.SellOpcode);
Assert.Equal(0x40001000u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(1,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
Assert.Equal(0x50002000u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
}
[Fact]
public void BuildSell_MultipleItems_WritesEachAmountGuidPairInOrderWithNoTrailer()
{
byte[] body = VendorRequests.BuildSell(
gameActionSequence: 4,
vendorGuid: 0x40001000u,
items: new (int Amount, uint ItemGuid)[]
{
(1, 0x50002000u),
(10, 0x50002001u),
});
// 20 + 2*8 = 36.
Assert.Equal(36, body.Length);
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(1,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
Assert.Equal(0x50002000u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
Assert.Equal(10,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(28)));
Assert.Equal(0x50002001u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(32)));
}
[Fact]
public void BuildSell_EmptyList_WritesAZeroCountAndNoItemPairs()
{
byte[] body = VendorRequests.BuildSell(
gameActionSequence: 1,
vendorGuid: 0x40001000u,
items: Array.Empty<(int Amount, uint ItemGuid)>());
Assert.Equal(20, body.Length);
Assert.Equal(0u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
}
}

View file

@ -61,4 +61,70 @@ public sealed class WorldSessionVendorTests
Assert.Equal(expected, captured);
}
// ---- Slice 6b: the batched "Buy All" SendBuy overload -------------------
[Fact]
public void SendBuy_BatchedOverload_EmitsBytesIdenticalToVendorRequestsBuildBuyWithAList()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
var items = new (int Amount, uint ItemGuid)[]
{
(1, 0x50002000u),
(10, 0x50002001u),
};
session.SendBuy(0x40001000u, items, 0u);
byte[] expected = VendorRequests.BuildBuy(
gameActionSequence: 1,
vendorGuid: 0x40001000u,
items: items,
alternateCurrencyId: 0u);
Assert.NotNull(captured);
Assert.Equal(expected, captured);
}
// ---- Slice 6c: SendSell ---------------------------------------------
[Fact]
public void SendSell_EmitsBytesIdenticalToVendorRequestsBuildSell()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
var items = new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) };
session.SendSell(0x40001000u, items);
byte[] expected = VendorRequests.BuildSell(
gameActionSequence: 1,
vendorGuid: 0x40001000u,
items: items);
Assert.NotNull(captured);
Assert.Equal(expected, captured);
}
[Fact]
public void SendSell_IncrementsTheSharedGameActionSequenceLikeEveryOtherSend()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendTalk("first"); // consumes sequence 1
var items = new (int Amount, uint ItemGuid)[] { (1, 0x50002000u) };
session.SendSell(0x40001000u, items); // should be sequence 2
byte[] expected = VendorRequests.BuildSell(
gameActionSequence: 2,
vendorGuid: 0x40001000u,
items: items);
Assert.Equal(expected, captured);
}
}

View file

@ -0,0 +1,174 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
/// <summary>
/// Conformance tests for <see cref="VendorSellAcceptability"/> — the port of
/// <c>VendorSellUI::DragItemAcceptable</c> (<c>pc:201195-201307</c>) chained
/// into <c>VendorProfile::InqAcceptability</c> (<c>pc:484768-484797</c>).
/// </summary>
public sealed class VendorSellAcceptabilityTests
{
private const uint Armor = (uint)ItemType.Armor;
private const uint Weapon = (uint)ItemType.Weapon;
private const uint NoLimit = VendorSellAcceptability.NoLimit;
[Fact]
public void NotOwnedByPlayerIsRejectedBeforeAnyOtherCheck()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: false,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.NotOwnedByPlayer, rejection);
}
[Fact]
public void ANonEmptyContainerBypassesTheTypeAndValueFilterEntirely()
{
// pc:201229-201233: GetNumContainedItems > 0 -> accept unconditionally,
// even though the container's OWN type (Weapon) does not intersect
// the vendor's merchandise mask (Armor) and it has zero value.
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 3,
itemTypeMask: Weapon,
perUnitValue: 0,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.None, rejection);
}
[Fact]
public void AWrongItemTypeIsRejected()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Weapon,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.WrongType, rejection);
}
[Fact]
public void ZeroPerUnitValueIsRejectedAsNoValue()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 0,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.NoValue, rejection);
}
[Fact]
public void AboveTheAuthoredMaxValueIsRejectedAsTooValuable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 1001,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.TooValuable, rejection);
}
[Fact]
public void ExactlyAtTheAuthoredMaxValueIsAcceptable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 1000,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.None, rejection);
}
[Fact]
public void BelowTheAuthoredMinValueIsRejectedAsTooCheap()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 4,
merchandiseItemTypes: Armor,
merchandiseMinValue: 5u,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.TooCheap, rejection);
}
[Fact]
public void NoLimitSentinelDisablesBothMaxAndMinChecks()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: int.MaxValue - 1,
merchandiseItemTypes: Armor,
merchandiseMinValue: NoLimit,
merchandiseMaxValue: NoLimit);
Assert.Equal(VendorSellRejection.None, rejection);
}
[Fact]
public void AnOrdinaryAcceptableItemReturnsNone()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 500,
merchandiseItemTypes: Armor,
merchandiseMinValue: 1u,
merchandiseMaxValue: 10_000u);
Assert.Equal(VendorSellRejection.None, rejection);
}
// ---- MessageFor: exact retail strings, recovered from the decompiled ----
// ---- binary's data segment (see VendorSellAcceptability's doc comment). ----
[Theory]
[InlineData(VendorSellRejection.NotOwnedByPlayer, "You can only sell items you are carrying")]
[InlineData(VendorSellRejection.CannotBeSoldHere, "That item cannot be sold here")]
[InlineData(VendorSellRejection.NoValue, "That item has no value and cannot be sold")]
[InlineData(VendorSellRejection.TooCheap, "That item is too cheap to sell here")]
[InlineData(VendorSellRejection.TooValuable, "That item is too valuable to sell here")]
[InlineData(VendorSellRejection.WrongType, "You cannot sell that here")]
public void MessageForReturnsRetailsExactString(VendorSellRejection rejection, string expected)
{
Assert.Equal(expected, VendorSellAcceptability.MessageFor(rejection));
}
[Fact]
public void MessageForAcceptableReturnsNull()
{
Assert.Null(VendorSellAcceptability.MessageFor(VendorSellRejection.None));
}
}

View file

@ -0,0 +1,169 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
/// <summary>
/// Conformance tests for <see cref="VendorStagingList"/> — retail's
/// <c>gmVendorUI::RemoveProfileFromList</c> (<c>pc:200497-200537</c>) removal
/// semantics, and the Add/Clear staging shape both the Buying and Selling
/// tabs share (Slice 6b/6c).
/// </summary>
public sealed class VendorStagingListTests
{
private const uint ItemA = 0x60000101u;
private const uint ItemB = 0x60000102u;
[Fact]
public void AddAppendsANewEntry()
{
var list = new VendorStagingList();
list.Add(ItemA, 5);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(ItemA, entry.ItemGuid);
Assert.Equal(5, entry.Quantity);
Assert.False(list.IsEmpty);
}
[Fact]
public void AddingTheSameGuidTwiceUpsertsRatherThanDuplicating()
{
var list = new VendorStagingList();
list.Add(ItemA, 5);
list.Add(ItemA, 20);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(20, entry.Quantity);
}
[Theory]
[InlineData(0u, 5)]
[InlineData(ItemA, 0)]
[InlineData(ItemA, -1)]
public void AddIgnoresAZeroGuidOrNonPositiveQuantity(uint guid, int quantity)
{
var list = new VendorStagingList();
list.Add(guid, quantity);
Assert.True(list.IsEmpty);
}
[Fact]
public void ChangedFiresOnAddAndNotOnANoOpAdd()
{
var list = new VendorStagingList();
int fired = 0;
list.Changed += () => fired++;
list.Add(ItemA, 5);
Assert.Equal(1, fired);
list.Add(0u, 5); // no-op: zero guid
Assert.Equal(1, fired);
}
// ---- Remove: retail's amount==-1 (0xffffffff) "full removal" sentinel ----
[Fact]
public void RemoveWithNegativeOneAmountRemovesTheWholeEntryRegardlessOfQuantity()
{
var list = new VendorStagingList();
list.Add(ItemA, 100);
bool removed = list.Remove(ItemA, -1);
Assert.True(removed);
Assert.True(list.IsEmpty);
}
[Fact]
public void RemoveWithAnAmountAtOrAboveTheStagedQuantityRemovesTheWholeEntry()
{
var list = new VendorStagingList();
list.Add(ItemA, 5);
Assert.True(list.Remove(ItemA, 5));
Assert.True(list.IsEmpty);
}
[Fact]
public void RemoveWithAPartialAmountDecrementsInPlace()
{
var list = new VendorStagingList();
list.Add(ItemA, 10);
Assert.True(list.Remove(ItemA, 3));
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(ItemA, entry.ItemGuid);
Assert.Equal(7, entry.Quantity);
}
[Fact]
public void RemoveOfAnUnstagedGuidIsANoOp()
{
var list = new VendorStagingList();
list.Add(ItemA, 5);
bool removed = list.Remove(ItemB, -1);
Assert.False(removed);
Assert.Single(list.Entries);
}
[Fact]
public void RemoveOnlyTouchesTheMatchingEntry()
{
var list = new VendorStagingList();
list.Add(ItemA, 5);
list.Add(ItemB, 9);
list.Remove(ItemA, -1);
VendorStagingEntry remaining = Assert.Single(list.Entries);
Assert.Equal(ItemB, remaining.ItemGuid);
Assert.Equal(9, remaining.Quantity);
}
[Fact]
public void TryGetFindsAStagedEntryByGuid()
{
var list = new VendorStagingList();
list.Add(ItemA, 5);
Assert.True(list.TryGet(ItemA, out VendorStagingEntry entry));
Assert.Equal(5, entry.Quantity);
Assert.False(list.TryGet(ItemB, out _));
}
[Fact]
public void ClearRemovesEveryEntryAndFiresChangedOnce()
{
var list = new VendorStagingList();
list.Add(ItemA, 5);
list.Add(ItemB, 9);
int fired = 0;
list.Changed += () => fired++;
list.Clear();
Assert.True(list.IsEmpty);
Assert.Equal(1, fired);
}
[Fact]
public void ClearOnAnAlreadyEmptyListDoesNotFireChanged()
{
var list = new VendorStagingList();
int fired = 0;
list.Changed += () => fired++;
list.Clear();
Assert.Equal(0, fired);
}
}