fix(vendor): 6b/6c review corrections — pre-send guards, accumulating staging, trade-note exemption, drag-over tab switch, full-stack sells
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
All thirteen findings, each anchored in recovered bytes or pc reads:
Buy All now runs retail's four PRE-SEND guards in order (pyreal and
alt-currency affordability, container and item slot capacity; strings
recovered from .rdata at 0x007b57b4/0x007b5750) — a rejected batch can
no longer destroy the staged list. Staged adds ACCUMULATE with the
5000 cap ("I can't possibly sell you that much!..." @0x007b59d8) and
the shop rows decrement/restore per RemoveFromShop. The max-value sell
rejection exempts trade notes — the raw bytes at 0x005d1add are `not`
(bitwise), not the pseudo-C's misleading `!`, and the early ret skips
the min check too. BF_RETAINED gates selling end to end (the bit was
already on ClientObject; AP-164's three claims were all false once
traced — RETIRED). Dragging over the vendor window auto-opens the
Selling tab per UpdateDragOver — with a correction to the review's own
citation: token 0x100000cd is the SELLING page, the guard is
"don't reopen the current tab." Sells are full-stack-only (three
retail sites; "Cannot sell part of a stack" @0x007b57ec) and Sell Item
acts on the global selection unconditionally. The confirm string gains
its byte-true trailing '?', dies with the session, staged-row
highlights repaint, dead guids unstage with retail's shopping-list
notice, and move-to-use no longer walks to targets the dispatch would
refuse.
AP-162 narrowed, AP-164 retired, AP-167/AP-168 filed honest.
Clean-room complete solution: 11,508 passed / 4 skipped / 0 failed.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
92ea3977b6
commit
c68ad1e646
11 changed files with 1314 additions and 99 deletions
File diff suppressed because one or more lines are too long
|
|
@ -231,18 +231,32 @@ internal sealed class SelectionInteractionController
|
||||||
// TurnToObject branch, Use keeps sending immediately either way (the
|
// TurnToObject branch, Use keeps sending immediately either way (the
|
||||||
// existing RuntimeInteractionTransactionState.TryDispatchUse doc
|
// existing RuntimeInteractionTransactionState.TryDispatchUse doc
|
||||||
// comment: "consume the strict 0.2-second gate, send immediately").
|
// comment: "consume the strict 0.2-second gate, send immediately").
|
||||||
if (_query.TryGetApproach(serverGuid, out InteractionApproach approach)
|
//
|
||||||
|
// F11 (Slice 6b/6c review): the eligibility TryDispatchUse itself
|
||||||
|
// gates on (ownedByPlayer || useable) is computed ONCE, up front,
|
||||||
|
// and checked BEFORE BeginApproach — a prior version of this method
|
||||||
|
// called BeginApproach unconditionally whenever the target was out
|
||||||
|
// of close range, kicking off a client-predicted walk toward a
|
||||||
|
// target the dispatch below was always going to refuse anyway (a
|
||||||
|
// wasted, visually confusing approach with no possible Use at the
|
||||||
|
// end of it). Reordering does not change the dispatch itself: an
|
||||||
|
// ELIGIBLE target still sends immediately, in the same order,
|
||||||
|
// exactly as before.
|
||||||
|
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
||||||
|
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);
|
||||||
|
|
||||||
|
if (useable
|
||||||
|
&& _query.TryGetApproach(serverGuid, out InteractionApproach approach)
|
||||||
&& !approach.IsCloseRange)
|
&& !approach.IsCloseRange)
|
||||||
{
|
{
|
||||||
_movement.BeginApproach(approach);
|
_movement.BeginApproach(approach);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
|
|
||||||
RuntimeInteractionDispatchResult result =
|
RuntimeInteractionDispatchResult result =
|
||||||
_transactions.TryDispatchUse(
|
_transactions.TryDispatchUse(
|
||||||
serverGuid,
|
serverGuid,
|
||||||
ownedByPlayer,
|
ownedByPlayer,
|
||||||
ownedByPlayer || _query.IsUseable(serverGuid),
|
useable,
|
||||||
reservation,
|
reservation,
|
||||||
_transport,
|
_transport,
|
||||||
out uint sequence);
|
out uint sequence);
|
||||||
|
|
|
||||||
|
|
@ -303,12 +303,14 @@ public sealed class ItemInteractionController : IDisposable
|
||||||
/// case <c>0x100000ca</c>, <c>pc:204011-204079</c>) — the ONE path that
|
/// 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
|
/// sends a multi-item Buy in a single wire call. Rides the SAME
|
||||||
/// one-request-at-a-time use reservation <see cref="TryBuy"/> does.
|
/// one-request-at-a-time use reservation <see cref="TryBuy"/> does.
|
||||||
/// Retail's client-side affordability/pack-capacity pre-checks
|
/// F1 (Slice 6b/6c review): retail's four client-side affordability/
|
||||||
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported
|
/// pack-capacity pre-checks (<c>pc:204017/204032/204053/204067</c>) are
|
||||||
/// here either, for the same reason <see cref="TryBuy"/>'s own doc
|
/// now ported, but live in the CALLER —
|
||||||
/// comment already gives for the single-item Buy: the server is
|
/// <c>VendorUiController.BuyAllButtonPressed</c> — ahead of this
|
||||||
/// authoritative either way (register AP-162, extended to this batched
|
/// method, not inside it, since they need the staged entries' prices
|
||||||
/// path rather than filing a second near-duplicate row).
|
/// and the player's live capacity, neither of which this method
|
||||||
|
/// otherwise touches. <see cref="TryBuy"/>'s single-item path still has
|
||||||
|
/// none (register AP-162, narrowed to that one remaining case).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TryBuyAll(
|
public bool TryBuyAll(
|
||||||
uint vendorGuid,
|
uint vendorGuid,
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6
|
// X-close confirmation is already up; HandleButtonClicks' 0x100000d6
|
||||||
// case only opens a NEW one when this is 0 (pc:204155).
|
// case only opens a NEW one when this is 0 (pc:204155).
|
||||||
private uint _closeConfirmContext;
|
private uint _closeConfirmContext;
|
||||||
|
// F5: see DragOverGlobalTimeSink's own doc comment.
|
||||||
|
private readonly DragOverGlobalTimeSink _dragOverSink;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
private VendorUiController(
|
private VendorUiController(
|
||||||
|
|
@ -501,6 +503,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
// target. UiItemList.RegisterDragHandler is the structural analogue.
|
// target. UiItemList.RegisterDragHandler is the structural analogue.
|
||||||
_sellingList?.RegisterDragHandler(this);
|
_sellingList?.RegisterDragHandler(this);
|
||||||
|
|
||||||
|
// F5: mount the global-time sink so a live drag hovering anywhere
|
||||||
|
// over this window auto-switches to the Selling tab — see
|
||||||
|
// DragOverGlobalTimeSink's and PollDragOver's own doc comments.
|
||||||
|
_dragOverSink = new DragOverGlobalTimeSink(PollDragOver);
|
||||||
|
_window.ContentRoot.AddChild(_dragOverSink);
|
||||||
|
|
||||||
// F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers
|
// F1 (Slice 5.4 review): wire the dropdown's font/sprite resolvers
|
||||||
// (UiMenu draws nothing without SpriteResolve — see the popup
|
// (UiMenu draws nothing without SpriteResolve — see the popup
|
||||||
// geometry class doc above) and the vendor-authored popup geometry
|
// geometry class doc above) and the vendor-authored popup geometry
|
||||||
|
|
@ -586,6 +594,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
_sellClearListButton.OnClick = () => _sellStaging.Clear();
|
_sellClearListButton.OnClick = () => _sellStaging.Clear();
|
||||||
|
|
||||||
_buyStaging.Changed += RebuildBuyingList;
|
_buyStaging.Changed += RebuildBuyingList;
|
||||||
|
// F2 (Slice 6b/6c review): the Items tab's own row visibility must
|
||||||
|
// track staging too — see RefreshItemsTabAvailability's doc.
|
||||||
|
_buyStaging.Changed += RefreshItemsTabAvailability;
|
||||||
_sellStaging.Changed += RebuildSellingList;
|
_sellStaging.Changed += RebuildSellingList;
|
||||||
|
|
||||||
ShowTab(VendorPanelTab.Items);
|
ShowTab(VendorPanelTab.Items);
|
||||||
|
|
@ -685,9 +696,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
// non-empty (Q3's close-button finding). Optional — absent gracefully
|
// non-empty (Q3's close-button finding). Optional — absent gracefully
|
||||||
// degrades the close gate to a plain hide (see CloseButtonPressed).
|
// degrades the close gate to a plain hide (see CloseButtonPressed).
|
||||||
RetailDialogFactory? dialogs = null,
|
RetailDialogFactory? dialogs = null,
|
||||||
// Slice 6b/6c: InqAcceptability rejection strings + retail's two
|
// Slice 6b/6c: InqAcceptability rejection strings, the Buy-All
|
||||||
// Buy-All affordability/capacity transient errors (deliberately not
|
// affordability/capacity pre-send guards (F1, Slice 6b/6c review),
|
||||||
// ported, register AP-162) share this sink.
|
// and the buy/sell staging notices all share this one sink.
|
||||||
Action<string>? systemMessage = null)
|
Action<string>? systemMessage = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(layout);
|
ArgumentNullException.ThrowIfNull(layout);
|
||||||
|
|
@ -782,6 +793,26 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
|
|
||||||
private enum VendorPanelTab { Items, Buying, Selling }
|
private enum VendorPanelTab { Items, Buying, Selling }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F5 (Slice 6b/6c review): a runtime-only, zero-size, always-invisible-
|
||||||
|
/// to-hit-testing helper that opts this window into retail's global UI
|
||||||
|
/// message 3 — <c>gmVendorUI::ListenToGlobalMessage</c> (<c>0x004c0480</c>):
|
||||||
|
/// <c>if (arg2 == 3) gmVendorUI::UpdateDragOver(this);</c>. This
|
||||||
|
/// controller is not itself a <see cref="UiElement"/> (it wraps
|
||||||
|
/// several), so it cannot directly implement <see cref="IUiGlobalTimeListener"/>
|
||||||
|
/// the way <see cref="UiButton"/> does — <see cref="UiRoot.Tick"/>'s
|
||||||
|
/// broadcast walks the ELEMENT tree, not arbitrary controllers. Mounting
|
||||||
|
/// this tiny sink as a child of the window gives it the same periodic
|
||||||
|
/// pulse retail's own <c>UIElementManager::UseTime</c> delivers, without
|
||||||
|
/// adding a second per-frame plumbing path.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class DragOverGlobalTimeSink : UiElement, IUiGlobalTimeListener
|
||||||
|
{
|
||||||
|
private readonly Action _onGlobalUiTime;
|
||||||
|
public DragOverGlobalTimeSink(Action onGlobalUiTime) => _onGlobalUiTime = onGlobalUiTime;
|
||||||
|
public void OnGlobalUiTime(double nowSeconds) => _onGlobalUiTime();
|
||||||
|
}
|
||||||
|
|
||||||
private void ShowTab(VendorPanelTab tab)
|
private void ShowTab(VendorPanelTab tab)
|
||||||
{
|
{
|
||||||
_itemsPage.Visible = tab == VendorPanelTab.Items;
|
_itemsPage.Visible = tab == VendorPanelTab.Items;
|
||||||
|
|
@ -841,6 +872,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
ClearContent();
|
ClearContent();
|
||||||
ShowTab(VendorPanelTab.Items);
|
ShowTab(VendorPanelTab.Items);
|
||||||
_window.Hide();
|
_window.Hide();
|
||||||
|
// F8 (Slice 6b/6c review): a live X-close confirmation
|
||||||
|
// dialog must not survive the session it was confirming
|
||||||
|
// the abandonment of — a range-triggered Close() or a
|
||||||
|
// portal/logout Reset() while the dialog is up left it
|
||||||
|
// dangling (a stale callback capturing this disposed
|
||||||
|
// controller's state) before this fix.
|
||||||
|
DismissCloseConfirmationIfOpen();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -946,7 +984,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
/// field, so the toolbar status bar and slider light up for the
|
/// field, so the toolbar status bar and slider light up for the
|
||||||
/// auto-selected item too.
|
/// auto-selected item too.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void RebuildItemList()
|
private void RebuildItemList() => RebuildItemList(reselectFirst: true);
|
||||||
|
|
||||||
|
/// <param name="reselectFirst">
|
||||||
|
/// F2 (Slice 6b/6c review): <see langword="true"/> for every ORIGINAL
|
||||||
|
/// call site (category rebuild — Opened/Refreshed/manual category
|
||||||
|
/// switch, retail's notify=1 case per the remarks above).
|
||||||
|
/// <see langword="false"/> for the NEW staging-triggered repaint this
|
||||||
|
/// review added (<see cref="RefreshItemsTabAvailability"/>) — retail's
|
||||||
|
/// <c>RemoveFromShop</c>/<c>DeleteItem</c> (the function that actually
|
||||||
|
/// hides/shrinks a row as staging consumes it) never reselects to the
|
||||||
|
/// first item; only a full <c>UpdateItemsList</c> rebuild does that, and
|
||||||
|
/// staging a Buy does not trigger one.
|
||||||
|
/// </param>
|
||||||
|
private void RebuildItemList(bool reselectFirst)
|
||||||
{
|
{
|
||||||
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
|
ItemType activeMask = _selectedCategoryIndex >= 0 && _selectedCategoryIndex < _presentCategories.Count
|
||||||
? _presentCategories[_selectedCategoryIndex].Mask
|
? _presentCategories[_selectedCategoryIndex].Mask
|
||||||
|
|
@ -963,6 +1014,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
// Changed to correct it).
|
// Changed to correct it).
|
||||||
uint? selectedGuid = _selection.SelectedObjectId;
|
uint? selectedGuid = _selection.SelectedObjectId;
|
||||||
VendorShopItem? firstItem = null;
|
VendorShopItem? firstItem = null;
|
||||||
|
bool selectedStillVisible = false;
|
||||||
|
|
||||||
using (_itemList.DeferLayout())
|
using (_itemList.DeferLayout())
|
||||||
{
|
{
|
||||||
|
|
@ -972,8 +1024,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
foreach (VendorShopItem item in items)
|
foreach (VendorShopItem item in items)
|
||||||
{
|
{
|
||||||
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
|
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
|
||||||
|
// F2 (Slice 6b/6c review): a shop item fully consumed by
|
||||||
|
// staging hides its row — port of
|
||||||
|
// VendorItemsUI::RemoveFromShop's DeleteItem branch
|
||||||
|
// (pc:202846-202852) — see AvailableShopQuantity's own
|
||||||
|
// doc comment.
|
||||||
|
if (AvailableShopQuantity(item) <= 0) continue;
|
||||||
|
|
||||||
firstItem ??= item;
|
firstItem ??= item;
|
||||||
|
if (item.ItemGuid == selectedGuid) selectedStillVisible = true;
|
||||||
|
|
||||||
// F5 (Slice 5.4 review): forward the icon underlay/
|
// F5 (Slice 5.4 review): forward the icon underlay/
|
||||||
// overlay/effects PublicWeenieDescParser already
|
// overlay/effects PublicWeenieDescParser already
|
||||||
|
|
@ -1004,21 +1063,68 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// F6: unconditional — no survival test. Every rebuild call site
|
if (reselectFirst)
|
||||||
// (Opened/Refreshed via RebuildCategories, and a manual category
|
{
|
||||||
// switch via SelectCategory) is retail's notify=1 case; see the
|
// F6: unconditional — no survival test. Every rebuild call site
|
||||||
// remarks above for the decomp trace.
|
// (Opened/Refreshed via RebuildCategories, and a manual category
|
||||||
if (firstItem is { } first)
|
// switch via SelectCategory) is retail's notify=1 case; see the
|
||||||
_selection.Select(first.ItemGuid, SelectionChangeSource.Vendor);
|
// remarks above for the decomp trace.
|
||||||
else
|
if (firstItem is { } first)
|
||||||
_selection.Clear(SelectionChangeSource.Vendor);
|
_selection.Select(first.ItemGuid, SelectionChangeSource.Vendor);
|
||||||
|
else
|
||||||
|
_selection.Clear(SelectionChangeSource.Vendor);
|
||||||
|
|
||||||
// F7a: unconditional scroll-to-start on every rebuild (retail only
|
// F7a: unconditional scroll-to-start on every rebuild (retail only
|
||||||
// guards on the list being non-empty; resetting an already-empty
|
// guards on the list being non-empty; resetting an already-empty
|
||||||
// list's scroll is harmless).
|
// list's scroll is harmless).
|
||||||
_itemList.Scroll.SetScrollY(0);
|
_itemList.Scroll.SetScrollY(0);
|
||||||
|
}
|
||||||
|
else if (selectedGuid is not null && !selectedStillVisible)
|
||||||
|
{
|
||||||
|
// F2: the currently-selected row just disappeared (staging
|
||||||
|
// consumed its last unit) — matches RemoveFromShop's own
|
||||||
|
// unconditional SetSelectedObject(0, 0) on the delete path
|
||||||
|
// (pc:202848-202850), NOT a reselect-to-first.
|
||||||
|
_selection.Clear(
|
||||||
|
SelectionChangeSource.Vendor,
|
||||||
|
SelectionChangeReason.SelectedObjectRemoved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F2 (Slice 6b/6c review): retail's <c>RemoveFromShop</c>
|
||||||
|
/// (<c>0x004c3ce0</c>) recomputes "shop remaining = ORIGINAL vendor
|
||||||
|
/// supply - the item's CURRENT total staged amount" fresh on every call
|
||||||
|
/// (<c>pc:202844</c>: <c>_maxStackSize_1 = var_c - arg3</c>, where
|
||||||
|
/// <c>var_c</c> is read fresh from the untouched <c>shopItemProfileList</c>
|
||||||
|
/// and <c>arg3</c> is <c>AddToBuyList</c>'s own running accumulated
|
||||||
|
/// total) rather than incrementally decrementing a mutable counter.
|
||||||
|
/// This mirrors that shape exactly: <see cref="VendorState.Items"/>
|
||||||
|
/// (the untouched <c>ApproachVendor</c> snapshot) combined with
|
||||||
|
/// <see cref="_buyStaging"/>'s current total, recomputed on every call
|
||||||
|
/// — so un-staging automatically restores a hidden/shrunk row with no
|
||||||
|
/// separate "restore" code path. <see cref="int.MaxValue"/> for
|
||||||
|
/// unlimited supply (<see cref="VendorShopItem.StackSize"/> == -1,
|
||||||
|
/// retail's <c>ItemProfile</c> sign-extended unlimited sentinel) —
|
||||||
|
/// retail's own <c>var_c != 0xffffffff</c> guard skips the whole
|
||||||
|
/// decrement/delete branch for unlimited stock.
|
||||||
|
/// </summary>
|
||||||
|
private int AvailableShopQuantity(VendorShopItem item)
|
||||||
|
{
|
||||||
|
if (item.StackSize < 0)
|
||||||
|
return int.MaxValue;
|
||||||
|
int staged = _buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry) ? entry.Quantity : 0;
|
||||||
|
return item.StackSize - staged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F2 (Slice 6b/6c review): repaints the Items tab's row visibility
|
||||||
|
/// whenever <see cref="_buyStaging"/> changes, WITHOUT the
|
||||||
|
/// reselect-to-first behavior a full category rebuild performs — see
|
||||||
|
/// <see cref="RebuildItemList(bool)"/>'s <c>reselectFirst</c> doc.
|
||||||
|
/// </summary>
|
||||||
|
private void RefreshItemsTabAvailability() => RebuildItemList(reselectFirst: false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Port of retail row selection display —
|
/// Port of retail row selection display —
|
||||||
/// <c>VendorItemsUI::UpdateItemsUI</c> (<c>0x004C38E0</c>,
|
/// <c>VendorItemsUI::UpdateItemsUI</c> (<c>0x004C38E0</c>,
|
||||||
|
|
@ -1115,6 +1221,35 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
{
|
{
|
||||||
_ = transition;
|
_ = transition;
|
||||||
RefreshSelectionDisplay();
|
RefreshSelectionDisplay();
|
||||||
|
// F9 (Slice 6b/6c review): a click on an already-staged Buying/
|
||||||
|
// Selling row calls SelectionState.Select the same way an Items-tab
|
||||||
|
// row does (RebuildBuyingList/RebuildSellingList's own Clicked
|
||||||
|
// handlers), but those two lists are only ever REBUILT when their
|
||||||
|
// OWN staging list changes -- a pure selection change (no staging
|
||||||
|
// mutation) never repainted their cell.Selected flags, so the
|
||||||
|
// highlight silently failed to move onto a staged row. Update both
|
||||||
|
// strips' highlight in place on every selection transition, no
|
||||||
|
// matter which panel originated it -- mirrors RefreshSelectionDisplay's
|
||||||
|
// own "react to ANY global selection change" shape.
|
||||||
|
RefreshStagingSelectionHighlight();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>See <see cref="OnSelectionTransition"/>'s F9 note.</summary>
|
||||||
|
private void RefreshStagingSelectionHighlight()
|
||||||
|
{
|
||||||
|
uint? selected = _selection.SelectedObjectId;
|
||||||
|
SetHighlight(_buyingList, selected);
|
||||||
|
SetHighlight(_sellingList, selected);
|
||||||
|
|
||||||
|
static void SetHighlight(UiItemList? list, uint? selectedGuid)
|
||||||
|
{
|
||||||
|
if (list is null) return;
|
||||||
|
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||||
|
{
|
||||||
|
if (list.GetItem(i) is { } cell)
|
||||||
|
cell.Selected = cell.ItemId == selectedGuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1158,6 +1293,33 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
/// (Slice 6.1) is therefore what actually drives "vendor session close
|
/// (Slice 6.1) is therefore what actually drives "vendor session close
|
||||||
/// clears a vendor-owned selection."
|
/// clears a vendor-owned selection."
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// F10 (Slice 6b/6c review): retail's two unstage-on-dispossession
|
||||||
|
/// sites, both reachable through this SAME <see cref="ClientObjectTable.ObjectRemoved"/>
|
||||||
|
/// notification in this architecture.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Sell side</b> — <c>gmVendorUI::RecvNotice_ServerSaysMoveItem</c>
|
||||||
|
/// (<c>0x004c44a0</c>): a staged SELL item silently unstages once
|
||||||
|
/// <c>ACCWeenieObject::IsOwnedByPlayer</c> goes false for it (no notice
|
||||||
|
/// shown). The closest reachable proxy here is "the item left
|
||||||
|
/// <see cref="ClientObjectTable"/> entirely" — a strictly narrower
|
||||||
|
/// trigger than retail's "moved to ANY non-player container," but the
|
||||||
|
/// only one an item leaving the table for real (destroyed, traded away
|
||||||
|
/// and never re-registered, sold through a different path) reaches.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Buy side</b> — <c>VendorItemsUI</c>'s shop-list-removal notice
|
||||||
|
/// (<c>0x004c4246</c>, inside <c>gmVendorUI::HandleMousePresses</c>,
|
||||||
|
/// <c>pc:203165</c>: <c>"Removing %s from shopping list"</c>). A staged
|
||||||
|
/// BUY guid that drops out of the vendor's CURRENT stock (sold out by
|
||||||
|
/// someone else, or a different vendor superseded this session) is
|
||||||
|
/// retired from <see cref="ClientObjectTable"/> by
|
||||||
|
/// <c>VendorShopItemMaterializer</c>'s own retire pass on every
|
||||||
|
/// Opened/Refreshed/Closed/Reset transition — which fires this SAME
|
||||||
|
/// event, giving one unified site for both retail sources instead of a
|
||||||
|
/// separate "diff the vendor's item list" listener.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
private void OnObjectRemoved(ClientObject item)
|
private void OnObjectRemoved(ClientObject item)
|
||||||
{
|
{
|
||||||
if (_selection.SelectedObjectId == item.ObjectId)
|
if (_selection.SelectedObjectId == item.ObjectId)
|
||||||
|
|
@ -1166,6 +1328,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
SelectionChangeSource.Vendor,
|
SelectionChangeSource.Vendor,
|
||||||
SelectionChangeReason.SelectedObjectRemoved);
|
SelectionChangeReason.SelectedObjectRemoved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sell side: silent, matching retail's own RecvNotice_ServerSaysMoveItem.
|
||||||
|
_sellStaging.Remove(item.ObjectId, -1);
|
||||||
|
|
||||||
|
// Buy side: retail's exact "Removing %s from shopping list" notice.
|
||||||
|
if (_buyStaging.Remove(item.ObjectId, -1))
|
||||||
|
{
|
||||||
|
string name = string.IsNullOrWhiteSpace(item.Name) ? "that item" : item.Name;
|
||||||
|
_systemMessage?.Invoke($"Removing {name} from shopping list");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1350,6 +1522,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
/// SAME quantity computation the display and Buy button already share
|
/// SAME quantity computation the display and Buy button already share
|
||||||
/// (F2) — into the "Buying" tab's list. Sends NOTHING to the server
|
/// (F2) — into the "Buying" tab's list. Sends NOTHING to the server
|
||||||
/// (<c>VendorItemsUI::AddToBuyList</c> is purely client-local).
|
/// (<c>VendorItemsUI::AddToBuyList</c> is purely client-local).
|
||||||
|
/// F2 (Slice 6b/6c review): re-adding an already-staged item ACCUMULATES
|
||||||
|
/// rather than overwrites, and retail's 5000-unit cap on that
|
||||||
|
/// accumulate shows <see cref="VendorStagingList.TooMuchMessage"/> and
|
||||||
|
/// leaves the entry unchanged — see <see cref="VendorStagingList.Add"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void AddSelectedToBuyList()
|
private void AddSelectedToBuyList()
|
||||||
{
|
{
|
||||||
|
|
@ -1357,7 +1533,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
return;
|
return;
|
||||||
|
|
||||||
uint quantity = ResolveBuyQuantity(shopItem);
|
uint quantity = ResolveBuyQuantity(shopItem);
|
||||||
_buyStaging.Add(shopItem.ItemGuid, (int)quantity);
|
if (_buyStaging.Add(shopItem.ItemGuid, (int)quantity) == VendorStagingAddOutcome.Capped)
|
||||||
|
_systemMessage?.Invoke(VendorStagingList.TooMuchMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1402,16 +1579,64 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's exact affordability-failure notice — read from the
|
||||||
|
/// decompiled binary's data segment at <c>0x007b57b4</c> (BuyAllButtonPressed's
|
||||||
|
/// two guards below share this SAME string; see the class doc).
|
||||||
|
/// </summary>
|
||||||
|
private const string NotEnoughMoneyMessage = "You don't have enough money";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's exact capacity-failure notice — read from the decompiled
|
||||||
|
/// binary's data segment at <c>0x007b5750</c> (BuyAllButtonPressed's
|
||||||
|
/// two capacity guards below share this SAME string; see the class doc).
|
||||||
|
/// </summary>
|
||||||
|
private const string NotEnoughRoomMessage = "You must empty some slots in your backpack first";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Slice 6b: "Buying" tab's "Buy All" — retail case <c>0x100000ca</c>
|
/// Slice 6b: "Buying" tab's "Buy All" — retail case <c>0x100000ca</c>
|
||||||
/// (<c>pc:204011-204079</c>). Sends every staged entry as ONE batched
|
/// (<c>pc:204011-204079</c>, <c>0x004c5388</c>). Sends every staged
|
||||||
/// Buy (<see cref="ItemInteractionController.TryBuyAll"/>). Retail's
|
/// entry as ONE batched Buy (<see cref="ItemInteractionController.TryBuyAll"/>).
|
||||||
/// client-side affordability/pack-capacity pre-checks
|
/// <para>
|
||||||
/// (<c>pc:204017/204032/204053/204067</c>) are deliberately NOT ported —
|
/// F1 (Slice 6b/6c review): retail's FOUR client-side pre-send guards
|
||||||
/// see <c>TryBuyAll</c>'s own doc comment (register AP-162, extended
|
/// are ported here, in order, each returning with staging fully
|
||||||
/// rather than duplicated). On a successful DISPATCH the whole staged
|
/// intact on failure (amends AP-162's "no client-side pre-check"
|
||||||
/// list is flushed UNCONDITIONALLY and immediately, matching retail's
|
/// claim — see the register):
|
||||||
/// literal order: <c>SendShopEvent(...)</c> then
|
/// </para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>pyreal affordability — transaction total vs. purse
|
||||||
|
/// (<c>pc:204017</c>: <c>m_transactionValue <= m_totalValue</c>).</item>
|
||||||
|
/// <item>alt-currency affordability — vs. held trade currency minus
|
||||||
|
/// <c>m_last_sale</c> (<c>pc:204032</c>). This session tracks no
|
||||||
|
/// <c>m_last_sale</c> credit yet (see the register's AP-161 residual),
|
||||||
|
/// so this uses the vendor's raw held count, retail's own
|
||||||
|
/// <c>m_last_sale == 0</c> case.</item>
|
||||||
|
/// <item>container-slot capacity (<c>pc:204053</c>:
|
||||||
|
/// <c>containerSlotsNeeded > player.ContainersCapacity - containersUsed</c>).</item>
|
||||||
|
/// <item>item-slot capacity (<c>pc:204067</c>: the same shape for
|
||||||
|
/// item slots).</item>
|
||||||
|
/// </list>
|
||||||
|
/// Both affordability guards share <see cref="NotEnoughMoneyMessage"/>;
|
||||||
|
/// both capacity guards share <see cref="NotEnoughRoomMessage"/> —
|
||||||
|
/// retail's own two distinct <c>StringInfo</c> literal sites collapse
|
||||||
|
/// to exactly these two strings (<c>pc:204020</c>/<c>204034</c> both
|
||||||
|
/// reference <c>0x007b57b4</c>; <c>pc:204056</c>/<c>204068</c> both
|
||||||
|
/// reference <c>0x007b5750</c> via the shared <c>label_4c5509</c>).
|
||||||
|
/// <para>
|
||||||
|
/// <b>Container-vs-item slot classification (register AP-168).</b>
|
||||||
|
/// Retail's own split tests a bitfield bit this codebase does not
|
||||||
|
/// currently thread onto <see cref="VendorShopItem"/>
|
||||||
|
/// (<c>gmVendorUI::InqListSlotCount</c>, <c>pc:200038-200065</c>) — this
|
||||||
|
/// port approximates "is this shop item a container" with
|
||||||
|
/// <see cref="ItemType.Container"/> instead, correct for the ordinary
|
||||||
|
/// case (a real backpack/pouch DOES carry that type bit) but not
|
||||||
|
/// byte-identical for the theoretical case of a non-<c>Container</c>-typed
|
||||||
|
/// item that still authors nonzero pack/side capacities. See the
|
||||||
|
/// register.
|
||||||
|
/// </para>
|
||||||
|
/// On a successful DISPATCH the whole staged list is flushed
|
||||||
|
/// UNCONDITIONALLY and immediately, matching retail's literal order:
|
||||||
|
/// <c>SendShopEvent(...)</c> then
|
||||||
/// <c>PackableList::Flush(&m_buyList)</c> (<c>pc:204075-204076</c>) —
|
/// <c>PackableList::Flush(&m_buyList)</c> (<c>pc:204075-204076</c>) —
|
||||||
/// the clear happens right after the send, not gated on the eventual
|
/// the clear happens right after the send, not gated on the eventual
|
||||||
/// server response/UseDone.
|
/// server response/UseDone.
|
||||||
|
|
@ -1425,10 +1650,105 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
||||||
items.Add((entry.Quantity, entry.ItemGuid));
|
items.Add((entry.Quantity, entry.ItemGuid));
|
||||||
|
|
||||||
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, _vendor.Profile.AlternateCurrencyWcid))
|
VendorShopProfile profile = _vendor.Profile;
|
||||||
|
int transactionValue = ComputeBuyTransactionValue();
|
||||||
|
|
||||||
|
// Guards 1/2: pyreal vs. alt-currency affordability.
|
||||||
|
if (profile.AlternateCurrencyWcid == 0u)
|
||||||
|
{
|
||||||
|
int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0;
|
||||||
|
if (transactionValue > playerTotal)
|
||||||
|
{
|
||||||
|
_systemMessage?.Invoke(NotEnoughMoneyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (transactionValue > (int)profile.AlternateCurrencyAmount)
|
||||||
|
{
|
||||||
|
_systemMessage?.Invoke(NotEnoughMoneyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guards 3/4: container-slot then item-slot capacity.
|
||||||
|
(int itemSlotsNeeded, int containerSlotsNeeded) = ComputeBuySlotsNeeded(items);
|
||||||
|
ClientObject? player = _objects.Get(_playerGuid());
|
||||||
|
(int itemsUsed, int containersUsed) = CountPlayerContents();
|
||||||
|
|
||||||
|
int freeContainerSlots = (player?.ContainersCapacity ?? 0) - containersUsed;
|
||||||
|
if (containerSlotsNeeded > freeContainerSlots)
|
||||||
|
{
|
||||||
|
_systemMessage?.Invoke(NotEnoughRoomMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int freeItemSlots = (player?.ItemsCapacity ?? 0) - itemsUsed;
|
||||||
|
if (itemSlotsNeeded > freeItemSlots)
|
||||||
|
{
|
||||||
|
_systemMessage?.Invoke(NotEnoughRoomMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid))
|
||||||
_buyStaging.Clear();
|
_buyStaging.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>F1: the SAME per-row price formula <see cref="ApplyItemDisplay"/> shows, summed over every staged entry.</summary>
|
||||||
|
private int ComputeBuyTransactionValue()
|
||||||
|
{
|
||||||
|
VendorShopProfile profile = _vendor.Profile;
|
||||||
|
int total = 0;
|
||||||
|
foreach (VendorStagingEntry entry in _buyStaging.Entries)
|
||||||
|
{
|
||||||
|
if (!TryFindShopItem(entry.ItemGuid, out VendorShopItem item))
|
||||||
|
continue;
|
||||||
|
int perUnit = VendorPricing.PerUnitValue(item.Value ?? 0, item.DescStackSize);
|
||||||
|
total += VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, entry.Quantity);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F1: port of <c>gmVendorUI::InqListSlotCount</c> (<c>pc:200038-200065</c>,
|
||||||
|
/// <c>0x004c0c10</c>) — see <see cref="BuyAllButtonPressed"/>'s own doc
|
||||||
|
/// comment for the container-classification approximation.
|
||||||
|
/// </summary>
|
||||||
|
private (int ItemSlots, int ContainerSlots) ComputeBuySlotsNeeded(
|
||||||
|
IReadOnlyList<(int Amount, uint ItemGuid)> items)
|
||||||
|
{
|
||||||
|
int itemSlots = 0, containerSlots = 0;
|
||||||
|
foreach ((int amount, uint guid) in items)
|
||||||
|
{
|
||||||
|
if (!TryFindShopItem(guid, out VendorShopItem item))
|
||||||
|
continue;
|
||||||
|
bool isContainer = ((item.ItemType ?? 0u) & (uint)ItemType.Container) != 0u;
|
||||||
|
bool stackable = (item.DescStackSize ?? 1) > 1;
|
||||||
|
if (stackable)
|
||||||
|
{
|
||||||
|
if (isContainer) containerSlots += 1; else itemSlots += 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (isContainer) containerSlots += amount; else itemSlots += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (itemSlots, containerSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>F1: the player's CURRENT occupied item/container slot counts.</summary>
|
||||||
|
private (int Items, int Containers) CountPlayerContents()
|
||||||
|
{
|
||||||
|
int items = 0, containers = 0;
|
||||||
|
foreach (uint guid in _objects.GetContents(_playerGuid()))
|
||||||
|
{
|
||||||
|
ClientObject? obj = _objects.Get(guid);
|
||||||
|
bool isContainer = obj is not null
|
||||||
|
&& (obj.ItemsCapacity > 0
|
||||||
|
|| obj.ContainersCapacity > 0
|
||||||
|
|| (obj.Type & ItemType.Container) != 0);
|
||||||
|
if (isContainer) containers++; else items++;
|
||||||
|
}
|
||||||
|
return (items, containers);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Slice 6b: "Buying" tab's "Clear Item" — retail case <c>0x100000cb</c>
|
/// Slice 6b: "Buying" tab's "Clear Item" — retail case <c>0x100000cb</c>
|
||||||
/// (<c>pc:204080-204094</c>). The EXACT SAME removal call "Buy Item"
|
/// (<c>pc:204080-204094</c>). The EXACT SAME removal call "Buy Item"
|
||||||
|
|
@ -1446,25 +1766,74 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
_buyStaging.Remove(guid, amount);
|
_buyStaging.Remove(guid, amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's exact refusal for a partial-stack Sell Item attempt,
|
||||||
|
/// read from the decompiled binary's data segment at
|
||||||
|
/// <c>0x007b57ec</c> (<c>gmVendorUI::SellSingleItem</c>,
|
||||||
|
/// <c>pc:201860-201864</c>).
|
||||||
|
/// </summary>
|
||||||
|
private const string CannotSellPartialStackMessage = "Cannot sell part of a stack";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Slice 6c: "Selling" tab's "Sell Item" — retail case <c>0x100000d2</c>
|
/// Slice 6c: "Selling" tab's "Sell Item" — retail case <c>0x100000d2</c>
|
||||||
/// (<c>pc:204101-204112</c>). Sells the globally-selected staged item
|
/// (<c>pc:204101-204112</c>), calling <c>gmVendorUI::SellSingleItem</c>
|
||||||
/// immediately (a one-entry <see cref="ItemInteractionController.TrySell"/>
|
/// (<c>pc:201808-201881</c>, <c>0x004c2b40</c>).
|
||||||
/// list using the entry's OWN staged quantity — Sell has no separate
|
/// <para>
|
||||||
/// global-slider read the way Buy does), then on a successful dispatch
|
/// F13 (Slice 6b/6c review): retail reads <c>ACCWeenieObject::selectedID</c>
|
||||||
/// removes the ENTIRE staged entry unconditionally — retail always
|
/// UNCONDITIONALLY — the GLOBAL selection, with NO "is this guid
|
||||||
/// passes <c>0xffffffff</c> here (<c>pc:204108</c>), unlike the Buy
|
/// actually staged" requirement at all. A prior version of this port
|
||||||
/// side's stackable-conditional amount.
|
/// required a matching <see cref="_sellStaging"/> entry first; that
|
||||||
|
/// gate does not exist in retail (you can Sell Item something you
|
||||||
|
/// never dragged onto the Selling tab, exactly like the Items tab's
|
||||||
|
/// own single-item Buy button operates on the selection with no
|
||||||
|
/// staging requirement either).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// F6 (Slice 6b/6c review, byte-verified): <c>SellSingleItem</c>
|
||||||
|
/// refuses a PARTIAL stack — the selected item's own split slider must
|
||||||
|
/// show the FULL stack (or the item must be non-stackable), else it
|
||||||
|
/// shows <see cref="CannotSellPartialStackMessage"/> and sends NOTHING
|
||||||
|
/// (<c>pc:201833-201864</c>: <c>_stackSize<=1 || splitSize>=maxSplitSize</c>
|
||||||
|
/// gates the send). On success it sends amount <c>1</c> LITERALLY
|
||||||
|
/// (<c>var_9c = 1</c>, <c>pc:201838</c>) — not the stack size — matching
|
||||||
|
/// retail's own send exactly rather than <see cref="VendorStagingEntry.Quantity"/>.
|
||||||
|
/// This method does NOT re-run <see cref="EvaluateSellAcceptability"/>'s
|
||||||
|
/// ownership/type/value gate — retail's own <c>SellSingleItem</c>
|
||||||
|
/// doesn't either at this call site (that gate is drag-time only,
|
||||||
|
/// <c>VendorSellUI::DragItemAcceptable</c>); the server remains
|
||||||
|
/// authoritative for a selection that was never legitimately
|
||||||
|
/// draggable.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Retail's own non-empty-container refusal branch inside
|
||||||
|
/// <c>SellSingleItem</c> (<c>pc:201818-201829</c>, a container-type
|
||||||
|
/// item with contents currently blocks a Sell Item attempt on the
|
||||||
|
/// CONTAINER itself with a distinct message) is NOT ported here — see
|
||||||
|
/// the register.
|
||||||
|
/// </para>
|
||||||
|
/// On a successful dispatch the item's own staged entry (if any) is
|
||||||
|
/// still removed unconditionally, mirroring retail's own
|
||||||
|
/// <c>RemoveProfileFromList(&m_sellList, selectedID, 0xffffffff)</c>
|
||||||
|
/// (<c>pc:204108</c>), which runs regardless of whether the sold item
|
||||||
|
/// was ever actually staged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void SellItemButtonPressed()
|
private void SellItemButtonPressed()
|
||||||
{
|
{
|
||||||
if (_selection.SelectedObjectId is not { } guid
|
if (_selection.SelectedObjectId is not { } guid || _objects.Get(guid) is not { } item)
|
||||||
|| !_sellStaging.TryGet(guid, out VendorStagingEntry entry))
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
||||||
|
if (fullStack > 1)
|
||||||
|
{
|
||||||
|
uint live = _splitQuantity.GetObjectSplitSize(guid, guid, fullStack);
|
||||||
|
if (live < fullStack)
|
||||||
|
{
|
||||||
|
_systemMessage?.Invoke(CannotSellPartialStackMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (entry.Quantity, guid) }))
|
if (_itemInteraction.TrySell(_vendor.VendorId, new[] { (1, guid) }))
|
||||||
_sellStaging.Remove(guid, -1);
|
_sellStaging.Remove(guid, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1617,6 +1986,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
: ItemDragAcceptance.Reject;
|
: ItemDragAcceptance.Reject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F5 (Slice 6b/6c review): port of <c>gmVendorUI::UpdateDragOver</c>
|
||||||
|
/// (<c>0x004c03b0</c>, <c>pc:199542-199553</c>), driven by
|
||||||
|
/// <see cref="DragOverGlobalTimeSink"/>'s periodic pulse. While the
|
||||||
|
/// window is visible, the Selling page is NOT already the open one, and
|
||||||
|
/// a drag is currently live anywhere in the whole UI (retail's
|
||||||
|
/// <c>UIElementManager::s_pInstance->m_dragElement != 0</c>, here
|
||||||
|
/// <see cref="UiRoot.DragSource"/>), retail opens the Selling tab the
|
||||||
|
/// instant the pointer enters the WINDOW'S bounds — not any specific
|
||||||
|
/// list — making <see cref="_sellingList"/> a reachable drop target
|
||||||
|
/// without the player manually clicking the tab first.
|
||||||
|
/// <para>
|
||||||
|
/// <b>Citation correction.</b> Retail's own guard token is
|
||||||
|
/// <c>m_OpenPageToken != 0x100000cd</c> — the review that flagged this
|
||||||
|
/// finding described <c>0x100000cd</c> as "the Buying page," but this
|
||||||
|
/// controller's own <see cref="SellingPageId"/> constant is
|
||||||
|
/// <c>0x100000CD</c>, not <see cref="BuyingPageId"/>
|
||||||
|
/// (<c>0x100000C4</c>). The guard is "don't reopen the tab you're
|
||||||
|
/// already on," checked against the SELLING page specifically —
|
||||||
|
/// <see cref="_sellingPage"/>'s own <c>Visible</c> flag is the exact
|
||||||
|
/// analogue.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private void PollDragOver()
|
||||||
|
{
|
||||||
|
if (_sellingPage.Visible) return;
|
||||||
|
if (!_window.IsVisible) return;
|
||||||
|
|
||||||
|
UiRoot? root = _itemsPage.FindRoot();
|
||||||
|
if (root?.DragSource is null) return;
|
||||||
|
|
||||||
|
System.Numerics.Vector2 pos = _window.OuterFrame.ScreenPosition;
|
||||||
|
float x0 = pos.X, y0 = pos.Y;
|
||||||
|
float x1 = x0 + _window.OuterFrame.Width, y1 = y0 + _window.OuterFrame.Height;
|
||||||
|
if (root.MouseX > x0 && root.MouseX < x1 && root.MouseY > y0 && root.MouseY < y1)
|
||||||
|
ShowTab(VendorPanelTab.Selling);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Port of <c>VendorSellUI::AcceptDragObject</c> (<c>pc:203866-203905</c>,
|
/// Port of <c>VendorSellUI::AcceptDragObject</c> (<c>pc:203866-203905</c>,
|
||||||
/// the non-silent release path that calls <c>DragItemAcceptable</c> with
|
/// the non-silent release path that calls <c>DragItemAcceptable</c> with
|
||||||
|
|
@ -1652,19 +2059,21 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
/// fed from the dragged item's own <see cref="ClientObject"/> state and
|
/// fed from the dragged item's own <see cref="ClientObject"/> state and
|
||||||
/// the open vendor's <see cref="VendorShopProfile"/> merchandise terms.
|
/// the open vendor's <see cref="VendorShopProfile"/> merchandise terms.
|
||||||
/// <paramref name="quantity"/> is the staged quantity a successful drop
|
/// <paramref name="quantity"/> is the staged quantity a successful drop
|
||||||
/// would use: 1 for a non-stack item, else the LIVE split-quantity
|
/// would use.
|
||||||
/// slider value for the dragged item — the SAME read
|
/// <para>
|
||||||
/// <c>ExternalContainerController.HandleDropRelease</c> already uses
|
/// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's
|
||||||
/// (<c>StackSplitQuantityState.GetObjectSplitSize</c> only returns the
|
/// FULL current stack — retail's <c>VendorSellUI::AddItemToSell</c>
|
||||||
/// slider value for the CURRENTLY selected item; both
|
/// (<c>pc:203546-203567</c>) stages via <c>gmVendorUI::AddItem(...,
|
||||||
/// <c>InventoryController</c> and <c>PaperdollController</c> already
|
/// itemGuid, -1, ...)</c>, a LITERAL <c>-1</c> "full stack" sentinel
|
||||||
/// select the dragged item on LIFT — <c>ItemList_BeginDrag @
|
/// argument, never a slider read. A prior version of this port read the
|
||||||
/// 0x004E32D0</c> — so by the time a drop reaches here the dragged item
|
/// LIVE split-quantity slider here instead (the Slice 6b/6c research
|
||||||
/// is already the global selection in the realistic flow). The Slice
|
/// doc's Q4 section had flagged this exact source as an unverified
|
||||||
/// 6b/6c research doc's Q4 section flags this exact quantity source as
|
/// inferred analogy to the Buying tab's <c>AddToBuyList</c>) — that
|
||||||
/// an inferred analogy to the Buying tab's <c>AddToBuyList</c>, not a
|
/// inference is now known WRONG: Sell staging has no partial-quantity
|
||||||
/// byte-verified citation for the Selling side specifically — see
|
/// feature in retail at all, unlike Buy. See
|
||||||
/// <c>VendorStagingList.Add</c>'s own doc comment.
|
/// <c>VendorStagingList.Add</c>'s own doc comment for the Buy side's
|
||||||
|
/// (genuinely slider-driven) contrast.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
|
private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity)
|
||||||
{
|
{
|
||||||
|
|
@ -1683,15 +2092,11 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
perUnitValue,
|
perUnitValue,
|
||||||
_vendor.Profile.MerchandiseItemTypes,
|
_vendor.Profile.MerchandiseItemTypes,
|
||||||
_vendor.Profile.MerchandiseMinValue,
|
_vendor.Profile.MerchandiseMinValue,
|
||||||
_vendor.Profile.MerchandiseMaxValue);
|
_vendor.Profile.MerchandiseMaxValue,
|
||||||
|
item.PublicWeenieBitfield ?? 0u);
|
||||||
|
|
||||||
if (rejection == VendorSellRejection.None)
|
if (rejection == VendorSellRejection.None)
|
||||||
{
|
quantity = (int)Math.Max(1, item.StackSize);
|
||||||
uint fullStack = (uint)Math.Max(1, item.StackSize);
|
|
||||||
quantity = fullStack > 1
|
|
||||||
? (int)_splitQuantity.GetObjectSplitSize(itemGuid, _selection.SelectedObjectId ?? 0u, fullStack)
|
|
||||||
: 1;
|
|
||||||
}
|
|
||||||
return rejection;
|
return rejection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1737,8 +2142,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
/// which preserves the player's category selection) instead of a full
|
/// which preserves the player's category selection) instead of a full
|
||||||
/// from-scratch <see cref="VendorStateTransitionKind.Opened"/> reopen.
|
/// from-scratch <see cref="VendorStateTransitionKind.Opened"/> reopen.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>
|
||||||
|
/// F7 (Slice 6b/6c review, byte-verified): the decompiled pseudo-C's
|
||||||
|
/// declared string length (<c>0x53</c> wchar16) truncates the literal
|
||||||
|
/// mid-sentence, but the raw bytes immediately following it
|
||||||
|
/// (<c>0x007b5c7e</c>) are <c>3f 00</c> — UTF-16LE for <c>'?'</c> —
|
||||||
|
/// before the null terminator. Retail's data segment carries a trailing
|
||||||
|
/// question mark this port previously dropped.
|
||||||
|
/// </summary>
|
||||||
private const string CloseConfirmationMessage =
|
private const string CloseConfirmationMessage =
|
||||||
"You have not completed all transactions. Are you sure you want to leave this vendor";
|
"You have not completed all transactions. Are you sure you want to leave this vendor?";
|
||||||
|
|
||||||
private void CloseButtonPressed()
|
private void CloseButtonPressed()
|
||||||
{
|
{
|
||||||
|
|
@ -1763,6 +2176,20 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F8 (Slice 6b/6c review): shared by <see cref="Dispose"/> and the
|
||||||
|
/// session Closed/Reset arm of <see cref="OnVendorChanged"/> — a live
|
||||||
|
/// X-close confirmation dialog must not outlive the controller or the
|
||||||
|
/// vendor session it was asking about.
|
||||||
|
/// </summary>
|
||||||
|
private void DismissCloseConfirmationIfOpen()
|
||||||
|
{
|
||||||
|
if (_closeConfirmContext == 0u)
|
||||||
|
return;
|
||||||
|
_dialogs?.CloseDialog(_closeConfirmContext);
|
||||||
|
_closeConfirmContext = 0u;
|
||||||
|
}
|
||||||
|
|
||||||
private void ClearContent()
|
private void ClearContent()
|
||||||
{
|
{
|
||||||
_presentCategories.Clear();
|
_presentCategories.Clear();
|
||||||
|
|
@ -1830,12 +2257,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag
|
||||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||||
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
_splitQuantity.Changed -= OnSplitQuantityChanged;
|
||||||
_buyStaging.Changed -= RebuildBuyingList;
|
_buyStaging.Changed -= RebuildBuyingList;
|
||||||
|
_buyStaging.Changed -= RefreshItemsTabAvailability;
|
||||||
_sellStaging.Changed -= RebuildSellingList;
|
_sellStaging.Changed -= RebuildSellingList;
|
||||||
if (_closeConfirmContext != 0u)
|
DismissCloseConfirmationIfOpen();
|
||||||
{
|
_dragOverSink.Parent?.RemoveChild(_dragOverSink);
|
||||||
_dialogs?.CloseDialog(_closeConfirmContext);
|
|
||||||
_closeConfirmContext = 0u;
|
|
||||||
}
|
|
||||||
RetailTabBinding.SetClick(_itemsTab, null);
|
RetailTabBinding.SetClick(_itemsTab, null);
|
||||||
RetailTabBinding.SetClick(_buyingTab, null);
|
RetailTabBinding.SetClick(_buyingTab, null);
|
||||||
RetailTabBinding.SetClick(_sellingTab, null);
|
RetailTabBinding.SetClick(_sellingTab, null);
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ public enum PublicWeenieFlags : uint
|
||||||
Healer = 0x00010000,
|
Healer = 0x00010000,
|
||||||
Lockpick = 0x00020000,
|
Lockpick = 0x00020000,
|
||||||
RequiresPackSlot = 0x00800000,
|
RequiresPackSlot = 0x00800000,
|
||||||
|
/// <summary>
|
||||||
|
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>, the "unsellable" bit
|
||||||
|
/// <c>VendorProfile::InqAcceptability</c> tests (<c>pc:005d1aa7</c>,
|
||||||
|
/// byte 3 bit 0 of <c>PublicWeenieDesc::_bitfield</c>). See
|
||||||
|
/// <see cref="VendorSellAcceptability"/>.
|
||||||
|
/// </summary>
|
||||||
|
Retained = 0x01000000,
|
||||||
VolatileRare = 0x10000000,
|
VolatileRare = 0x10000000,
|
||||||
WieldOnUse = 0x20000000,
|
WieldOnUse = 0x20000000,
|
||||||
WieldLeft = 0x40000000,
|
WieldLeft = 0x40000000,
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,28 @@ public enum VendorSellRejection
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>InqAcceptability</c> returned the raw <c>item_types</c> bitmask
|
/// <c>InqAcceptability</c> returned the raw <c>item_types</c> bitmask
|
||||||
/// (the type-mismatch/non-sellable-bit branch, <c>pc:005d1af8</c>) —
|
/// (the type-mismatch/non-sellable-bit branch, <c>pc:005d1af8</c>) —
|
||||||
/// the generic case in practice, since a genuine bitmask is almost never
|
/// mapped here to the generic "You cannot sell that here" message the
|
||||||
/// literally 1-4 (Slice 6b/6c research doc's open question #3).
|
/// same way retail's <c>DragItemAcceptable</c> switch falls through for
|
||||||
|
/// any return value outside its four named cases (1-4).
|
||||||
|
/// <para>
|
||||||
|
/// F12 (Slice 6b/6c review) — SOFTENED CLAIM: an earlier version of this
|
||||||
|
/// doc comment asserted a genuine bitmask is "almost never literally
|
||||||
|
/// 1-4." That is not true in general: <see cref="ItemType"/>
|
||||||
|
/// <c>MeleeWeapon</c>/<c>Armor</c>/<c>Clothing</c> are the single bits
|
||||||
|
/// <c>1</c>/<c>2</c>/<c>4</c>, and a real specialist vendor's own
|
||||||
|
/// <c>MerchandiseItemTypes</c> could legitimately be exactly one of
|
||||||
|
/// them (a weapon-only, armor-only, or clothing-only shop is an
|
||||||
|
/// ordinary AC vendor archetype). For such a vendor, retail's raw
|
||||||
|
/// <c>item_types</c> return WOULD collide with <c>DragItemAcceptable</c>'s
|
||||||
|
/// own named cases 1/2/4 ("cannot be sold here" / "has no value" /
|
||||||
|
/// "too valuable") — a genuine type mismatch would show the WRONG
|
||||||
|
/// retail message, not the generic one. This class does not reproduce
|
||||||
|
/// that collision (it always returns the semantic
|
||||||
|
/// <see cref="WrongType"/> case, never a raw integer another case could
|
||||||
|
/// alias), so acdream's own message is unaffected either way; this note
|
||||||
|
/// only corrects the doc's claim about how often retail's OWN collision
|
||||||
|
/// is reachable, in case a byte-exact reproduction is ever wanted.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
WrongType,
|
WrongType,
|
||||||
|
|
||||||
|
|
@ -40,6 +60,19 @@ public enum VendorSellRejection
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>InqAcceptability</c>'s "too valuable" branch (<c>pc:005d1add</c>):
|
/// <c>InqAcceptability</c>'s "too valuable" branch (<c>pc:005d1add</c>):
|
||||||
/// <c>max_value != -1 && value > max_value</c>.
|
/// <c>max_value != -1 && value > max_value</c>.
|
||||||
|
/// <para>
|
||||||
|
/// F3 (Slice 6b/6c review, byte-verified at <c>0x005d1add</c>): the
|
||||||
|
/// actual x86 at that return site is <c>mov eax,edi; shr eax,0x10;
|
||||||
|
/// not eax; and eax,4; ret</c> — i.e. <c>(~(itemTypeMask >> 16)) & 4</c>,
|
||||||
|
/// a BITWISE complement (the decompiled pseudo-C's <c>!</c> is
|
||||||
|
/// misleading — it is not a logical NOT). <c>ItemType.PromissoryNote</c>
|
||||||
|
/// (<c>0x00040000</c>, bit 18) sits exactly at bit 2 of
|
||||||
|
/// <c>itemTypeMask >> 16</c>, so a trade note above the vendor's
|
||||||
|
/// max value returns 0 (fully <see cref="None"/>, exempt) instead of 4
|
||||||
|
/// (<see cref="TooValuable"/>) — and the <c>ret</c> at that exact
|
||||||
|
/// address means the min-value check below is skipped entirely for a
|
||||||
|
/// trade note, not merely the max-value rejection.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
TooValuable,
|
TooValuable,
|
||||||
|
|
||||||
|
|
@ -84,6 +117,18 @@ public static class VendorSellAcceptability
|
||||||
/// The vendor's <c>VendorShopProfile.MerchandiseMaxValue</c> —
|
/// The vendor's <c>VendorShopProfile.MerchandiseMaxValue</c> —
|
||||||
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
|
/// <see cref="NoLimit"/> means retail's unset <c>-1</c>.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="publicWeenieBitfield">
|
||||||
|
/// F4 (Slice 6b/6c review): the dragged item's own
|
||||||
|
/// <c>PublicWeenieDesc::_bitfield</c> (<see cref="ClientObject.PublicWeenieBitfield"/>,
|
||||||
|
/// populated on every ordinary <c>CreateObject</c> — including the
|
||||||
|
/// player's own pack items, the only things ever dragged here). Tested
|
||||||
|
/// against <see cref="PublicWeenieFlags.Retained"/>
|
||||||
|
/// (<c>BF_RETAINED = 0x01000000</c>, acclient.h:6456) — retail's
|
||||||
|
/// <c>InqAcceptability</c> ORs this bit into the SAME type-mismatch
|
||||||
|
/// branch (byte 3 bit 0 of the bitfield, <c>pc:005d1aa7</c>), so it
|
||||||
|
/// folds into the same <see cref="VendorSellRejection.WrongType"/>
|
||||||
|
/// outcome, not a distinct rejection reason.
|
||||||
|
/// </param>
|
||||||
public static VendorSellRejection Evaluate(
|
public static VendorSellRejection Evaluate(
|
||||||
bool ownedByPlayer,
|
bool ownedByPlayer,
|
||||||
int containedItemCount,
|
int containedItemCount,
|
||||||
|
|
@ -91,21 +136,34 @@ public static class VendorSellAcceptability
|
||||||
int perUnitValue,
|
int perUnitValue,
|
||||||
uint merchandiseItemTypes,
|
uint merchandiseItemTypes,
|
||||||
uint merchandiseMinValue,
|
uint merchandiseMinValue,
|
||||||
uint merchandiseMaxValue)
|
uint merchandiseMaxValue,
|
||||||
|
uint publicWeenieBitfield = 0u)
|
||||||
{
|
{
|
||||||
if (!ownedByPlayer)
|
if (!ownedByPlayer)
|
||||||
return VendorSellRejection.NotOwnedByPlayer;
|
return VendorSellRejection.NotOwnedByPlayer;
|
||||||
if (containedItemCount > 0)
|
if (containedItemCount > 0)
|
||||||
return VendorSellRejection.None;
|
return VendorSellRejection.None;
|
||||||
|
|
||||||
if ((itemTypeMask & merchandiseItemTypes) == 0u)
|
// F4: InqAcceptability's first check ORs the type-mask mismatch
|
||||||
|
// with the BF_RETAINED bit (pc:005d1aa7) -- both branches return
|
||||||
|
// the SAME raw item_types value, so both fold into WrongType here.
|
||||||
|
bool retained = (publicWeenieBitfield & (uint)PublicWeenieFlags.Retained) != 0u;
|
||||||
|
if ((itemTypeMask & merchandiseItemTypes) == 0u || retained)
|
||||||
return VendorSellRejection.WrongType;
|
return VendorSellRejection.WrongType;
|
||||||
|
|
||||||
if (perUnitValue == 0)
|
if (perUnitValue == 0)
|
||||||
return VendorSellRejection.NoValue;
|
return VendorSellRejection.NoValue;
|
||||||
|
|
||||||
if (merchandiseMaxValue != NoLimit && perUnitValue > merchandiseMaxValue)
|
if (merchandiseMaxValue != NoLimit && perUnitValue > merchandiseMaxValue)
|
||||||
return VendorSellRejection.TooValuable;
|
{
|
||||||
|
// F3 (byte-verified at 0x005d1add) -- see TooValuable's own doc
|
||||||
|
// comment: a PromissoryNote (trade note) is EXEMPT from the
|
||||||
|
// max-value rejection (and, by the disassembly's early ret,
|
||||||
|
// from the min-value check too), not just capped differently.
|
||||||
|
return (itemTypeMask & (uint)ItemType.PromissoryNote) != 0u
|
||||||
|
? VendorSellRejection.None
|
||||||
|
: VendorSellRejection.TooValuable;
|
||||||
|
}
|
||||||
|
|
||||||
if (merchandiseMinValue != NoLimit && perUnitValue < merchandiseMinValue)
|
if (merchandiseMinValue != NoLimit && perUnitValue < merchandiseMinValue)
|
||||||
return VendorSellRejection.TooCheap;
|
return VendorSellRejection.TooCheap;
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,38 @@ public readonly record struct VendorStagingEntry(uint ItemGuid, int Quantity);
|
||||||
/// 6b/6c research doc's open question #2); this is the one generic list both
|
/// 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.
|
/// tab controllers own an instance of, rather than two near-duplicate types.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>Outcome of <see cref="VendorStagingList.Add"/> — see its doc comment.</summary>
|
||||||
|
public enum VendorStagingAddOutcome
|
||||||
|
{
|
||||||
|
/// <summary>Staged: a new entry was appended, or an existing one accumulated.</summary>
|
||||||
|
Added,
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's 5000-unit cap (<c>0x1388</c>) on an ACCUMULATED total would
|
||||||
|
/// be exceeded — rejected, the staged entry is unchanged. Show
|
||||||
|
/// <see cref="VendorStagingList.TooMuchMessage"/>.
|
||||||
|
/// </summary>
|
||||||
|
Capped,
|
||||||
|
/// <summary>Defensive no-op (zero guid or non-positive quantity) — never a retail-modeled path; do not message.</summary>
|
||||||
|
Ignored,
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class VendorStagingList
|
public sealed class VendorStagingList
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's cap on a staged entry's ACCUMULATED total — <c>0x1388</c>
|
||||||
|
/// (5000), <c>VendorItemsUI::AddToBuyList</c>'s comparison
|
||||||
|
/// (<c>pc:202936</c>, <c>0x004c3e73</c>).
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxStagedQuantity = 0x1388;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's exact over-cap notice, read from the decompiled binary's
|
||||||
|
/// data segment at <c>0x007b59d8</c> (<c>VendorItemsUI::AddToBuyList</c>,
|
||||||
|
/// <c>pc:202938-202949</c>).
|
||||||
|
/// </summary>
|
||||||
|
public const string TooMuchMessage =
|
||||||
|
"I can't possibly sell you that much! Please be a little more reasonable.";
|
||||||
|
|
||||||
private readonly List<VendorStagingEntry> _entries = new();
|
private readonly List<VendorStagingEntry> _entries = new();
|
||||||
|
|
||||||
public IReadOnlyList<VendorStagingEntry> Entries => _entries;
|
public IReadOnlyList<VendorStagingEntry> Entries => _entries;
|
||||||
|
|
@ -27,28 +57,40 @@ public sealed class VendorStagingList
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Port of the Buying tab's <c>VendorItemsUI::AddToBuyList</c> insertion
|
/// Port of the Buying tab's <c>VendorItemsUI::AddToBuyList</c> insertion
|
||||||
/// (Slice 6b/6c research doc §Q3): stages <paramref name="quantity"/>
|
/// (<c>pc:202884-202989</c>, <c>0x004c3dc0</c> — read in full for the
|
||||||
/// units of <paramref name="itemGuid"/>. Re-adding an already-staged guid
|
/// Slice 6b/6c review; the insert side is byte-verified below, not
|
||||||
/// (e.g. pressing "Add to List" again after moving the slider) UPSERTS
|
/// inferred). Re-adding an already-staged guid (e.g. pressing "Add to
|
||||||
/// the entry to the new quantity rather than appending a duplicate row —
|
/// List" again after moving the slider) ACCUMULATES
|
||||||
/// retail's own <c>RemoveProfileFromList</c> looks up an entry BY GUID
|
/// <paramref name="quantity"/> onto the entry's EXISTING staged amount
|
||||||
/// (a single match), which only holds if <c>AddToBuyList</c> never
|
/// (<c>eax_3 = arg3 + var_9c</c>, <c>pc:202934</c>) — NOT an upsert/
|
||||||
/// produces two rows for the same guid; the decomp excerpt available to
|
/// overwrite, the shape a prior version of this port used before this
|
||||||
/// this port does not show the insert side of that invariant directly,
|
/// review. A brand-new entry (no existing match) is inserted at
|
||||||
/// so this is a deliberate, documented inference from the removal side's
|
/// <paramref name="quantity"/> directly with NO cap check
|
||||||
/// single-match contract, not a byte-verified citation.
|
/// (<c>label_4c3e20</c>, <c>pc:202895-202923</c>) — the 5000-unit cap
|
||||||
|
/// only guards the ACCUMULATE branch. Exceeding the cap on an
|
||||||
|
/// accumulate shows <see cref="TooMuchMessage"/> and leaves the entry
|
||||||
|
/// COMPLETELY UNCHANGED (no partial accumulate, no shop-row effect —
|
||||||
|
/// <c>pc:202936-202951</c> jumps straight past both).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Add(uint itemGuid, int quantity)
|
public VendorStagingAddOutcome Add(uint itemGuid, int quantity)
|
||||||
{
|
{
|
||||||
if (itemGuid == 0u || quantity <= 0)
|
if (itemGuid == 0u || quantity <= 0)
|
||||||
return;
|
return VendorStagingAddOutcome.Ignored;
|
||||||
|
|
||||||
int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid);
|
int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid);
|
||||||
if (index >= 0)
|
if (index >= 0)
|
||||||
_entries[index] = new VendorStagingEntry(itemGuid, quantity);
|
{
|
||||||
|
int total = _entries[index].Quantity + quantity;
|
||||||
|
if (total > MaxStagedQuantity)
|
||||||
|
return VendorStagingAddOutcome.Capped;
|
||||||
|
_entries[index] = new VendorStagingEntry(itemGuid, total);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
_entries.Add(new VendorStagingEntry(itemGuid, quantity));
|
_entries.Add(new VendorStagingEntry(itemGuid, quantity));
|
||||||
|
}
|
||||||
Changed?.Invoke();
|
Changed?.Invoke();
|
||||||
|
return VendorStagingAddOutcome.Added;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -411,6 +411,31 @@ public sealed class SelectionInteractionControllerTests
|
||||||
Assert.Empty(h.Transport.Uses);
|
Assert.Empty(h.Transport.Uses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F11 (Slice 6b/6c review): a FAR target the eligibility gate will
|
||||||
|
/// refuse (not owned by the player, not useable) must never kick off a
|
||||||
|
/// speculative local approach at all — the prior <c>RequestUse</c>
|
||||||
|
/// ordering called <c>BeginApproach</c> before the eligibility check,
|
||||||
|
/// so an out-of-range unusable target still walked the player toward
|
||||||
|
/// it even though the dispatch immediately below was always going to
|
||||||
|
/// reject. The existing <see cref="RejectedWorldUseReleasesItsBusyReservation"/>
|
||||||
|
/// test never configured an approach at all (<c>TryGetApproach</c>
|
||||||
|
/// returns false unconditionally), so it could not have caught this —
|
||||||
|
/// this test explicitly combines "far" with "rejected."
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void FarUnusableTargetIsRejectedWithoutApproaching()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.Query.Useable = false;
|
||||||
|
h.SetApproach(closeRange: false);
|
||||||
|
|
||||||
|
h.Controller.SendUse(Target);
|
||||||
|
|
||||||
|
Assert.Empty(h.Movement.Approaches);
|
||||||
|
Assert.Empty(h.Transport.Uses);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SynchronousMovementCallbackCannotDuplicateWorldUse()
|
public void SynchronousMovementCallbackCannotDuplicateWorldUse()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,21 @@ public sealed class VendorUiControllerTests
|
||||||
|
|
||||||
public Harness()
|
public Harness()
|
||||||
{
|
{
|
||||||
|
// F1 (Slice 6b/6c review): a realistic player pack shape (102
|
||||||
|
// main-pack item slots, 7 side-pack/container slots — the
|
||||||
|
// conventional retail AC main-pack capacity) so the NEW Buy All
|
||||||
|
// capacity pre-checks have real room to work with; a stub
|
||||||
|
// ClientObject's default ItemsCapacity/ContainersCapacity=0
|
||||||
|
// would reject every Buy All. AddOrUpdate FIRST, then
|
||||||
|
// UpsertProperties SECOND — UpsertProperties mutates an
|
||||||
|
// EXISTING entry in place rather than replacing it wholesale.
|
||||||
|
Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = PlayerGuid,
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
ItemsCapacity = 102,
|
||||||
|
ContainersCapacity = 7,
|
||||||
|
});
|
||||||
var bundle = new PropertyBundle();
|
var bundle = new PropertyBundle();
|
||||||
bundle.Ints[(uint)PropertyInt.CoinValue] = DefaultPlayerCoinValue;
|
bundle.Ints[(uint)PropertyInt.CoinValue] = DefaultPlayerCoinValue;
|
||||||
Objects.UpsertProperties(PlayerGuid, bundle);
|
Objects.UpsertProperties(PlayerGuid, bundle);
|
||||||
|
|
@ -1385,6 +1400,116 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F2 (Slice 6b/6c review, byte-verified <c>pc:202934</c>): re-adding an
|
||||||
|
/// already-staged item ACCUMULATES onto the existing staged quantity —
|
||||||
|
/// a prior version of this port upserted/overwrote. Observed through a
|
||||||
|
/// subsequent Buy All send (the one path that reads
|
||||||
|
/// <see cref="VendorStagingEntry.Quantity"/> directly, unlike Buy
|
||||||
|
/// Item's own live-slider read).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AddToBuyList_ReAddingTheSameStackableItem_AccumulatesTheStagedQuantity()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, Profile(), new[]
|
||||||
|
{
|
||||||
|
new VendorShopItem(
|
||||||
|
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
||||||
|
DescStackSize: 100),
|
||||||
|
});
|
||||||
|
h.SplitQuantity.Reset(100u, initialValue: 10u);
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
h.SplitQuantity.SetValue(15u);
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
Assert.Equal(1, h.BuyingList.GetNumUIItems()); // one row, not two
|
||||||
|
|
||||||
|
h.BuyAllButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
|
||||||
|
Assert.Equal(new (int Amount, uint ItemGuid)[] { (25, StackedItemGuid) }, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F2: retail's 5000-unit cap (<c>VendorStagingList.MaxStagedQuantity</c>)
|
||||||
|
/// on an ACCUMULATE rejects with its own notice and leaves the entry
|
||||||
|
/// unchanged.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AddToBuyList_AccumulatingPastTheCap_ShowsRetailsNoticeAndLeavesStagingUnchanged()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, Profile(), new[]
|
||||||
|
{
|
||||||
|
new VendorShopItem(
|
||||||
|
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
|
||||||
|
DescStackSize: 100),
|
||||||
|
});
|
||||||
|
h.SplitQuantity.Reset(5000u, initialValue: 4990u);
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
h.SplitQuantity.Reset(5000u, initialValue: 20u);
|
||||||
|
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
Assert.Equal(new[] { VendorStagingList.TooMuchMessage }, h.SystemMessages);
|
||||||
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||||
|
// Boost the player's coin total so a follow-up Buy All send is only
|
||||||
|
// gated by staging content, not F1's affordability guard, proving
|
||||||
|
// the rejected accumulate above left the entry at its PRE-add value.
|
||||||
|
h.Objects.Get(Harness.PlayerGuid)!.Properties.Ints[(uint)PropertyInt.CoinValue] = 10_000_000;
|
||||||
|
h.BuyAllButton.OnClick!.Invoke();
|
||||||
|
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
|
||||||
|
Assert.Equal(4990, items.Single().Amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F2 (Slice 6b/6c review): port of <c>VendorItemsUI::RemoveFromShop</c>
|
||||||
|
/// (<c>0x004c3ce0</c>) — staging the vendor's ENTIRE limited supply of an
|
||||||
|
/// item hides its Items-tab row (matching retail's <c>DeleteItem</c>),
|
||||||
|
/// and un-staging restores it — <see cref="AvailableShopQuantity"/> is
|
||||||
|
/// recomputed fresh from the untouched snapshot each time, so no
|
||||||
|
/// separate "restore" code path is needed.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void StagingConsumesLimitedShopSupply_HidingTheRowWhenExhaustedAndRestoringItWhenUnstaged()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, Profile(), new[]
|
||||||
|
{
|
||||||
|
new VendorShopItem(ArmorItemGuid, 2, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
|
||||||
|
});
|
||||||
|
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
||||||
|
|
||||||
|
h.AddButton.OnClick!.Invoke(); // stages 1 of 2 available
|
||||||
|
Assert.Equal(1, h.ItemList.GetNumUIItems()); // still visible — 1 remains
|
||||||
|
|
||||||
|
h.AddButton.OnClick!.Invoke(); // stages 2 of 2 available — exhausted
|
||||||
|
|
||||||
|
Assert.Equal(0, h.ItemList.GetNumUIItems()); // row hidden
|
||||||
|
Assert.Null(h.Selection.SelectedObjectId); // selection cleared, matching RemoveFromShop's own SetSelectedObject(0,0)
|
||||||
|
|
||||||
|
h.BuyClearListButton.OnClick!.Invoke(); // un-stage everything
|
||||||
|
|
||||||
|
Assert.Equal(1, h.ItemList.GetNumUIItems()); // row restored
|
||||||
|
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>F2: retail's <c>var_c != 0xffffffff</c> guard — unlimited stock (StackSize == -1) is never hidden.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void UnlimitedSupplyShopItem_IsNeverHiddenNoMatterHowMuchIsStaged()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, Profile(), new[]
|
||||||
|
{
|
||||||
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
Assert.Equal(1, h.ItemList.GetNumUIItems());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess()
|
public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess()
|
||||||
{
|
{
|
||||||
|
|
@ -1427,6 +1552,90 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.Empty(h.BuyAlls);
|
Assert.Empty(h.BuyAlls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// F1 (Slice 6b/6c review) — Buy All's four client-side pre-send guards.
|
||||||
|
// Each blocking guard leaves staging fully intact; the existing
|
||||||
|
// BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess
|
||||||
|
// test above is the "all four pass" case (it already exercises the
|
||||||
|
// Harness's 102 item / 7 container capacity headroom).
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuyAllButton_InsufficientPyrealFunds_BlocksWithRetailsNoticeAndStagingIntact()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
// Value=2000, no DescStackSize -> perUnit=2000; SellPrice(2000, Armor,
|
||||||
|
// 1.5, 1) = ceil(3000 - 0.1) = 3000, well above the Harness's 1500 coin default.
|
||||||
|
h.State.Apply(VendorGuid, Profile(sellRate: 1.5f), new[]
|
||||||
|
{
|
||||||
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2000),
|
||||||
|
});
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||||
|
|
||||||
|
h.BuyAllButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
Assert.Empty(h.BuyAlls);
|
||||||
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||||
|
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuyAllButton_InsufficientAltCurrency_BlocksWithRetailsNoticeAndStagingIntact()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(
|
||||||
|
VendorGuid,
|
||||||
|
Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 10u),
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||||
|
});
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
h.BuyAllButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
Assert.Empty(h.BuyAlls);
|
||||||
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||||
|
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuyAllButton_InsufficientContainerSlots_BlocksWithRetailsNoticeAndStagingIntact()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, Profile(), new[]
|
||||||
|
{
|
||||||
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
|
||||||
|
});
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 0;
|
||||||
|
|
||||||
|
h.BuyAllButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
Assert.Empty(h.BuyAlls);
|
||||||
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||||
|
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuyAllButton_InsufficientItemSlots_BlocksWithRetailsNoticeAndStagingIntact()
|
||||||
|
{
|
||||||
|
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.Objects.Get(Harness.PlayerGuid)!.ItemsCapacity = 0;
|
||||||
|
|
||||||
|
h.BuyAllButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
Assert.Empty(h.BuyAlls);
|
||||||
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||||
|
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
|
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
|
||||||
{
|
{
|
||||||
|
|
@ -1483,6 +1692,39 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.Empty(h.Buys);
|
Assert.Empty(h.Buys);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must
|
||||||
|
/// visibly move the highlight — a prior version of this port only
|
||||||
|
/// repainted the Buying/Selling strips when their OWN staging list
|
||||||
|
/// changed, so a pure selection change (clicking a DIFFERENT already-
|
||||||
|
/// staged row, no staging mutation) left both rows' <c>Selected</c>
|
||||||
|
/// flags stale.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SelectingADifferentStagedBuyingRow_RepaintsBothRowsHighlight()
|
||||||
|
{
|
||||||
|
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+highlighted (last staged).
|
||||||
|
|
||||||
|
UiItemSlot firstRow = h.BuyingList.GetItem(0)!;
|
||||||
|
UiItemSlot secondRow = h.BuyingList.GetItem(1)!;
|
||||||
|
firstRow.Clicked?.Invoke(); // a pure selection change — no staging mutation
|
||||||
|
|
||||||
|
Assert.Equal(firstRow.ItemId, h.Selection.SelectedObjectId);
|
||||||
|
Assert.True(firstRow.Selected);
|
||||||
|
Assert.False(secondRow.Selected);
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
// Slice 6c — Selling tab drag-to-sell staging
|
// Slice 6c — Selling tab drag-to-sell staging
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -1546,6 +1788,82 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.Empty(h.SystemMessages);
|
Assert.Empty(h.SystemMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F5 (Slice 6b/6c review): every drag test above (and
|
||||||
|
/// <see cref="HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt"/>
|
||||||
|
/// below) calls <c>OnDragOver</c>/<c>HandleDropRelease</c> DIRECTLY,
|
||||||
|
/// bypassing <see cref="UiRoot"/>'s real pointer pipeline entirely — the
|
||||||
|
/// review flagged that this can never exercise the NEW auto-switch
|
||||||
|
/// behavior (<c>PollDragOver</c>), which reacts to <see cref="UiRoot.DragSource"/>/
|
||||||
|
/// <see cref="UiRoot.MouseX"/>/<see cref="UiRoot.MouseY"/>, not a direct
|
||||||
|
/// method call. This test drives the WHOLE thing through
|
||||||
|
/// <see cref="UiRoot.OnMouseDown"/>/<see cref="UiRoot.OnMouseMove"/>/
|
||||||
|
/// <see cref="UiRoot.Tick"/>/<see cref="UiRoot.OnMouseUp"/>: lift a drag
|
||||||
|
/// from an (unrelated) source cell, hover it over the vendor window
|
||||||
|
/// while the Items tab is still open, let the periodic global-UI-time
|
||||||
|
/// pulse (retail's message 3) auto-switch to Selling, then release
|
||||||
|
/// ONTO the now-visible Selling list's real hit-test geometry.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void DragOverTheVendorWindow_ThroughTheRealPointerPipeline_AutoSwitchesToSellingAndStages()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||||
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||||
|
|
||||||
|
// The Selling list needs real screen geometry (and at least one
|
||||||
|
// empty-slot placeholder cell) to be hit-testable — the hand-built
|
||||||
|
// harness never runs a real DAT-driven layout pass, so this test
|
||||||
|
// gives it one explicitly rather than relying on production sizing.
|
||||||
|
// Exactly ONE cell's worth of width so the post-drop repaint has no
|
||||||
|
// room left for an extra empty-slot placeholder alongside the real
|
||||||
|
// staged item.
|
||||||
|
h.SellingList.Width = 32f;
|
||||||
|
h.SellingList.Height = 32f;
|
||||||
|
using (h.SellingList.DeferLayout()) { }
|
||||||
|
|
||||||
|
// A standalone drag SOURCE cell standing in for "the player's own
|
||||||
|
// inventory panel" (not modeled by this harness) — positioned well
|
||||||
|
// outside the vendor window (which occupies roughly (0,0)-(800,110)
|
||||||
|
// here) so it cannot be confused with any vendor widget.
|
||||||
|
var sourceCell = new UiItemSlot { Left = 700f, Top = 550f, Width = 32f, Height = 32f };
|
||||||
|
sourceCell.SetItem(PlayerOwnedArmorGuid, 0u);
|
||||||
|
h.Screen.AddChild(sourceCell);
|
||||||
|
|
||||||
|
Assert.True(h.ItemsPage.Visible);
|
||||||
|
Assert.False(h.SellingPage.Visible);
|
||||||
|
|
||||||
|
// Press on the source cell, then move past the 3px promotion
|
||||||
|
// threshold to somewhere INSIDE the vendor window (but not
|
||||||
|
// specifically over the Selling list) while the Items tab is still
|
||||||
|
// showing — mirrors a player dragging toward "the vendor" in
|
||||||
|
// general before the panel has switched tabs for them.
|
||||||
|
h.Screen.OnMouseDown(UiMouseButton.Left, 710, 560);
|
||||||
|
h.Screen.OnMouseMove(400, 50);
|
||||||
|
Assert.Same(sourceCell, h.Screen.DragSource);
|
||||||
|
Assert.False(h.SellingPage.Visible);
|
||||||
|
|
||||||
|
// F5: the auto-switch happens on the periodic global-UI-time pulse,
|
||||||
|
// not on the mouse-move itself — retail polls this from
|
||||||
|
// UpdateDragOver via UI message 3.
|
||||||
|
h.Screen.Tick(0.016, 1L);
|
||||||
|
|
||||||
|
Assert.True(h.SellingPage.Visible);
|
||||||
|
Assert.False(h.ItemsPage.Visible);
|
||||||
|
|
||||||
|
// Move onto the now-visible Selling list's real geometry and
|
||||||
|
// release — UiRoot's own hit-test resolves the drop, not a direct
|
||||||
|
// HandleDropRelease call.
|
||||||
|
h.Screen.OnMouseMove(20, 15);
|
||||||
|
h.Screen.OnMouseUp(UiMouseButton.Left, 20, 15);
|
||||||
|
|
||||||
|
Assert.Null(h.Screen.DragSource);
|
||||||
|
Assert.Equal(1, h.SellingList.GetNumUIItems());
|
||||||
|
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
|
||||||
|
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
|
||||||
|
Assert.Empty(h.SystemMessages);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt()
|
public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt()
|
||||||
{
|
{
|
||||||
|
|
@ -1564,6 +1882,32 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.Empty(h.SystemMessages);
|
Assert.Empty(h.SystemMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records
|
||||||
|
/// the item's FULL stack — retail's <c>AddItemToSell</c> stages via a
|
||||||
|
/// LITERAL <c>-1</c> "full stack" argument
|
||||||
|
/// (<c>gmVendorUI::AddItem(..., -1, ...)</c>, <c>pc:203595</c>), never a
|
||||||
|
/// slider read. A prior version of this port read the LIVE split
|
||||||
|
/// slider here instead — this proves a PARTIAL slider selection at
|
||||||
|
/// drop time does not leak into the staged (or sent) quantity.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
|
||||||
|
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
|
||||||
|
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
|
||||||
|
h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored
|
||||||
|
|
||||||
|
h.Controller.HandleDropRelease(
|
||||||
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
|
||||||
|
h.SellAllButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
||||||
|
Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HandleDropRelease_WrongTargetList_IsIgnored()
|
public void HandleDropRelease_WrongTargetList_IsIgnored()
|
||||||
{
|
{
|
||||||
|
|
@ -1631,6 +1975,35 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages);
|
Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c> is now checked end to
|
||||||
|
/// end — an item whose TYPE matches the vendor's merchandise mask is
|
||||||
|
/// still rejected (as the generic <c>WrongType</c> message, matching
|
||||||
|
/// retail's OR'd branch) when its bitfield carries the bit.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void HandleDropRelease_RetainedItem_RejectsEvenWhenTheTypeMaskMatches()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||||
|
h.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = PlayerOwnedArmorGuid,
|
||||||
|
Name = "Heirloom Chainmail",
|
||||||
|
Type = ItemType.Armor,
|
||||||
|
Value = 100,
|
||||||
|
StackSize = 1,
|
||||||
|
PublicWeenieBitfield = (uint)PublicWeenieFlags.Retained,
|
||||||
|
});
|
||||||
|
h.Objects.MoveItem(PlayerOwnedArmorGuid, Harness.PlayerGuid, 0);
|
||||||
|
|
||||||
|
h.Controller.HandleDropRelease(
|
||||||
|
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||||
|
|
||||||
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||||
|
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess()
|
public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess()
|
||||||
{
|
{
|
||||||
|
|
@ -1680,6 +2053,71 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F13 (Slice 6b/6c review): retail's Sell Item reads
|
||||||
|
/// <c>ACCWeenieObject::selectedID</c> UNCONDITIONALLY — there is no
|
||||||
|
/// "must be staged first" requirement. A prior version of this port
|
||||||
|
/// required a matching <c>_sellStaging</c> entry, which this test would
|
||||||
|
/// have failed against (no drop/staging happens here at all — only a
|
||||||
|
/// direct global selection).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SellItemButton_ActsOnTheGlobalSelectionEvenWhenNeverStaged()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
|
||||||
|
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||||
|
h.Selection.Select(PlayerOwnedArmorGuid, SelectionChangeSource.Vendor);
|
||||||
|
Assert.Equal(0, h.SellingList.GetNumUIItems()); // never staged/dropped
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F6 (Slice 6b/6c review, byte-verified <c>pc:201833-201864</c>):
|
||||||
|
/// <c>SellSingleItem</c> refuses a stackable item whose split slider is
|
||||||
|
/// not showing the FULL stack — shows the exact retail notice and sends
|
||||||
|
/// nothing.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SellItemButton_PartialStackSelected_RefusesWithRetailsNoticeAndSendsNothing()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
|
||||||
|
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
|
||||||
|
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
|
||||||
|
h.SplitQuantity.Reset(20u, initialValue: 5u); // a PARTIAL amount, not the full stack of 20
|
||||||
|
|
||||||
|
h.SellItemButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
Assert.Empty(h.Sells);
|
||||||
|
Assert.Equal(new[] { "Cannot sell part of a stack" }, h.SystemMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F6: once the slider shows the FULL stack, Sell Item proceeds and
|
||||||
|
/// sends amount <c>1</c> LITERALLY — not the 20-unit stack size —
|
||||||
|
/// matching retail's own literal <c>var_9c = 1</c> send.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void SellItemButton_FullStackSelected_SendsLiteralAmountOneNotTheStackSize()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
|
||||||
|
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
|
||||||
|
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
|
||||||
|
h.SplitQuantity.Reset(20u, initialValue: 20u); // the FULL stack
|
||||||
|
|
||||||
|
h.SellItemButton.OnClick!.Invoke();
|
||||||
|
|
||||||
|
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
|
||||||
|
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedWeaponGuid) }, items);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling()
|
public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling()
|
||||||
{
|
{
|
||||||
|
|
@ -1713,6 +2151,65 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.Empty(h.Sells);
|
Assert.Empty(h.Sells);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// F10 (Slice 6b/6c review) — unstage on removal/dispossession.
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sell side — retail's <c>RecvNotice_ServerSaysMoveItem</c>
|
||||||
|
/// (<c>0x004c44a0</c>) silently unstages a staged sell item once it's
|
||||||
|
/// no longer trackable/owned by the player; this port's closest
|
||||||
|
/// reachable analogue is the item leaving <c>ClientObjectTable</c>
|
||||||
|
/// entirely.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void RemovingAStagedSellItemFromClientObjectTable_SilentlyUnstagesIt()
|
||||||
|
{
|
||||||
|
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.Objects.Remove(PlayerOwnedArmorGuid);
|
||||||
|
|
||||||
|
Assert.Equal(0, h.SellingList.GetNumUIItems());
|
||||||
|
Assert.Empty(h.SystemMessages); // silent — matches retail's own site
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buy side — a staged shop item that drops out of the vendor's
|
||||||
|
/// CURRENT stock (retired from <c>ClientObjectTable</c> — production's
|
||||||
|
/// <c>VendorShopItemMaterializer</c> does this on every Refreshed
|
||||||
|
/// transition that drops a guid; this hand-built harness doesn't mount
|
||||||
|
/// that class, so the test performs the SAME removal directly) unstages
|
||||||
|
/// with retail's exact "Removing %s from shopping list" notice
|
||||||
|
/// (<c>0x004c4246</c>).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ShopItemLeavingClientObjectTable_UnstagesTheBuyEntryWithRetailsNotice()
|
||||||
|
{
|
||||||
|
var h = new Harness();
|
||||||
|
h.State.Apply(VendorGuid, Profile(), new[]
|
||||||
|
{
|
||||||
|
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
|
||||||
|
});
|
||||||
|
h.Objects.AddOrUpdate(new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = ArmorItemGuid,
|
||||||
|
Name = "Chainmail",
|
||||||
|
Type = ItemType.Armor,
|
||||||
|
ContainerId = VendorGuid,
|
||||||
|
});
|
||||||
|
h.AddButton.OnClick!.Invoke();
|
||||||
|
Assert.Equal(1, h.BuyingList.GetNumUIItems());
|
||||||
|
|
||||||
|
h.Objects.Remove(ArmorItemGuid);
|
||||||
|
|
||||||
|
Assert.Equal(0, h.BuyingList.GetNumUIItems());
|
||||||
|
Assert.Equal(new[] { "Removing Chainmail from shopping list" }, h.SystemMessages);
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
// Slice 6b/6c — X-close staging confirmation + session-boundary clears
|
// Slice 6b/6c — X-close staging confirmation + session-boundary clears
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -1749,9 +2246,11 @@ public sealed class VendorUiControllerTests
|
||||||
Assert.True(h.Dialogs.IsOpen);
|
Assert.True(h.Dialogs.IsOpen);
|
||||||
Assert.NotNull(h.ShownDialog);
|
Assert.NotNull(h.ShownDialog);
|
||||||
// Exact retail string, read from the decompiled binary's data
|
// Exact retail string, read from the decompiled binary's data
|
||||||
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment.
|
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment. F7
|
||||||
|
// (Slice 6b/6c review): the raw bytes right after the declared
|
||||||
|
// string length are UTF-16LE for '?' before the null terminator.
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
"You have not completed all transactions. Are you sure you want to leave this vendor",
|
"You have not completed all transactions. Are you sure you want to leave this vendor?",
|
||||||
string.Join(" ", Assert.IsType<UiText>(h.ShownDialog!.FindElement(
|
string.Join(" ", Assert.IsType<UiText>(h.ShownDialog!.FindElement(
|
||||||
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,83 @@ public sealed class VendorSellAcceptabilityTests
|
||||||
Assert.Equal(VendorSellRejection.None, rejection);
|
Assert.Equal(VendorSellRejection.None, rejection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F3 (Slice 6b/6c review, byte-verified at 0x005d1add): a trade note
|
||||||
|
/// above the vendor's max value is EXEMPT — the actual x86 is
|
||||||
|
/// <c>(~(itemTypeMask >> 16)) & 4</c>, and PromissoryNote's bit
|
||||||
|
/// (0x00040000) lands exactly on that mask, zeroing the result.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PromissoryNoteAboveMaxValueIsExemptFromTooValuable()
|
||||||
|
{
|
||||||
|
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
|
||||||
|
ownedByPlayer: true,
|
||||||
|
containedItemCount: 0,
|
||||||
|
itemTypeMask: (uint)ItemType.PromissoryNote,
|
||||||
|
perUnitValue: 999_999,
|
||||||
|
merchandiseItemTypes: (uint)ItemType.PromissoryNote,
|
||||||
|
merchandiseMinValue: 0u,
|
||||||
|
merchandiseMaxValue: 1000u);
|
||||||
|
|
||||||
|
Assert.Equal(VendorSellRejection.None, rejection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An ordinary (non-note) item above max value is still rejected — the exemption is note-specific.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void NonPromissoryNoteAboveMaxValueIsStillTooValuable()
|
||||||
|
{
|
||||||
|
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
|
||||||
|
ownedByPlayer: true,
|
||||||
|
containedItemCount: 0,
|
||||||
|
itemTypeMask: Armor,
|
||||||
|
perUnitValue: 999_999,
|
||||||
|
merchandiseItemTypes: Armor,
|
||||||
|
merchandiseMinValue: 0u,
|
||||||
|
merchandiseMaxValue: 1000u);
|
||||||
|
|
||||||
|
Assert.Equal(VendorSellRejection.TooValuable, rejection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>
|
||||||
|
/// (<see cref="PublicWeenieFlags.Retained"/>, 0x01000000) folds into
|
||||||
|
/// the SAME <see cref="VendorSellRejection.WrongType"/> outcome as a
|
||||||
|
/// genuine type mismatch — retail's InqAcceptability ORs the two
|
||||||
|
/// conditions together (pc:005d1aa7).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void RetainedItemIsRejectedAsWrongTypeEvenWhenTheTypeMaskMatches()
|
||||||
|
{
|
||||||
|
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
|
||||||
|
ownedByPlayer: true,
|
||||||
|
containedItemCount: 0,
|
||||||
|
itemTypeMask: Armor,
|
||||||
|
perUnitValue: 100,
|
||||||
|
merchandiseItemTypes: Armor,
|
||||||
|
merchandiseMinValue: 0u,
|
||||||
|
merchandiseMaxValue: NoLimit,
|
||||||
|
publicWeenieBitfield: (uint)PublicWeenieFlags.Retained);
|
||||||
|
|
||||||
|
Assert.Equal(VendorSellRejection.WrongType, rejection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An item with OTHER bitfield bits set (not Retained) is unaffected.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void NonRetainedBitfieldDoesNotAffectAcceptability()
|
||||||
|
{
|
||||||
|
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
|
||||||
|
ownedByPlayer: true,
|
||||||
|
containedItemCount: 0,
|
||||||
|
itemTypeMask: Armor,
|
||||||
|
perUnitValue: 100,
|
||||||
|
merchandiseItemTypes: Armor,
|
||||||
|
merchandiseMinValue: 0u,
|
||||||
|
merchandiseMaxValue: NoLimit,
|
||||||
|
publicWeenieBitfield: (uint)PublicWeenieFlags.Stuck);
|
||||||
|
|
||||||
|
Assert.Equal(VendorSellRejection.None, rejection);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AnOrdinaryAcceptableItemReturnsNone()
|
public void AnOrdinaryAcceptableItemReturnsNone()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,14 @@ public sealed class VendorStagingListTests
|
||||||
Assert.False(list.IsEmpty);
|
Assert.False(list.IsEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F2 (Slice 6b/6c review, byte-verified <c>pc:202934</c>): re-adding an
|
||||||
|
/// already-staged guid ACCUMULATES onto the existing quantity — a prior
|
||||||
|
/// version of this port upserted/overwrote instead, which this test
|
||||||
|
/// used to assert (20, not 25).
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddingTheSameGuidTwiceUpsertsRatherThanDuplicating()
|
public void AddingTheSameGuidTwiceAccumulatesRatherThanDuplicatingOrOverwriting()
|
||||||
{
|
{
|
||||||
var list = new VendorStagingList();
|
var list = new VendorStagingList();
|
||||||
|
|
||||||
|
|
@ -35,7 +41,64 @@ public sealed class VendorStagingListTests
|
||||||
list.Add(ItemA, 20);
|
list.Add(ItemA, 20);
|
||||||
|
|
||||||
VendorStagingEntry entry = Assert.Single(list.Entries);
|
VendorStagingEntry entry = Assert.Single(list.Entries);
|
||||||
Assert.Equal(20, entry.Quantity);
|
Assert.Equal(25, entry.Quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddReturnsAddedOnASuccessfulStage()
|
||||||
|
{
|
||||||
|
var list = new VendorStagingList();
|
||||||
|
|
||||||
|
Assert.Equal(VendorStagingAddOutcome.Added, list.Add(ItemA, 5));
|
||||||
|
Assert.Equal(VendorStagingAddOutcome.Added, list.Add(ItemA, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F2: retail's 5000-unit cap (<c>0x1388</c>) guards the ACCUMULATE
|
||||||
|
/// path only — exceeding it on a re-add rejects and leaves the entry
|
||||||
|
/// COMPLETELY UNCHANGED (<c>pc:202936-202951</c>).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AddAccumulatingPastTheCapIsRejectedAndLeavesTheEntryUnchanged()
|
||||||
|
{
|
||||||
|
var list = new VendorStagingList();
|
||||||
|
list.Add(ItemA, VendorStagingList.MaxStagedQuantity - 10);
|
||||||
|
|
||||||
|
VendorStagingAddOutcome outcome = list.Add(ItemA, 11);
|
||||||
|
|
||||||
|
Assert.Equal(VendorStagingAddOutcome.Capped, outcome);
|
||||||
|
VendorStagingEntry entry = Assert.Single(list.Entries);
|
||||||
|
Assert.Equal(VendorStagingList.MaxStagedQuantity - 10, entry.Quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddAccumulatingExactlyToTheCapSucceeds()
|
||||||
|
{
|
||||||
|
var list = new VendorStagingList();
|
||||||
|
list.Add(ItemA, VendorStagingList.MaxStagedQuantity - 10);
|
||||||
|
|
||||||
|
VendorStagingAddOutcome outcome = list.Add(ItemA, 10);
|
||||||
|
|
||||||
|
Assert.Equal(VendorStagingAddOutcome.Added, outcome);
|
||||||
|
VendorStagingEntry entry = Assert.Single(list.Entries);
|
||||||
|
Assert.Equal(VendorStagingList.MaxStagedQuantity, entry.Quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// F2: a BRAND-NEW entry has no cap in retail's own <c>AddToBuyList</c>
|
||||||
|
/// — the cap check lives only inside the "found an existing match"
|
||||||
|
/// branch (<c>label_4c3e20</c>'s insert has none, <c>pc:202895-202923</c>).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void AddOfABrandNewEntryHasNoCapEvenAboveTheThreshold()
|
||||||
|
{
|
||||||
|
var list = new VendorStagingList();
|
||||||
|
|
||||||
|
VendorStagingAddOutcome outcome = list.Add(ItemA, VendorStagingList.MaxStagedQuantity + 500);
|
||||||
|
|
||||||
|
Assert.Equal(VendorStagingAddOutcome.Added, outcome);
|
||||||
|
VendorStagingEntry entry = Assert.Single(list.Entries);
|
||||||
|
Assert.Equal(VendorStagingList.MaxStagedQuantity + 500, entry.Quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -46,8 +109,9 @@ public sealed class VendorStagingListTests
|
||||||
{
|
{
|
||||||
var list = new VendorStagingList();
|
var list = new VendorStagingList();
|
||||||
|
|
||||||
list.Add(guid, quantity);
|
VendorStagingAddOutcome outcome = list.Add(guid, quantity);
|
||||||
|
|
||||||
|
Assert.Equal(VendorStagingAddOutcome.Ignored, outcome);
|
||||||
Assert.True(list.IsEmpty);
|
Assert.True(list.IsEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue