feat(vendor): Slice 6 buy arc — shop items are real objects, vendor selection is THE selection, and Buy works (0x005F)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Three ordered pieces in one landing (the shared controller/composition
files carry all three; the internal order was 6.1 -> 6.2 -> 6.3):

6.1 VendorShopItemMaterializer diff-merges the shop list into the live
ClientObjectTable on VendorState transitions (so client-local close and
session teardown retire the entries too) and never claims a guid it did
not add — ACE's UniqueItemsForSale can re-list a guid a player once
held (AP-163 files the collision-skip; no retail counterpart traced).
Right-click examine on shop items now routes through the ordinary
appraisal path — the 5.4 F7c blocker dissolves with the table entries.

6.2 SelectionChangeSource.Vendor: row clicks, auto-select, and examine
all flow through the canonical SelectionState; the status bar and the
existing byte-faithful StackSplitQuantityState slider light up
unmodified. VendorSplitPolicy is the single 0xDC41CB0 mask owner; the
slider VALUE seeds to 1 for exempt items while maxSplitSize keeps the
stack (the splitSize/maxSplitSize distinction, research §B.3).
Selection clears at retail's actual site — VendorItemsUI::RemoveFromShop
(pc:202848), not a CloseVendor-level clear that does not exist.

6.3 BuildBuy (0x005F): vendorGuid, count, (i32 amount, u32 guid) pairs,
and the trailing alternateCurrencyId the REAL client sends
(CM_Vendor::Event_Buy pc:689288) though ACE's reader ignores it.
TryBuy rides the EXISTING J5.2 one-request-at-a-time reservation and
completes on UseDone; the Buy button disables while a request is in
flight. The reconciliation round-trip (money property update, inventory
CreateObject, ApproachVendor refresh -> panel rebuild) is proven by a
synthetic-inbound test against existing machinery — no new owner.

Register: AP-161 narrowed (selection + examine residuals close;
staging/Sell remain; double-click-to-buy confirmed ABSENT from retail
with negative evidence cited — we match retail). AP-162 files the
conscious no-client-side-affordability-precheck deferral.

Clean-room complete solution: 11,368 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 20:28:26 +02:00
parent c884a938e0
commit 97cf873870
19 changed files with 1577 additions and 65 deletions

File diff suppressed because one or more lines are too long

View file

@ -361,7 +361,16 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
{ {
d.Inventory.ExternalContainers.RequestOpen(guid); d.Inventory.ExternalContainers.RequestOpen(guid);
}, },
requestUse: selection.RequestUse); requestUse: selection.RequestUse,
// Slice 6.3: ItemInteractionController.TryBuy owns the
// reservation dance itself (see its doc comment); this is a
// plain wire send, not a second requestUse-shaped delegate.
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
session.CurrentSession?.SendBuy(
vendorGuid,
itemGuid,
amount,
alternateCurrencyId));
} }
public MagicRuntime CreateMagicRuntime( public MagicRuntime CreateMagicRuntime(
@ -655,7 +664,21 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.Session.CurrentSession?.SendPutItemInContainer( late.Session.CurrentSession?.SendPutItemInContainer(
item, item,
container, container,
placement)), placement),
// Slice 6.2: retail gmToolbarUI::HandleSelectionChanged's
// own vendor-owned gate (pc:198781) is literally
// "selected weenie's ContainerId == the open vendor's
// guid" — Slice 6.1 guarantees a materialized shop
// item's ContainerId IS the vendor's guid, so this reads
// straight off the same ClientObjectTable/VendorState
// pair VendorUiController.VendorSplitSize's display-only
// copy also reads, through the SAME VendorSplitPolicy
// mask helper (no second mask copy).
guid =>
d.Inventory.Vendor.VendorId != 0u
&& d.Inventory.Objects.Get(guid) is { } vendorCandidate
&& vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)),
Character: new CharacterRuntimeBindings(characterSheet), Character: new CharacterRuntimeBindings(characterSheet),
Inventory: new InventoryRuntimeBindings( Inventory: new InventoryRuntimeBindings(
d.Inventory.Objects, d.Inventory.Objects,
@ -706,7 +729,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
late.Selection.IsWithinExternalContainerUseRange), late.Selection.IsWithinExternalContainerUseRange),
Vendor: new VendorRuntimeBindings( Vendor: new VendorRuntimeBindings(
d.Inventory.Vendor, d.Inventory.Vendor,
iconComposer.GetIcon), iconComposer.GetIcon,
itemInteraction,
d.Actions.Selection),
Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager), Cursor: new RetailUiCursorBindings(cursorFeedback, cursorManager),
Confirmations: new ConfirmationRuntimeBindings( Confirmations: new ConfirmationRuntimeBindings(
(type, context, accepted) => (type, context, accepted) =>

View file

@ -61,6 +61,8 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<string>? _systemMessage; private readonly Action<string>? _systemMessage;
private readonly AutoWieldController _autoWield; private readonly AutoWieldController _autoWield;
private readonly Action<uint, ItemUseRequestReservation>? _requestUse; private readonly Action<uint, ItemUseRequestReservation>? _requestUse;
// Slice 6.3: vendorGuid, itemGuid, amount, alternateCurrencyId.
private readonly Action<uint, uint, int, uint>? _sendBuy;
private readonly RuntimeInteractionTransactionState _runtimeTransactions; private readonly RuntimeInteractionTransactionState _runtimeTransactions;
private readonly InventoryTransactionState _transactions; private readonly InventoryTransactionState _transactions;
@ -101,7 +103,8 @@ public sealed class ItemInteractionController : IDisposable
Action<uint>? requestExternalContainer = null, Action<uint>? requestExternalContainer = null,
CombatState? combatState = null, CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null, Action<CombatMode>? sendChangeCombatMode = null,
Action<uint, ItemUseRequestReservation>? requestUse = null) Action<uint, ItemUseRequestReservation>? requestUse = null,
Action<uint, uint, int, uint>? sendBuy = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -131,6 +134,7 @@ public sealed class ItemInteractionController : IDisposable
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true); _dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
_systemMessage = systemMessage; _systemMessage = systemMessage;
_requestUse = requestUse; _requestUse = requestUse;
_sendBuy = sendBuy;
_interactionState = interactionState _interactionState = interactionState
?? throw new ArgumentNullException(nameof(interactionState)); ?? throw new ArgumentNullException(nameof(interactionState));
_runtimeTransactions = runtimeTransactions _runtimeTransactions = runtimeTransactions
@ -222,6 +226,43 @@ public sealed class ItemInteractionController : IDisposable
return false; return false;
} }
/// <summary>
/// Slice 6.3: retail's Buy button — <c>gmVendorUI::BuySingleItem</c>
/// (<c>pc:201661</c>, <c>0x004C2820</c>). Immediate single-item purchase
/// of <paramref name="itemGuid"/> (a vendor shop item) at
/// <paramref name="amount"/> units, with the vendor's own trade currency
/// (0 = pyreal). Rides the EXISTING one-request-at-a-time reservation
/// ordinary Use takes (research doc §A.4: every
/// <c>HandleActionBuyItem</c> path ends in exactly one
/// <c>SendUseDoneEvent()</c>, the SAME completion signal
/// <c>RuntimeInteractionTransactionState.CompleteUse</c> already
/// resolves via the wired <c>UseDone (0x01C7)</c> handler) — no second
/// gate, and retail's client-side busy-count increment
/// (<c>ClientUISystem::IncrementBusyCount</c>, <c>pc:201765</c>) is
/// exactly what <see cref="EnsureInventoryRequestReady"/>'s
/// <c>BusyCount == 0</c> check already guards for every other request.
/// </summary>
public bool TryBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId)
{
if (vendorGuid == 0u || itemGuid == 0u || amount <= 0 || _sendBuy is null)
return false;
if (!EnsureInventoryRequestReady())
return false;
ItemUseRequestReservation reservation = BeginUseRequestReservation();
try
{
_sendBuy(vendorGuid, itemGuid, amount, alternateCurrencyId);
}
catch
{
reservation.CancelBeforeDispatch();
throw;
}
reservation.MarkDispatched();
return true;
}
/// <summary> /// <summary>
/// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local /// Retail <c>UIElement_ItemList::AcceptDragObject</c>'s local
/// <c>m_pendingItem</c> branch. This wording belongs only to the destination /// <c>m_pendingItem</c> branch. This wording belongs only to the destination

View file

@ -93,6 +93,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
private readonly Action<uint> _sendQueryItemMana; private readonly Action<uint> _sendQueryItemMana;
private readonly StackSplitQuantityState _splitQuantity; private readonly StackSplitQuantityState _splitQuantity;
private readonly SelectionState _selection; private readonly SelectionState _selection;
private readonly Func<uint, bool> _isVendorSplitExempt;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged; private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged; private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated; private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
@ -126,7 +127,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
UiDatFont? datFont, UiDatFont? datFont,
StackSplitQuantityState splitQuantity, StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated, Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated) Action<Action<ClientObject>> unsubscribeObjectUpdated,
Func<uint, bool> isVendorSplitExempt)
{ {
_isHealthTarget = isHealthTarget; _isHealthTarget = isHealthTarget;
_isOwnedByPlayer = isOwnedByPlayer; _isOwnedByPlayer = isOwnedByPlayer;
@ -139,6 +141,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
_sendQueryItemMana = sendQueryItemMana; _sendQueryItemMana = sendQueryItemMana;
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity)); _splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
_selection = selection ?? throw new ArgumentNullException(nameof(selection)); _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_isVendorSplitExempt = isVendorSplitExempt
?? throw new ArgumentNullException(nameof(isVendorSplitExempt));
_unsubscribeHealthChanged = unsubscribeHealthChanged; _unsubscribeHealthChanged = unsubscribeHealthChanged;
_unsubscribeItemManaChanged = unsubscribeItemManaChanged; _unsubscribeItemManaChanged = unsubscribeItemManaChanged;
_unsubscribeObjectUpdated = unsubscribeObjectUpdated; _unsubscribeObjectUpdated = unsubscribeObjectUpdated;
@ -250,6 +254,18 @@ public sealed class SelectedObjectController : IRetainedPanelController
/// <param name="stackSize">Returns the stack size for a guid (0 or 1 = non-stacked).</param> /// <param name="stackSize">Returns the stack size for a guid (0 or 1 = non-stacked).</param>
/// <param name="sendQueryHealth">Sends retail <c>QueryHealth (0x01BF)</c>; may be a no-op offline.</param> /// <param name="sendQueryHealth">Sends retail <c>QueryHealth (0x01BF)</c>; may be a no-op offline.</param>
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param> /// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
/// <param name="isVendorSplitExempt">
/// Slice 6.2: retail's <c>gmToolbarUI::HandleSelectionChanged</c> vendor
/// branch (<c>pc:198779-198790</c>) — true when the selected guid is
/// owned by the currently-open vendor (its <c>ClientObject.ContainerId</c>
/// equals <c>VendorState.VendorId</c>) AND its type intersects
/// <see cref="VendorSplitPolicy.SplitExemptMask"/>. When true, a stack
/// seeds to quantity 1 instead of the full authored stack size — see
/// <see cref="ApplySelection"/>. <c>VendorUiController.VendorSplitSize</c>
/// answers the SAME question for vendor's own display text via the SAME
/// <see cref="VendorSplitPolicy"/> helper, so the mask exists in exactly
/// one place (composed at <c>InteractionRetainedUiComposition</c>).
/// </param>
public static SelectedObjectController Bind( public static SelectedObjectController Bind(
ImportedLayout layout, ImportedLayout layout,
SelectionState selection, SelectionState selection,
@ -269,14 +285,16 @@ public sealed class SelectedObjectController : IRetainedPanelController
UiDatFont? datFont, UiDatFont? datFont,
StackSplitQuantityState splitQuantity, StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated, Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated) Action<Action<ClientObject>> unsubscribeObjectUpdated,
Func<uint, bool> isVendorSplitExempt)
=> new SelectedObjectController( => new SelectedObjectController(
layout, selection, layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged, subscribeHealthChanged, unsubscribeHealthChanged,
subscribeItemManaChanged, unsubscribeItemManaChanged, subscribeItemManaChanged, unsubscribeItemManaChanged,
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize, isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
sendQueryHealth, manaPercent, sendQueryItemMana, datFont, sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated); splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated,
isVendorSplitExempt);
/// <summary> /// <summary>
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>): /// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
@ -335,11 +353,20 @@ public sealed class SelectedObjectController : IRetainedPanelController
// gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666: // gmToolbarUI::HandleSelectionChanged @ 0x004BF52D..0x004BF666:
// stacks initialize to the full stack, show the numeric entry + horizontal // stacks initialize to the full stack, show the numeric entry + horizontal
// slider, and set the stacked selection state. Vendor-owned stack precedence // slider, and set the stacked selection state. Slice 6.2: the
// is intentionally absent until the vendor panel owns an active vendor id. // vendor-owned branch (pc:198779-198790, mask literal pc:198784)
// seeds splitSize (the INITIAL value) to 1 instead of the full stack
// when the selection is owned by the currently-open vendor AND its
// type intersects VendorSplitPolicy.SplitExemptMask — see the
// isVendorSplitExempt parameter doc. maxSplitSize (the slider's
// RANGE) is always the full authored stack size regardless of
// exemption (research doc §B.3: "Sets GenItemHolder::splitSize =
// seed, GenItemHolder::maxSplitSize = stackSize") — only the
// starting VALUE differs, not the ceiling.
if (stackSize > 1u) if (stackSize > 1u)
{ {
_splitQuantity.Reset(stackSize); uint seed = _isVendorSplitExempt(g) ? 1u : stackSize;
_splitQuantity.Reset(stackSize, initialValue: seed);
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true; if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true;
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true;
} }

View file

@ -5,6 +5,7 @@ using System.Linq;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Properties; using AcDream.Core.Properties;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -149,19 +150,6 @@ public sealed class VendorUiController : IRetainedPanelController
private const uint TypeMenuNormalSprite = 0x060012B3u; private const uint TypeMenuNormalSprite = 0x060012B3u;
private const uint TypeMenuPressedSprite = 0x060012B4u; private const uint TypeMenuPressedSprite = 0x060012B4u;
/// <summary>
/// F2 mask rule (Slice 5.4 review): the split-exempt <c>ItemType</c>
/// mask <c>gmToolbarUI::HandleSelectionChanged</c> applies when seeding
/// <c>GenItemHolder::splitSize</c> for a vendor-owned selection
/// (<c>pc:198779-198790</c>, literal mask at <c>pc:198784</c>). Every
/// row <see cref="VendorUiController"/> shows IS vendor-owned (its
/// container is unconditionally the open vendor), so the "does this
/// item belong to the open vendor" gate that precedes the mask check in
/// retail's function is always true here and is not reproduced
/// separately — see <see cref="VendorSplitSize"/>.
/// </summary>
private const uint SplitExemptMask = 0x0DC41CB0u;
/// <summary> /// <summary>
/// Retail's ordered category table, transcribed verbatim from /// Retail's ordered category table, transcribed verbatim from
/// <c>VendorItemsUI::OpenVendor</c>'s <c>AddTypeFilter</c> call chain /// <c>VendorItemsUI::OpenVendor</c>'s <c>AddTypeFilter</c> call chain
@ -197,6 +185,9 @@ public sealed class VendorUiController : IRetainedPanelController
private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon; private readonly Func<ItemType, uint, uint, uint, uint, uint> _resolveIcon;
private readonly ClientObjectTable _objects; private readonly ClientObjectTable _objects;
private readonly Func<uint> _playerGuid; private readonly Func<uint> _playerGuid;
private readonly ItemInteractionController _itemInteraction;
private readonly SelectionState _selection;
private readonly StackSplitQuantityState _splitQuantity;
private readonly UiElement _itemsPage; private readonly UiElement _itemsPage;
private readonly UiElement _buyingPage; private readonly UiElement _buyingPage;
private readonly UiElement _sellingPage; private readonly UiElement _sellingPage;
@ -213,7 +204,11 @@ public sealed class VendorUiController : IRetainedPanelController
private readonly List<(string Label, ItemType Mask)> _presentCategories = new(); private readonly List<(string Label, ItemType Mask)> _presentCategories = new();
private int _selectedCategoryIndex = -1; private int _selectedCategoryIndex = -1;
private uint _selectedItemGuid; // Slice 6.3: tracks "does the CURRENT selection permit Buy/Add" separately
// from "is a Buy request currently in flight" (RecomputeBuyButtonEnabled
// combines both for the Buy button specifically; Add is selection-only,
// per contract decision 6 — staging stays unwired this pass).
private bool _buyEnabledBySelection;
private bool _disposed; private bool _disposed;
private VendorUiController( private VendorUiController(
@ -222,6 +217,9 @@ public sealed class VendorUiController : IRetainedPanelController
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon, Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
ClientObjectTable objects, ClientObjectTable objects,
Func<uint> playerGuid, Func<uint> playerGuid,
ItemInteractionController itemInteraction,
SelectionState selection,
StackSplitQuantityState splitQuantity,
UiElement itemsPage, UiElement itemsPage,
UiElement buyingPage, UiElement buyingPage,
UiElement sellingPage, UiElement sellingPage,
@ -246,6 +244,9 @@ public sealed class VendorUiController : IRetainedPanelController
_resolveIcon = resolveIcon; _resolveIcon = resolveIcon;
_objects = objects; _objects = objects;
_playerGuid = playerGuid; _playerGuid = playerGuid;
_itemInteraction = itemInteraction;
_selection = selection;
_splitQuantity = splitQuantity;
_itemsPage = itemsPage; _itemsPage = itemsPage;
_buyingPage = buyingPage; _buyingPage = buyingPage;
_sellingPage = sellingPage; _sellingPage = sellingPage;
@ -276,6 +277,13 @@ public sealed class VendorUiController : IRetainedPanelController
{ {
SpriteResolve = _itemList.SpriteResolve, SpriteResolve = _itemList.SpriteResolve,
}; };
// Slice 6.1: mirrors ExternalContainerController's own
// right-click-examine wiring (UiItemSlot.OnEvent's RightClick case).
// Now that shop items are materialized into ClientObjectTable (see
// VendorShopItemMaterializer), AppraisalUiController.Apply's lookup
// succeeds and this stops being a dead end — closes half of AP-161
// finding #2.
_itemList.ExamineItemRequested = ExamineItem;
if (itemScrollbar is not null) if (itemScrollbar is not null)
{ {
itemScrollbar.Model = _itemList.Scroll; itemScrollbar.Model = _itemList.Scroll;
@ -310,11 +318,40 @@ public sealed class VendorUiController : IRetainedPanelController
RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling)); RetailTabBinding.SetClick(_sellingTab, () => ShowTab(VendorPanelTab.Selling));
if (_close is not null) if (_close is not null)
_close.OnClick = () => _vendor.Close(); _close.OnClick = () => _vendor.Close();
// Slice 6.3: retail gmVendorUI::HandleButtonClicks' 0x100000C2 case —
// BuySingleItem(selectedID) — an immediate single-item purchase, no
// staging list required (research doc §B.1).
if (_buyButton is not null)
_buyButton.OnClick = BuySelectedItem;
ShowTab(VendorPanelTab.Items); ShowTab(VendorPanelTab.Items);
ClearContent(); ClearContent();
_vendor.Changed += OnVendorChanged; _vendor.Changed += OnVendorChanged;
// Slice 6.2: SelectionState is now the AUTHORITY (research doc §B.4:
// vendor-context selections flow through the SAME global
// ACCWeenieObject::SetSelectedObject primitive as every other
// origin) — this panel is a CONSUMER, mirroring
// ExternalContainerController.OnSelectionChanged's shape exactly.
_selection.Changed += OnSelectionTransition;
// Slice 6.2: mirrors ExternalContainerController.OnObjectRemoved —
// retail's VendorItemsUI::RemoveFromShop (pc:202848-202850,
// 0x004c3d4a) clears the global selection when a shop item leaves
// the list (there is no separate CloseVendor-level SetSelectedObject(0)
// call; retail's ItemList_Flush on close does not touch selection
// directly). Since VendorShopItemMaterializer removes every
// materialized item from ClientObjectTable on Close/Reset/replace
// (Slice 6.1), subscribing here gives "vendor session close clears
// a vendor-owned selection" as a consequence of the SAME generic
// mechanism every other panel already uses, not a vendor-specific
// special case.
_objects.ObjectRemoved += OnObjectRemoved;
// Slice 6.3: mirrors ExternalContainerController's own
// _itemInteraction.StateChanged subscription — the Buy button must
// disable the instant a reservation is taken (BeginUseRequestReservation
// increments BusyCount synchronously, before the wire send), and
// re-enable on the matching UseDone/cancel, without polling.
_itemInteraction.StateChanged += OnInteractionStateChanged;
} }
/// <param name="objects"> /// <param name="objects">
@ -325,6 +362,28 @@ public sealed class VendorUiController : IRetainedPanelController
/// <c>ObjectTableWiring</c>'s <c>PrivateUpdatePropertyInt</c> routing. /// <c>ObjectTableWiring</c>'s <c>PrivateUpdatePropertyInt</c> routing.
/// </param> /// </param>
/// <param name="playerGuid">Resolves the local player's guid to look up in <paramref name="objects"/>.</param> /// <param name="playerGuid">Resolves the local player's guid to look up in <paramref name="objects"/>.</param>
/// <param name="itemInteraction">
/// Slice 6.1: the shared retail item interaction orchestrator — its
/// <c>ExamineSelectedOrEnterMode</c> is what right-click-examine on a
/// shop row routes through, mirroring
/// <see cref="ExternalContainerController"/>'s own examine wiring.
/// </param>
/// <param name="selection">
/// Slice 6.2: the canonical <see cref="SelectionState"/> — now the
/// AUTHORITY for shop-row selection (row clicks, the F4 auto-select
/// fallback, and right-click examine all call
/// <see cref="SelectionState.Select"/> directly); this panel only
/// listens and reacts, the same way every sibling panel does.
/// </param>
/// <param name="splitQuantity">
/// Slice 6.3: the shared toolbar stack-quantity control (retail
/// <c>GenItemHolder::splitSize</c>/<c>maxSplitSize</c>) — the Buy button
/// reads the SAME live value <see cref="SelectedObjectController"/>
/// seeds/the player adjusts via <c>ItemHolder::GetObjectSplitSize</c>
/// (<c>0x00586F00</c>), matching retail's <c>BuySingleItem</c>
/// (<c>pc:201674-201681</c>: quantity 1 if <c>_stackSize &lt;= 1</c>,
/// else the current slider value).
/// </param>
/// <param name="datFont">Retail dat font for the category dropdown's button/row labels.</param> /// <param name="datFont">Retail dat font for the category dropdown's button/row labels.</param>
/// <param name="debugFont">Fallback debug bitmap font (used when <paramref name="datFont"/> is null).</param> /// <param name="debugFont">Fallback debug bitmap font (used when <paramref name="datFont"/> is null).</param>
/// <param name="resolveSprite">Dat RenderSurface id → (GL tex handle, px width, px height).</param> /// <param name="resolveSprite">Dat RenderSurface id → (GL tex handle, px width, px height).</param>
@ -336,6 +395,9 @@ public sealed class VendorUiController : IRetainedPanelController
Func<ItemType, uint, uint, uint, uint, uint> resolveIcon, Func<ItemType, uint, uint, uint, uint, uint> resolveIcon,
ClientObjectTable objects, ClientObjectTable objects,
Func<uint> playerGuid, Func<uint> playerGuid,
ItemInteractionController itemInteraction,
SelectionState selection,
StackSplitQuantityState splitQuantity,
UiDatFont? datFont, UiDatFont? datFont,
BitmapFont? debugFont, BitmapFont? debugFont,
Func<uint, (uint tex, int w, int h)> resolveSprite, Func<uint, (uint tex, int w, int h)> resolveSprite,
@ -347,6 +409,9 @@ public sealed class VendorUiController : IRetainedPanelController
ArgumentNullException.ThrowIfNull(resolveIcon); ArgumentNullException.ThrowIfNull(resolveIcon);
ArgumentNullException.ThrowIfNull(objects); ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(playerGuid); ArgumentNullException.ThrowIfNull(playerGuid);
ArgumentNullException.ThrowIfNull(itemInteraction);
ArgumentNullException.ThrowIfNull(selection);
ArgumentNullException.ThrowIfNull(splitQuantity);
ArgumentNullException.ThrowIfNull(resolveSprite); ArgumentNullException.ThrowIfNull(resolveSprite);
if (layout.FindElement(ItemsPageId) is not { } itemsPage if (layout.FindElement(ItemsPageId) is not { } itemsPage
@ -374,6 +439,9 @@ public sealed class VendorUiController : IRetainedPanelController
resolveIcon, resolveIcon,
objects, objects,
playerGuid, playerGuid,
itemInteraction,
selection,
splitQuantity,
itemsPage, itemsPage,
buyingPage, buyingPage,
sellingPage, sellingPage,
@ -517,11 +585,12 @@ public sealed class VendorUiController : IRetainedPanelController
/// FIRST item that passed the filter becomes the display selection when /// FIRST item that passed the filter becomes the display selection when
/// the previous one didn't survive it, and the list unconditionally /// the previous one didn't survive it, and the list unconditionally
/// scrolls back to its start (<c>ScrollToShow(m_shopList, 0)</c>). /// scrolls back to its start (<c>ScrollToShow(m_shopList, 0)</c>).
/// Retail routes the selection through the global /// Slice 6.2: retail routes the selection through the global
/// <c>ACCWeenieObject::selectedID</c>/<c>SetSelectedObject</c>; this /// <c>ACCWeenieObject::selectedID</c>/<c>SetSelectedObject</c>
/// keeps the existing PRIVATE <see cref="_selectedItemGuid"/> selection /// (<c>pc:201184</c>, confirmed to be the SAME primitive as the fallback
/// instead of wiring that global seam (deferred — see the register, /// select here) — this now calls <see cref="SelectionState.Select"/>
/// AP-161). /// instead of the retired private field, so the toolbar status bar and
/// slider light up for the auto-selected item too.
/// </remarks> /// </remarks>
private void RebuildItemList() private void RebuildItemList()
{ {
@ -531,6 +600,7 @@ public sealed class VendorUiController : IRetainedPanelController
uint maskValue = (uint)activeMask; uint maskValue = (uint)activeMask;
IReadOnlyList<VendorShopItem> items = _vendor.Items; IReadOnlyList<VendorShopItem> items = _vendor.Items;
uint? selectedGuid = _selection.SelectedObjectId;
bool selectionStillPresent = false; bool selectionStillPresent = false;
VendorShopItem? firstItem = null; VendorShopItem? firstItem = null;
@ -544,7 +614,7 @@ public sealed class VendorUiController : IRetainedPanelController
if (((item.ItemType ?? 0u) & maskValue) == 0u) continue; if (((item.ItemType ?? 0u) & maskValue) == 0u) continue;
firstItem ??= item; firstItem ??= item;
if (item.ItemGuid == _selectedItemGuid) selectionStillPresent = true; if (item.ItemGuid == selectedGuid) selectionStillPresent = 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
@ -562,9 +632,9 @@ public sealed class VendorUiController : IRetainedPanelController
SlotIndex = _itemList.GetNumUIItems(), SlotIndex = _itemList.GetNumUIItems(),
}; };
cell.SetItem(item.ItemGuid, icon); cell.SetItem(item.ItemGuid, icon);
cell.Selected = item.ItemGuid == _selectedItemGuid; cell.Selected = item.ItemGuid == selectedGuid;
VendorShopItem captured = item; VendorShopItem captured = item;
cell.Clicked = () => SelectItem(captured); cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor);
_itemList.AddItem(cell); _itemList.AddItem(cell);
} }
} }
@ -572,8 +642,10 @@ public sealed class VendorUiController : IRetainedPanelController
if (!selectionStillPresent) if (!selectionStillPresent)
{ {
if (firstItem is { } first) SelectItem(first); if (firstItem is { } first)
else ClearSelection(); _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
@ -585,7 +657,13 @@ public sealed class VendorUiController : IRetainedPanelController
/// <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>,
/// <c>pc:202539-202820</c>). /// <c>pc:202539-202820</c>). Slice 6.2: called ONLY from
/// <see cref="OnSelectionTransition"/>, once <paramref name="item"/> has
/// already been confirmed to be the globally-selected guid — this method
/// no longer writes the selection itself (<see cref="SelectionState"/>
/// is the authority; row clicks, the F4 auto-select fallback, and
/// right-click examine all call <see cref="SelectionState.Select"/>
/// directly and let this method react).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// F2/F3 (Slice 5.4 review): the priced/named QUANTITY is retail's /// F2/F3 (Slice 5.4 review): the priced/named QUANTITY is retail's
@ -608,14 +686,12 @@ public sealed class VendorUiController : IRetainedPanelController
/// retail state pair (see <c>ToolbarController</c>'s /// retail state pair (see <c>ToolbarController</c>'s
/// <c>_useButton.Enabled</c>). /// <c>_useButton.Enabled</c>).
/// </remarks> /// </remarks>
private void SelectItem(VendorShopItem item) private void ApplyItemDisplay(VendorShopItem item)
{ {
_selectedItemGuid = item.ItemGuid;
for (int i = 0; i < _itemList.GetNumUIItems(); i++) for (int i = 0; i < _itemList.GetNumUIItems(); i++)
{ {
if (_itemList.GetItem(i) is { } cell) if (_itemList.GetItem(i) is { } cell)
cell.Selected = cell.ItemId == _selectedItemGuid; cell.Selected = cell.ItemId == item.ItemGuid;
} }
int quantity = VendorSplitSize(item); int quantity = VendorSplitSize(item);
@ -635,20 +711,86 @@ public sealed class VendorUiController : IRetainedPanelController
SetActionButtonsEnabled(true); SetActionButtonsEnabled(true);
} }
/// <summary>
/// Right-click examine on a shop row — mirrors
/// <c>ExternalContainerController.ExamineItem</c>'s "select then
/// request appraisal" shape, matching retail's single-selection model
/// (research doc §B.2: no dedicated double-click mechanism, plain
/// select-then-act). Slice 6.2: routes through
/// <see cref="SelectionState.Select"/> — <see cref="OnSelectionTransition"/>
/// applies the display update, so this method no longer needs to search
/// <see cref="_vendor"/>'s items itself.
/// </summary>
private void ExamineItem(uint guid)
{
_selection.Select(guid, SelectionChangeSource.Vendor);
_itemInteraction.ExamineSelectedOrEnterMode(guid);
}
/// <summary>
/// Slice 6.2: reacts to ANY global selection change, not just ones this
/// panel originated — mirrors <c>ExternalContainerController.OnSelectionChanged</c>.
/// Shows this panel's own price/name text for the newly-selected guid
/// when it is one of <see cref="_vendor"/>'s current items; clears the
/// panel's display otherwise (a selection made in some OTHER panel while
/// the vendor window is open must not leave stale vendor pricing text
/// on screen).
/// </summary>
private void OnSelectionTransition(SelectionTransition transition)
{
_ = transition;
uint? selected = _selection.SelectedObjectId;
if (selected is { } guid)
{
foreach (VendorShopItem item in _vendor.Items)
{
if (item.ItemGuid == guid)
{
ApplyItemDisplay(item);
return;
}
}
}
ClearSelectionDisplay();
}
/// <summary>
/// Slice 6.2: retail's <c>VendorItemsUI::RemoveFromShop</c>
/// (<c>pc:202848-202850</c>, <c>0x004c3d4a</c>) clears the global
/// selection when a shop item leaves the vendor's list — this mirrors
/// that (and, transitively, every other panel's own
/// <c>OnObjectRemoved</c>) rather than a vendor-specific "on close, set
/// selected to 0" special case. <c>VendorShopItemMaterializer</c>
/// removing every materialized item on session close/reset/replace
/// (Slice 6.1) is therefore what actually drives "vendor session close
/// clears a vendor-owned selection."
/// </summary>
private void OnObjectRemoved(ClientObject item)
{
if (_selection.SelectedObjectId == item.ObjectId)
{
_selection.Clear(
SelectionChangeSource.Vendor,
SelectionChangeReason.SelectedObjectRemoved);
}
}
/// <summary> /// <summary>
/// The quantity retail prices/names a vendor-shop selection at — /// The quantity retail prices/names a vendor-shop selection at —
/// <c>gmToolbarUI::HandleSelectionChanged</c>'s vendor-owned branch /// <c>gmToolbarUI::HandleSelectionChanged</c>'s vendor-owned branch
/// (<c>pc:198779-198790</c>, mask literal at <c>pc:198784</c>): 1 unit /// (<c>pc:198779-198790</c>). Every row <see cref="VendorUiController"/>
/// if the item's type intersects <see cref="SplitExemptMask"/>, else its /// shows IS vendor-owned (its container is unconditionally the open
/// own authored stack size (0/absent treated as 1, matching /// vendor), so the "does this item belong to the open vendor" gate that
/// <c>ItemHolder::GetObjectSplitSize</c>'s own <c>stackSize==0 -&gt; 1</c> /// precedes the mask check in retail's function is always true here and
/// floor, <c>pc:401473-401476</c>). /// is not reproduced separately. Slice 6.2: delegates to
/// <see cref="VendorSplitPolicy"/> — the single source of truth for the
/// <c>0xDC41CB0</c> mask, also used by <c>SelectedObjectController</c>'s
/// REAL seeding path (<c>InteractionRetainedUiComposition</c>'s
/// <c>isVendorSplitExempt</c> delegate) so the mask exists in exactly
/// one place.
/// </summary> /// </summary>
private static int VendorSplitSize(VendorShopItem item) private static int VendorSplitSize(VendorShopItem item) =>
{ VendorSplitPolicy.SeedQuantity((ItemType)(item.ItemType ?? 0u), item.DescStackSize);
if (((item.ItemType ?? 0u) & SplitExemptMask) != 0u) return 1;
return item.DescStackSize is { } size && size > 0 ? size : 1;
}
/// <summary> /// <summary>
/// Cost sentence — <c>VendorItemsUI::UpdateItemsUI</c>'s tail /// Cost sentence — <c>VendorItemsUI::UpdateItemsUI</c>'s tail
@ -698,9 +840,8 @@ public sealed class VendorUiController : IRetainedPanelController
playerTotal.ToString("N0", CultureInfo.InvariantCulture)); playerTotal.ToString("N0", CultureInfo.InvariantCulture));
} }
private void ClearSelection() private void ClearSelectionDisplay()
{ {
_selectedItemGuid = 0u;
for (int i = 0; i < _itemList.GetNumUIItems(); i++) for (int i = 0; i < _itemList.GetNumUIItems(); i++)
{ {
if (_itemList.GetItem(i) is { } cell) if (_itemList.GetItem(i) is { } cell)
@ -713,8 +854,65 @@ public sealed class VendorUiController : IRetainedPanelController
private void SetActionButtonsEnabled(bool enabled) private void SetActionButtonsEnabled(bool enabled)
{ {
if (_buyButton is not null) _buyButton.Enabled = enabled; _buyEnabledBySelection = enabled;
if (_addButton is not null) _addButton.Enabled = enabled; if (_addButton is not null) _addButton.Enabled = enabled;
RecomputeBuyButtonEnabled();
}
/// <summary>
/// Slice 6.3: the Buy button's enabled state is the CONJUNCTION of "is
/// something selected" (<see cref="_buyEnabledBySelection"/>, set by
/// <see cref="SetActionButtonsEnabled"/>) and "is the shared inventory/
/// use gate free right now" (<see cref="ItemInteractionController.CanMakeInventoryRequest"/>).
/// The Add button (staging, contract decision 6 — unwired this pass)
/// stays selection-only. Called on every selection change AND on every
/// <see cref="ItemInteractionController.StateChanged"/> tick, so the
/// button disables the instant <see cref="ItemInteractionController.TryBuy"/>
/// takes its reservation and re-enables on the matching completion —
/// no per-frame polling.
/// </summary>
private void RecomputeBuyButtonEnabled()
{
if (_buyButton is not null)
_buyButton.Enabled = _buyEnabledBySelection && _itemInteraction.CanMakeInventoryRequest;
}
private void OnInteractionStateChanged() => RecomputeBuyButtonEnabled();
/// <summary>
/// Slice 6.3: retail <c>gmVendorUI::BuySingleItem</c> (<c>pc:201661</c>).
/// Reads the CURRENT globally-selected shop item and the CURRENT split
/// quantity, then dispatches a single-item purchase through the shared
/// use/inventory reservation. Client-side affordability/capacity
/// pre-checks are deliberately NOT ported (research doc's open question
/// 1: the server is authoritative either way and pre-checks are latency/
/// UX polish, not correctness — deferred as a fast follow-up if the
/// round-trip lag on a refused purchase is noticeable live).
/// </summary>
private void BuySelectedItem()
{
if (_selection.SelectedObjectId is not { } guid)
return;
VendorShopItem? selected = null;
foreach (VendorShopItem item in _vendor.Items)
{
if (item.ItemGuid == guid)
{
selected = item;
break;
}
}
if (selected is not { } shopItem)
return;
uint stackSize = (uint)Math.Max(shopItem.DescStackSize ?? 1, 1);
uint quantity = _splitQuantity.GetObjectSplitSize(shopItem.ItemGuid, guid, stackSize);
_itemInteraction.TryBuy(
_vendor.VendorId,
shopItem.ItemGuid,
(int)quantity,
_vendor.Profile.AlternateCurrencyWcid);
} }
private void ClearContent() private void ClearContent()
@ -724,7 +922,13 @@ public sealed class VendorUiController : IRetainedPanelController
_typeMenu.Items = Array.Empty<UiMenu.MenuItem>(); _typeMenu.Items = Array.Empty<UiMenu.MenuItem>();
_typeMenu.Selected = null; _typeMenu.Selected = null;
_itemList.Flush(); _itemList.Flush();
ClearSelection(); // Local widget hygiene only — does NOT touch the global selection.
// This runs from the constructor (before any vendor is ever open)
// and cannot assume whatever SelectionState.SelectedObjectId
// currently holds belongs to this panel. The actual "vendor session
// close clears a vendor-owned selection" behavior is OnObjectRemoved
// reacting to VendorShopItemMaterializer's removal, not this method.
ClearSelectionDisplay();
} }
private static void SetPlainText(UiText text, string value) private static void SetPlainText(UiText text, string value)
@ -740,12 +944,18 @@ public sealed class VendorUiController : IRetainedPanelController
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
_vendor.Changed -= OnVendorChanged; _vendor.Changed -= OnVendorChanged;
_selection.Changed -= OnSelectionTransition;
_objects.ObjectRemoved -= OnObjectRemoved;
_itemInteraction.StateChanged -= OnInteractionStateChanged;
RetailTabBinding.SetClick(_itemsTab, null); RetailTabBinding.SetClick(_itemsTab, null);
RetailTabBinding.SetClick(_buyingTab, null); RetailTabBinding.SetClick(_buyingTab, null);
RetailTabBinding.SetClick(_sellingTab, null); RetailTabBinding.SetClick(_sellingTab, null);
_typeMenu.OnSelect = null; _typeMenu.OnSelect = null;
_typeMenu.ButtonLabelProvider = null; _typeMenu.ButtonLabelProvider = null;
_itemList.ExamineItemRequested = null;
if (_close is not null) if (_close is not null)
_close.OnClick = null; _close.OnClick = null;
if (_buyButton is not null)
_buyButton.OnClick = null;
} }
} }

View file

@ -112,7 +112,11 @@ public sealed record ToolbarRuntimeBindings(
Action<uint> SendQueryHealth, Action<uint> SendQueryHealth,
Action<uint> SendQueryItemMana, Action<uint> SendQueryItemMana,
Func<uint> PlayerGuid, Func<uint> PlayerGuid,
Action<uint, uint, int>? SendPutItemInContainer); Action<uint, uint, int>? SendPutItemInContainer,
// Slice 6.2: composed at InteractionRetainedUiComposition from
// d.Inventory.Vendor + d.Inventory.Objects — SelectedObjectController's
// vendor-owned split-exempt-seed predicate (research doc §C.1).
Func<uint, bool> IsVendorSplitExempt);
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider); public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
@ -170,7 +174,15 @@ public sealed record AppraisalRuntimeBindings(
public sealed record VendorRuntimeBindings( public sealed record VendorRuntimeBindings(
VendorState State, VendorState State,
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon); Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
// Slice 6.1: shared examine routing — VendorUiController.ExamineItem
// calls ItemInteraction.ExamineSelectedOrEnterMode the same way
// ExternalContainerRuntimeBindings.ItemInteraction's consumer does.
ItemInteractionController ItemInteraction,
// Slice 6.2: the canonical selection authority — every sibling binding
// record (Radar/Magic/Toolbar/Inventory/ExternalContainer) already
// carries this; Vendor was the one outlier (research doc §C.1).
SelectionState Selection);
public sealed record RetailUiRuntimeBindings( public sealed record RetailUiRuntimeBindings(
UiHost Host, UiHost Host,
@ -763,7 +775,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.DefaultFont, _bindings.Assets.DefaultFont,
StackSplitQuantity, StackSplitQuantity,
handler => b.Objects.ObjectUpdated += handler, handler => b.Objects.ObjectUpdated += handler,
handler => b.Objects.ObjectUpdated -= handler); handler => b.Objects.ObjectUpdated -= handler,
b.IsVendorSplitExempt);
UiElement root = layout.Root; UiElement root = layout.Root;
RetailWindowHandle handle = RetailWindowFrame.Mount( RetailWindowHandle handle = RetailWindowFrame.Mount(
@ -1994,6 +2007,9 @@ public sealed class RetailUiRuntime : IDisposable
b.ResolveIcon, b.ResolveIcon,
_bindings.Inventory.Objects, _bindings.Inventory.Objects,
_bindings.Inventory.PlayerGuid, _bindings.Inventory.PlayerGuid,
b.ItemInteraction,
b.Selection,
StackSplitQuantity,
_bindings.Assets.DefaultFont, _bindings.Assets.DefaultFont,
_bindings.Assets.DebugFont, _bindings.Assets.DebugFont,
_bindings.Assets.ResolveSprite, _bindings.Assets.ResolveSprite,

View file

@ -0,0 +1,110 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Outbound vendor GameActions. Slice 6.3: Buy (<c>0x005F</c>) only — Sell
/// (<c>0x0060</c>) is research-only scope (Slice 6 research doc §A.3), not
/// implemented here.
///
/// <para>
/// Wire layout, confirmed FOUR ways with zero disagreement (research doc
/// §A.1: ACE's reader, Chorizite's generated reader/writer, holtburger's
/// independent client, and the retail decompiled sender —
/// <c>CM_Vendor::Event_Buy</c>, <c>pc:689288</c>, <c>0x006AA0F0</c>):
/// <code>
/// u32 0xF7B1 // GameAction envelope
/// u32 gameActionSequence
/// u32 0x005F // Buy opcode
/// u32 vendorGuid
/// u32 itemCount
/// per item:
/// i32 amount // quantity to buy (plain positive int32,
/// // NOT ItemProfile's packed sign-extended
/// // supply-count field)
/// u32 objectGuid // the SHOP ITEM's guid
/// u32 alternateCurrencyId // TRAILING — 0 for a pyreal vendor
/// </code>
/// </para>
///
/// <para>
/// <b>The trailing <c>alternateCurrencyId</c> field — a deliberate,
/// evidence-backed divergence from ACE.</b> Retail's client
/// (<c>CM_Vendor::Event_Buy</c>) writes this field on EVERY Buy, after the
/// packed item list, every time (<c>pc:689335-689336</c>). ACE's current
/// server-side reader has the matching line PRESENT but COMMENTED OUT
/// (<c>GameActionBuyItems.cs:32</c>,
/// <c>//var altCurrencyWcid = message.Payload.ReadUInt32();</c>) — it simply
/// never reads the trailing bytes. holtburger, a real client written and
/// tested against ACE's actual accepted wire shape, omits the field
/// entirely and round-trips fine against ACE. Both are correct for what
/// they target: ACE demonstrably does not NEED this field today. We port
/// the field anyway because retail — the top oracle per this project's
/// CLAUDE.md — sends it unconditionally, it costs one <c>u32</c>, and it
/// costs ACE nothing to ignore (forward-compatible with any future ACE
/// build that un-comments its read). Do not "fix" this by dropping the
/// field without re-reading
/// <c>docs/research/2026-08-08-slice6-vendor-transactions-research.md</c>
/// §A.1 first — that document's "Resolution of the ACE/holtburger vs.
/// retail disagreement" section is the full reasoning trail.
/// </para>
/// </summary>
public static class VendorRequests
{
public const uint GameActionEnvelope = 0xF7B1u;
public const uint BuyOpcode = 0x005Fu;
/// <summary>
/// Build a Buy GameAction for <paramref name="items"/> — retail's
/// <c>CM_Vendor::Event_Buy(vendorGuid, &amp;list, currencyId)</c>. Slice
/// 6.3's Buy button always passes a ONE-entry list (retail
/// <c>BuySingleItem</c>, <c>pc:201661</c>, has no staging-list
/// dependency — see the Slice 6 research doc §B.1); the list shape is
/// kept general because that is literally the wire message's own shape
/// (a future "Buy All" staged-purchase path would reuse this builder
/// unchanged, not because Slice 6.3 needs it today).
/// </summary>
public static byte[] BuildBuy(
uint gameActionSequence,
uint vendorGuid,
IReadOnlyList<(int Amount, uint ItemGuid)> items,
uint alternateCurrencyId)
{
ArgumentNullException.ThrowIfNull(items);
int itemCount = items.Count;
byte[] body = new byte[24 + (itemCount * 8)];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), gameActionSequence);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), BuyOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), vendorGuid);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), (uint)itemCount);
int offset = 20;
for (int i = 0; i < itemCount; i++)
{
(int amount, uint itemGuid) = items[i];
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(offset), amount);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset + 4), itemGuid);
offset += 8;
}
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(offset), alternateCurrencyId);
return body;
}
/// <summary>Convenience overload for the single-item Buy Slice 6.3 sends.</summary>
public static byte[] BuildBuy(
uint gameActionSequence,
uint vendorGuid,
int amount,
uint itemGuid,
uint alternateCurrencyId)
=> BuildBuy(
gameActionSequence,
vendorGuid,
new[] { (amount, itemGuid) },
alternateCurrencyId);
}

View file

@ -2393,6 +2393,19 @@ public sealed class WorldSession : IDisposable
SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid)); SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid));
} }
/// <summary>
/// Slice 6.3: send retail Buy (0x005F) — a single-item purchase, retail
/// <c>CM_Vendor::Event_Buy</c> (<c>pc:689288</c>). See
/// <see cref="VendorRequests"/> for the wire layout and the deliberate
/// trailing <c>alternateCurrencyId</c> field (ported for retail
/// fidelity; ACE's server ignores it today).
/// </summary>
public void SendBuy(uint vendorGuid, uint itemGuid, int amount, uint alternateCurrencyId)
{
uint seq = NextGameActionSequence();
SendGameAction(VendorRequests.BuildBuy(seq, vendorGuid, amount, itemGuid, alternateCurrencyId));
}
/// <summary>Send retail IdentifyObject/Appraise (0x00C8).</summary> /// <summary>Send retail IdentifyObject/Appraise (0x00C8).</summary>
public void SendAppraise(uint targetGuid) public void SendAppraise(uint targetGuid)
{ {

View file

@ -0,0 +1,45 @@
namespace AcDream.Core.Items;
/// <summary>
/// Retail's vendor-owned stack-split-seed rule — the SHARED mask check
/// <c>gmToolbarUI::HandleSelectionChanged</c> applies when seeding
/// <c>GenItemHolder::splitSize</c> for a vendor-owned selection
/// (<c>pc:198779-198790</c>, mask literal at <c>pc:198784</c>): 1 unit if
/// the item's type intersects the mask, else the item's own authored stack
/// size.
///
/// <para>
/// Single source of truth for the <c>0xDC41CB0</c> mask (Slice 6.2, per the
/// research doc's §C.1 open question). Before this class existed the mask
/// was ported once, privately, inside <c>VendorUiController</c>
/// (display-only price/name text). <c>SelectedObjectController</c>'s real
/// seeding path (the toolbar quantity slider — the actual retail mechanism
/// this display text mirrors) needs the SAME check; both now call this
/// class instead of either carrying its own copy.
/// </para>
/// </summary>
public static class VendorSplitPolicy
{
public const uint SplitExemptMask = 0x0DC41CB0u;
/// <summary>
/// True when <paramref name="itemType"/> intersects
/// <see cref="SplitExemptMask"/> — a vendor-owned selection of this type
/// always seeds/prices/names as quantity 1, regardless of its authored
/// stack size.
/// </summary>
public static bool IsSplitExempt(ItemType itemType) =>
((uint)itemType & SplitExemptMask) != 0u;
/// <summary>
/// The quantity retail prices/names/seeds a VENDOR-OWNED selection at:
/// 1 if <paramref name="itemType"/> intersects <see cref="SplitExemptMask"/>,
/// else <paramref name="authoredStackSize"/> (0/absent treated as 1,
/// matching <c>ItemHolder::GetObjectSplitSize</c>'s own
/// <c>stackSize==0 -&gt; 1</c> floor, <c>pc:401473-401476</c>).
/// </summary>
public static int SeedQuantity(ItemType itemType, int? authoredStackSize) =>
IsSplitExempt(itemType)
? 1
: authoredStackSize is { } size && size > 0 ? size : 1;
}

View file

@ -13,6 +13,11 @@ public enum SelectionChangeSource
Toolbar, Toolbar,
Keyboard, Keyboard,
Plugin, Plugin,
// Slice 6.2: a vendor shop-list row click — research doc §B.4 confirms
// vendor-context selections flow through the SAME global
// ACCWeenieObject::SetSelectedObject primitive as every other origin
// (VendorSellUI::AddItemToSell, pc:203558), never a vendor-local one.
Vendor,
} }
public enum SelectionChangeReason public enum SelectionChangeReason

View file

@ -17,7 +17,13 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
long ShortcutDispatchFailureCount, long ShortcutDispatchFailureCount,
long TransactionDispatchFailureCount, long TransactionDispatchFailureCount,
// Slice 5.3: the sole open vendor shop id, 0 when no session is open. // Slice 5.3: the sole open vendor shop id, 0 when no session is open.
uint VendorId) uint VendorId,
// Slice 6.1: guids VendorShopItemMaterializer currently owns in
// ClientObjectTable. Must reach 0 alongside VendorId — a nonzero count
// here with VendorId already 0 would mean materialized shop items
// outlived their session (the exact regression the removal-lifecycle
// requirement guards against).
int MaterializedVendorItemCount)
{ {
public bool IsConverged => public bool IsConverged =>
IsDisposed IsDisposed
@ -30,7 +36,8 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
&& ItemManaCount == 0 && ItemManaCount == 0
&& ShortcutCount == 0 && ShortcutCount == 0
&& ShortcutSubscriberCount == 0 && ShortcutSubscriberCount == 0
&& VendorId == 0u; && VendorId == 0u
&& MaterializedVendorItemCount == 0;
} }
/// <summary> /// <summary>
@ -58,6 +65,13 @@ public sealed class RuntimeInventoryState : IDisposable
// Changed event for presentation observers" shape, generation-gated // Changed event for presentation observers" shape, generation-gated
// and torn down alongside the rest of this owner's children. // and torn down alongside the rest of this owner's children.
Vendor = new VendorState(); Vendor = new VendorState();
// Slice 6.1: materializes/retires ApproachVendor shop items into the
// SAME ClientObjectTable this owner exposes as Objects — see
// VendorShopItemMaterializer's class doc for why it subscribes to
// Vendor.Changed directly rather than the ApproachVendor wire
// handler (it must also react to RuntimeVendorRangeQuery's
// client-local Close() and this owner's own Reset()/Dispose()).
VendorItems = new VendorShopItemMaterializer(Vendor, _entityObjects.Objects);
View = new InventoryStateView(this); View = new InventoryStateView(this);
} }
@ -67,6 +81,7 @@ public sealed class RuntimeInventoryState : IDisposable
public ShortcutStore Shortcuts { get; } public ShortcutStore Shortcuts { get; }
public InventoryTransactionState Transactions { get; } public InventoryTransactionState Transactions { get; }
public VendorState Vendor { get; } public VendorState Vendor { get; }
public VendorShopItemMaterializer VendorItems { get; }
public IRuntimeInventoryStateView View { get; } public IRuntimeInventoryStateView View { get; }
public bool IsDisposed => _disposed; public bool IsDisposed => _disposed;
@ -83,7 +98,8 @@ public sealed class RuntimeInventoryState : IDisposable
Shortcuts.SubscriberCount, Shortcuts.SubscriberCount,
Shortcuts.DispatchFailureCount, Shortcuts.DispatchFailureCount,
Transactions.DispatchFailureCount, Transactions.DispatchFailureCount,
Vendor.VendorId); Vendor.VendorId,
VendorItems.OwnedCount);
public void ResetExternalContainer() => ExternalContainers.Reset(); public void ResetExternalContainer() => ExternalContainers.Reset();
public void ResetTransactions() => Transactions.ResetSession(); public void ResetTransactions() => Transactions.ResetSession();
@ -155,7 +171,13 @@ public sealed class RuntimeInventoryState : IDisposable
try try
{ {
Try(() => ExternalContainers.Reset(), ref failures); Try(() => ExternalContainers.Reset(), ref failures);
// Vendor.Reset() must run BEFORE VendorItems.Dispose() —
// Reset() fires Changed synchronously, which is what drives the
// materializer's own retire pass; disposing first would
// unsubscribe before that pass runs and strand materialized
// items in ClientObjectTable past session teardown.
Try(() => Vendor.Reset(), ref failures); Try(() => Vendor.Reset(), ref failures);
Try(VendorItems.Dispose, ref failures);
Try(ItemMana.Clear, ref failures); Try(ItemMana.Clear, ref failures);
Try(Shortcuts.Dispose, ref failures); Try(Shortcuts.Dispose, ref failures);
Try(Transactions.Dispose, ref failures); Try(Transactions.Dispose, ref failures);

View file

@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Items;
namespace AcDream.Runtime.Gameplay;
/// <summary>
/// Slice 6.1: materializes each <c>ApproachVendor</c> shop-list item into the
/// SAME <see cref="ClientObjectTable"/> Runtime issues identity into for
/// spawned entities (J3.5's "Runtime issues identity before App hydration"
/// ownership — see <c>ObjectTableWiring.ApplyEntitySpawn</c>, which is
/// invoked from <c>RuntimeEntityObjectLifetime</c>, never directly from
/// <c>GameEventWiring</c>/<c>AcDream.Core.Net</c>). Owned by
/// <see cref="RuntimeInventoryState"/> alongside the <see cref="VendorState"/>
/// it observes and the exact <see cref="ClientObjectTable"/> instance
/// <c>RuntimeEntityObjectLifetime</c> owns.
///
/// <para>
/// <b>Why subscribe to <see cref="VendorState.Changed"/> instead of the
/// ApproachVendor wire handler.</b> A listener registered only on the
/// ApproachVendor GameEvent (<c>GameEventWiring.cs</c>) would only ever see
/// the wire-driven <c>Opened</c>/<c>Refreshed</c> transitions. Two of the
/// four transition kinds never touch the wire at all:
/// <c>RuntimeVendorRangeQuery.EnforceRange</c>'s distance-triggered
/// <see cref="VendorState.Close"/> and this owner's own
/// <see cref="VendorState.Reset"/> teardown (session reset / portal-out /
/// logout / final disposal). Subscribing directly to <c>Changed</c> reacts
/// uniformly to every source, matching the Slice 6 contract's "on session
/// Close/Replace/Reset, the materialized shop items leave the table."
/// </para>
///
/// <para>
/// <b>Retail anchor.</b> <c>gmVendorUI::OpenVendor</c> materializes each
/// list item as a full <c>CWeenieObject</c> in <c>ClientObjMaintSystem</c>
/// (research doc <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c>
/// §A.2 point 4, pc:203720-203748) — vendor items are ordinary client
/// objects with no spatial presence, not a separate lightweight record.
/// §C.1 of the Slice 6 research doc names the load-bearing consequence:
/// without a live <see cref="ClientObjectTable"/> entry,
/// <c>SelectedObjectController</c>'s name/stack resolvers (Slice 6.2) and
/// <c>AppraisalUiController.Apply</c> (this slice's examine wiring) both
/// come up blank. <c>gmVendorUI::CloseVendor</c> (pc:202080) is the retail
/// teardown site this class mirrors for the removal half.
/// </para>
///
/// <para>
/// <b>Diff, not blanket remove-then-reinsert.</b> ACE's own
/// <c>Vendor.LoadInventory</c>
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:126-172</c>)
/// assigns each <c>DefaultItemsForSale</c> entry a real server guid ONCE, at
/// first load — that guid is stable across every later
/// <c>ApproachVendor</c> for the SAME vendor, so a post-buy/sell
/// <c>Refreshed</c> snapshot reuses the same guids for stock that's still in
/// supply. Removing and immediately re-adding a still-present guid would
/// fire a spurious <c>ObjectRemoved</c>/<c>ObjectAdded</c> pair for every
/// unrelated line item on every refresh — a UI panel holding that guid (an
/// open appraisal window, Slice 6.2's <c>SelectionState</c>) would see a
/// false "it's gone" notice. Only guids that left this vendor's stock (sold
/// out, or a DIFFERENT vendor entirely superseded this one) are removed;
/// every still-present guid is merge-upserted via the ordinary
/// <see cref="ClientObjectTable.Ingest"/> path (a harmless no-op if
/// genuinely unchanged, a field refresh otherwise). On a supersede (a
/// DIFFERENT vendor's <c>Opened</c> transition), the retire pass runs before
/// the materialize pass in the SAME call, satisfying the contract's "on
/// REPLACE, the old vendor's items go before the new ones land."
/// </para>
///
/// <para>
/// <b>Collision policy.</b> ACE's <c>UniqueItemsForSale</c>
/// (<c>Vendor.cs:34,638</c>) keeps the EXACT <c>WorldObject</c> — and
/// therefore the exact guid — a player last held when they sold it to this
/// vendor. If that guid is already present in <see cref="ClientObjectTable"/>
/// for a reason THIS materializer did not itself create (a live entity, an
/// item still sitting in someone's inventory/equipment, or any other
/// collision), <see cref="ClientObjectTable.Ingest"/>-ing vendor-owned
/// <see cref="WeenieData"/> over it would silently reparent a real object
/// into the vendor's container. Per the project's no-workarounds-without-
/// approval rule this is a SKIP, not a best-effort overwrite: a guid this
/// class did not itself add to its owned set on the previous cycle is
/// treated as owned by someone else and is left completely untouched (not
/// materialized, not tracked, not later removed by this class either). The
/// vendor row still renders correctly regardless —
/// <c>VendorUiController</c> reads display fields straight off
/// <see cref="VendorShopItem"/>, never through <see cref="ClientObjectTable"/>
/// — only that one item's status-bar/appraisal projection stays whatever it
/// already was, which is safe by construction and never corrupts a real
/// object's ownership.
/// </para>
/// </summary>
public sealed class VendorShopItemMaterializer : IDisposable
{
private readonly VendorState _vendor;
private readonly ClientObjectTable _objects;
private readonly HashSet<uint> _ownedGuids = new();
private bool _disposed;
public VendorShopItemMaterializer(VendorState vendor, ClientObjectTable objects)
{
_vendor = vendor ?? throw new ArgumentNullException(nameof(vendor));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_vendor.Changed += OnVendorTransition;
}
/// <summary>
/// Count of guids this materializer currently owns in
/// <see cref="ClientObjectTable"/>. Zero once the session is closed/
/// reset — feeds <see cref="RuntimeInventoryOwnershipSnapshot"/>'s
/// convergence gate.
/// </summary>
public int OwnedCount => _ownedGuids.Count;
/// <summary>True if <paramref name="guid"/> is a shop item this materializer put in the table.</summary>
public bool Owns(uint guid) => _ownedGuids.Contains(guid);
private void OnVendorTransition(VendorTransition transition)
{
IReadOnlyList<VendorShopItem> currentItems = _vendor.Items;
var stillListed = new HashSet<uint>(currentItems.Count);
foreach (VendorShopItem item in currentItems)
stillListed.Add(item.ItemGuid);
// Retire every guid we own that fell out of the new snapshot (sold
// out, session closed/reset, or a different vendor superseded this
// one — in every one of those cases stillListed is missing it).
// Runs BEFORE the materialize loop below: "on REPLACE, the old
// vendor's items go before the new ones land."
foreach (uint guid in _ownedGuids)
{
if (!stillListed.Contains(guid))
_objects.Remove(guid);
}
var nextOwned = new HashSet<uint>(currentItems.Count);
foreach (VendorShopItem item in currentItems)
{
bool ownedAlready = _ownedGuids.Contains(item.ItemGuid);
if (!ownedAlready && _objects.Get(item.ItemGuid) is not null)
{
// Collision guard — see class doc. Never take ownership of a
// guid this materializer did not itself add.
Console.Error.WriteLine(
"[VendorShopItemMaterializer] skipped guid=0x"
+ item.ItemGuid.ToString("X8")
+ " — already present in ClientObjectTable and not "
+ "owned by this vendor session.");
continue;
}
_objects.Ingest(ToWeenieData(item, transition.VendorId));
nextOwned.Add(item.ItemGuid);
}
_ownedGuids.Clear();
foreach (uint guid in nextOwned)
_ownedGuids.Add(guid);
}
/// <summary>
/// Field mapping from the domain-shaped <see cref="VendorShopItem"/> to
/// the wire-shaped merge patch <see cref="ClientObjectTable.Ingest"/>
/// expects. <see cref="VendorShopItem.DescStackSize"/> — not
/// <see cref="VendorShopItem.StackSize"/>, ItemProfile's separate packed
/// SUPPLY-count field — is the wire equivalent of an ordinary
/// CreateObject's own StackSize field (see the doc comment on
/// <see cref="VendorShopItem.DescStackSize"/>). Every field
/// <see cref="VendorShopItem"/> doesn't carry (capacity, equip mask,
/// combat use, etc.) is passed null, leaving it untouched on a refresh
/// and defaulted on a fresh object per <see cref="WeenieData"/>'s
/// null-preserving merge contract.
/// </summary>
private static WeenieData ToWeenieData(VendorShopItem item, uint vendorId) => new(
Guid: item.ItemGuid,
Name: item.Name,
Type: item.ItemType is { } t ? (ItemType)t : null,
WeenieClassId: item.WeenieClassId,
IconId: item.IconId,
IconOverlayId: item.IconOverlayId,
IconUnderlayId: item.IconUnderlayId,
Effects: item.Effects,
Value: item.Value,
StackSize: item.DescStackSize,
StackSizeMax: null,
Burden: null,
ContainerId: vendorId,
WielderId: 0u,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null,
PluralName: item.PluralName);
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_vendor.Changed -= OnVendorTransition;
}
}

View file

@ -27,6 +27,7 @@ public sealed class ItemInteractionControllerTests
public readonly List<uint> Drops = new(); public readonly List<uint> Drops = new();
public readonly List<(uint Item, uint Amount)> SplitDrops = new(); public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new(); public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
public readonly List<string> Toasts = new(); public readonly List<string> Toasts = new();
public readonly List<string> SystemMessages = new(); public readonly List<string> SystemMessages = new();
public readonly List<CombatMode> CombatModeRequests = new(); public readonly List<CombatMode> CombatModeRequests = new();
@ -94,7 +95,9 @@ public sealed class ItemInteractionControllerTests
}, },
combatState: Combat, combatState: Combat,
sendChangeCombatMode: CombatModeRequests.Add, sendChangeCombatMode: CombatModeRequests.Add,
requestUse: requestUse); requestUse: requestUse,
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId)));
} }
public ItemInteractionController Controller { get; } public ItemInteractionController Controller { get; }
@ -2145,4 +2148,123 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(0, h.Controller.BusyCount); Assert.Equal(0, h.Controller.BusyCount);
Assert.Equal(InteractionModeKind.None, h.Controller.InteractionState.Current.Kind); Assert.Equal(InteractionModeKind.None, h.Controller.InteractionState.Current.Kind);
} }
// ── Slice 6.3: TryBuy ───────────────────────────────────────────────
[Fact]
public void TryBuy_Succeeds_SendsBuyAndTakesTheSharedUseReservation()
{
var h = new Harness();
bool result = h.Controller.TryBuy(
vendorGuid: 0x40001000u,
itemGuid: 0x50002000u,
amount: 1,
alternateCurrencyId: 0u);
Assert.True(result);
Assert.Equal(
new[] { (0x40001000u, 0x50002000u, 1, 0u) },
h.Buys);
// BeginUseRequestReservation increments BusyCount synchronously,
// before/independent of any wire response -- this is what makes the
// Buy button disable immediately (research doc §A.4: "no second
// gate", the SAME BusyCount>0 check every other request rides).
Assert.Equal(1, h.Controller.BusyCount);
}
[Fact]
public void TryBuy_StackedQuantity_ForwardsTheExactAmount()
{
var h = new Harness();
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002001u, 25, 0u));
Assert.Equal(25, h.Buys.Single().Amount);
}
[Fact]
public void TryBuy_AlternateCurrencyVendor_ForwardsTheCurrencyWcid()
{
var h = new Harness();
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0x12345678u));
Assert.Equal(0x12345678u, h.Buys.Single().AlternateCurrencyId);
}
[Fact]
public void TryBuy_WhileAnotherRequestIsBusy_IsRejectedAndSendsNothing()
{
var h = new Harness();
h.Controller.IncrementBusyCount(); // simulates any other in-flight request
bool result = h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u);
Assert.False(result);
Assert.Empty(h.Buys);
Assert.Equal(1, h.Controller.BusyCount); // unchanged -- no second reservation taken
}
[Fact]
public void TryBuy_ASecondBuyWhileTheFirstIsInFlight_IsRejected()
{
var h = new Harness();
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u));
bool second = h.Controller.TryBuy(0x40001000u, 0x50002001u, 1, 0u);
Assert.False(second);
Assert.Single(h.Buys);
Assert.Equal(1, h.Controller.BusyCount);
}
[Theory]
[InlineData(0u, 0x50002000u, 1)]
[InlineData(0x40001000u, 0u, 1)]
[InlineData(0x40001000u, 0x50002000u, 0)]
[InlineData(0x40001000u, 0x50002000u, -1)]
public void TryBuy_InvalidArguments_IsRejectedWithoutTakingAReservation(
uint vendorGuid, uint itemGuid, int amount)
{
var h = new Harness();
bool result = h.Controller.TryBuy(vendorGuid, itemGuid, amount, 0u);
Assert.False(result);
Assert.Empty(h.Buys);
Assert.Equal(0, h.Controller.BusyCount);
}
[Fact]
public void TryBuy_CompleteUse_ReleasesTheReservationAndReenablesFurtherRequests()
{
// Research doc §A.4: UseDone (0x01C7) is the completion signal for
// Buy, resolved through the SAME RuntimeInteractionTransactionState.
// CompleteUse the existing UseDone handler already calls -- no new
// completion plumbing needed on the receive side.
var h = new Harness();
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u));
Assert.Equal(1, h.Controller.BusyCount);
h.Controller.CompleteUse(0);
Assert.Equal(0, h.Controller.BusyCount);
// The gate is free again -- a second Buy can now proceed.
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002001u, 1, 0u));
Assert.Equal(2, h.Buys.Count);
}
[Fact]
public void TryBuy_FailedUseDone_AlsoReleasesTheReservation()
{
// A.2's failure paths all still end in exactly one SendUseDoneEvent
// -- success or failure, the reservation resolves the same way.
var h = new Harness();
Assert.True(h.Controller.TryBuy(0x40001000u, 0x50002000u, 1, 0u));
h.Controller.CompleteUse(0x0009u); // an arbitrary nonzero WeenieError
Assert.Equal(0, h.Controller.BusyCount);
}
} }

View file

@ -94,6 +94,9 @@ public class SelectedObjectControllerTests
public readonly Dictionary<uint, bool> HasHealthMap = new(); public readonly Dictionary<uint, bool> HasHealthMap = new();
public readonly Dictionary<uint, float> ManaMap = new(); public readonly Dictionary<uint, float> ManaMap = new();
public readonly Dictionary<uint, uint> StackMap = new(); public readonly Dictionary<uint, uint> StackMap = new();
// Slice 6.2: vendor-owned split-exempt predicate — see
// SelectedObjectController.Bind's isVendorSplitExempt parameter.
public readonly Dictionary<uint, bool> VendorSplitExemptMap = new();
public void FireSelection(uint? g) public void FireSelection(uint? g)
{ {
@ -135,7 +138,8 @@ public class SelectedObjectControllerTests
unsubscribeObjectUpdated: h => unsubscribeObjectUpdated: h =>
{ {
if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null; if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null;
}); },
isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v);
} }
// ── B1: Bind initialisation ────────────────────────────────────────────── // ── B1: Bind initialisation ──────────────────────────────────────────────

View file

@ -4,6 +4,8 @@ using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Properties; using AcDream.Core.Properties;
using AcDream.Core.Selection;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -41,13 +43,26 @@ public sealed class VendorUiControllerTests
Visible = false, Visible = false,
}); });
var objects = new ClientObjectTable();
using var itemInteraction = new ItemInteractionController(
objects,
new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)),
new InteractionState(),
playerGuid: static () => 0u,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
VendorUiController? controller = VendorUiController.Bind( VendorUiController? controller = VendorUiController.Bind(
layout, layout,
new VendorState(), new VendorState(),
window, window,
static (_, _, _, _, _) => 0u, static (_, _, _, _, _) => 0u,
new ClientObjectTable(), objects,
static () => 0u, static () => 0u,
itemInteraction,
new SelectionState(),
new StackSplitQuantityState(),
datFont: null, datFont: null,
debugFont: null, debugFont: null,
static _ => (0u, 0, 0)); static _ => (0u, 0, 0));
@ -60,9 +75,11 @@ public sealed class VendorUiControllerTests
// F2/F3: a deterministic non-zero player coin total so the cost-text // F2/F3: a deterministic non-zero player coin total so the cost-text
// "(you have ...)" tail is assertable. // "(you have ...)" tail is assertable.
public const int DefaultPlayerCoinValue = 1500; public const int DefaultPlayerCoinValue = 1500;
private const uint PlayerGuid = 0x50000001u; public const uint PlayerGuid = 0x50000001u;
public readonly VendorState State = new(); public readonly VendorState State = new();
public readonly SelectionState Selection = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public readonly UiRoot Screen = new() { Width = 800f, Height = 600f }; public readonly UiRoot Screen = new() { Width = 800f, Height = 600f };
public readonly ClientObjectTable Objects = new(); public readonly ClientObjectTable Objects = new();
public readonly UiItemList ItemList = new(); public readonly UiItemList ItemList = new();
@ -81,6 +98,9 @@ public sealed class VendorUiControllerTests
public readonly UiButton AddButton; public readonly UiButton AddButton;
public readonly RetailWindowHandle Window; public readonly RetailWindowHandle Window;
public readonly VendorUiController Controller; public readonly VendorUiController Controller;
public readonly List<uint> Examines = new();
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
public readonly ItemInteractionController ItemInteraction;
public Harness() public Harness()
{ {
@ -145,6 +165,19 @@ public sealed class VendorUiControllerTests
Resizable = false, Resizable = false,
}); });
ItemInteraction = new ItemInteractionController(
Objects,
new RuntimeInteractionTransactionState(new InventoryTransactionState(Objects)),
new InteractionState(),
playerGuid: static () => PlayerGuid,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
sendExamine: Examines.Add,
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId)));
Controller = VendorUiController.Bind( Controller = VendorUiController.Bind(
layout, layout,
State, State,
@ -155,6 +188,9 @@ public sealed class VendorUiControllerTests
static (_, iconId, underlay, overlay, effects) => iconId + underlay + overlay + effects, static (_, iconId, underlay, overlay, effects) => iconId + underlay + overlay + effects,
Objects, Objects,
static () => PlayerGuid, static () => PlayerGuid,
ItemInteraction,
Selection,
SplitQuantity,
datFont: null, datFont: null,
debugFont: null, debugFont: null,
static _ => (0u, 0, 0))!; static _ => (0u, 0, 0))!;
@ -611,4 +647,204 @@ public sealed class VendorUiControllerTests
Assert.False(h.SellingPage.Visible); Assert.False(h.SellingPage.Visible);
Assert.Equal(1, h.ItemList.GetNumUIItems()); Assert.Equal(1, h.ItemList.GetNumUIItems());
} }
[Fact]
public void RightClickShopRow_SelectsAndRoutesThroughItemInteractionExamine()
{
// Slice 6.1: mirrors ExternalContainerControllerTests'
// RightClickLoot_selectsAndExaminesWithoutPickingUp — the shop list
// wires ExamineItemRequested the same way the container lists do,
// now that shop items are materialized into ClientObjectTable
// (AP-161 finding #2's examine gap closes here).
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(FoodItemGuid, -1, 1u, "Bread", (uint)ItemType.Food, 100u, 5),
});
// Food, not the auto-selected Armor default, so the assertion below
// proves the right click itself drove the selection.
UiItemSlot? foodCell = null;
object? foodPayload = h.TypeMenu.Items.First(i => i.Label == "Food").Payload;
h.TypeMenu.OnSelect!.Invoke(foodPayload);
foodCell = h.ItemList.GetItem(0);
Assert.Equal(FoodItemGuid, foodCell!.ItemId);
foodCell.OnEvent(new UiEvent(0u, foodCell, UiEventType.RightClick));
Assert.Equal(new[] { FoodItemGuid }, h.Examines);
Assert.Equal("Bread", GetText(h.ItemNameText));
Assert.True(foodCell.Selected);
}
// ── Slice 6.3: Buy button ────────────────────────────────────────────
[Fact]
public void BuyButton_Press_NonStackedItem_BuysQuantityOne()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// F4 auto-selected the sole item; its own stack size (default/absent)
// is <=1, so StackSplitQuantityState was never seeded above 1 either
// (this harness doesn't mount SelectedObjectController, so the split
// state starts at its class default Value=1/Maximum=1 — exactly what
// production would also show for a non-stacked item).
h.BuyButton.OnClick!.Invoke();
Assert.Equal(
new[] { (VendorGuid, ArmorItemGuid, 1, 0u) },
h.Buys);
}
[Fact]
public void BuyButton_Press_StackedItem_UsesTheLiveSplitSliderQuantity()
{
// Slice 6.3: BuySingleItem (pc:201674-201681) reads the CURRENT
// slider value, not the full stack. This harness doesn't mount
// SelectedObjectController (the toolbar owns that seeding in
// production), so the test seeds SplitQuantity directly to stand in
// for "the player selected this item, then dragged the slider to
// 25" before pressing Buy.
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: 25u);
h.BuyButton.OnClick!.Invoke();
Assert.Equal(25, h.Buys.Single().Amount);
}
[Fact]
public void BuyButton_Press_AlternateCurrencyVendor_ForwardsTheVendorsTradeWcid()
{
var h = new Harness();
h.State.Apply(
VendorGuid,
Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 500u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.BuyButton.OnClick!.Invoke();
Assert.Equal(0x12345678u, h.Buys.Single().AlternateCurrencyId);
}
[Fact]
public void BuyButton_DisablesTheInstantAPurchaseIsInFlight_AndReenablesOnCompletion()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
Assert.True(h.BuyButton.Enabled);
h.BuyButton.OnClick!.Invoke();
// TryBuy's reservation increments BusyCount synchronously, before
// any wire response — the button must reflect that immediately,
// with no per-frame polling (ItemInteractionController.StateChanged
// drives RecomputeBuyButtonEnabled).
Assert.False(h.BuyButton.Enabled);
h.ItemInteraction.CompleteUse(0);
Assert.True(h.BuyButton.Enabled);
}
[Fact]
public void BuyButton_NoSelection_PressDoesNothing()
{
var h = new Harness();
h.BuyButton.OnClick!.Invoke();
Assert.Empty(h.Buys);
}
[Fact]
public void ReconciliationRoundTrip_MoneyCreateObjectAndApproachVendorRefresh_FlowThroughExistingMachinery()
{
// Slice 6.3: verifies the loop end-to-end with synthetic inbound
// messages, adding no new owner (research doc §C.4/§A.2 point 4):
// (1) a money property update applies to the SAME ClientObjectTable
// the vendor panel reads live for its cost text,
// (2) the purchase lands in the player's inventory via the ordinary
// CreateObject merge-upsert (ClientObjectTable.Ingest),
// (3) the vendor's post-buy ApproachVendor refresh
// (VendorStateTransitionKind.Refreshed) rebuilds the panel —
// here the bought-out item leaves the vendor's stock entirely
// (the common single-item-purchase case), so the rebuild is
// externally observable: the item leaves the list and the
// selection/buttons clear.
var h = new Harness();
const uint PurchasedItemGuid = 0x60000900u;
h.State.Apply(VendorGuid, Profile(sellRate: 1.0f), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
Assert.Equal(1, h.ItemList.GetNumUIItems());
Assert.True(h.BuyButton.Enabled);
h.BuyButton.OnClick!.Invoke();
Assert.Single(h.Buys);
Assert.False(h.BuyButton.Enabled);
// (1) Money: PrivateUpdatePropertyInt(CoinValue) — the exact path
// ObjectTableWiring's PlayerIntPropertyUpdated handler calls.
int newCoinValue = Harness.DefaultPlayerCoinValue - 500;
h.Objects.UpdateIntProperty(Harness.PlayerGuid, (uint)PropertyInt.CoinValue, newCoinValue);
// (2) CreateObject: the purchased item lands in the player's own
// inventory — the ordinary Ingest merge-upsert every CreateObject
// uses (ObjectTableWiring.ApplyEntitySpawn), landing here with the
// player as its container.
h.Objects.Ingest(new WeenieData(
Guid: PurchasedItemGuid,
Name: "Chainmail",
Type: ItemType.Armor,
WeenieClassId: 2u,
IconId: 200u,
IconOverlayId: 0u,
IconUnderlayId: 0u,
Effects: 0u,
Value: 500,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: Harness.PlayerGuid,
WielderId: 0u,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null));
// (3) ApproachVendor refresh: sold out of the ONLY armor stack, so
// the SAME vendor's next snapshot no longer lists it.
h.State.Apply(VendorGuid, Profile(sellRate: 1.0f), System.Array.Empty<VendorShopItem>());
// (4) UseDone completes the reservation.
h.ItemInteraction.CompleteUse(0);
Assert.Equal(newCoinValue, h.Objects.Get(Harness.PlayerGuid)?.Properties.GetInt((uint)PropertyInt.CoinValue));
Assert.Equal(Harness.PlayerGuid, h.Objects.Get(PurchasedItemGuid)?.ContainerId);
Assert.Equal(0, h.ItemList.GetNumUIItems());
Assert.Equal(string.Empty, GetText(h.ItemNameText));
Assert.False(h.BuyButton.Enabled);
}
} }

View file

@ -0,0 +1,127 @@
using System;
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Slice 6.3 golden-byte coverage for the outbound Buy (<c>0x005F</c>)
/// builder — research doc §A.1's four-way-confirmed wire layout, including
/// the trailing <c>alternateCurrencyId</c> field the real retail client
/// sends but ACE's reader currently ignores.
/// </summary>
public sealed class VendorRequestsTests
{
[Fact]
public void BuildBuy_SingleItem_WritesEnvelopeSequenceOpcodeVendorCountAndItem()
{
byte[] body = VendorRequests.BuildBuy(
gameActionSequence: 9,
vendorGuid: 0x40001000u,
amount: 1,
itemGuid: 0x50002000u,
alternateCurrencyId: 0u);
// envelope(4) + seq(4) + opcode(4) + vendorGuid(4) + itemCount(4)
// + 1*(amount(4)+guid(4)) + trailing currency(4) = 32.
Assert.Equal(32, body.Length);
Assert.Equal(VendorRequests.GameActionEnvelope,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)));
Assert.Equal(9u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
Assert.Equal(VendorRequests.BuyOpcode,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
Assert.Equal(0x005Fu, VendorRequests.BuyOpcode);
Assert.Equal(0x40001000u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
Assert.Equal(1u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(1,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
Assert.Equal(0x50002000u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
// Trailing alternateCurrencyId — present even for a pyreal (0) vendor,
// matching retail's CM_Vendor::Event_Buy which writes it unconditionally.
Assert.Equal(0u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(28)));
}
[Fact]
public void BuildBuy_StackedItem_WritesTheSplitSliderQuantityAsAPlainPositiveAmount()
{
// amount is NOT ItemProfile's packed sign-extended supply-count
// field -- it's a plain positive int32 (research doc §A.1).
byte[] body = VendorRequests.BuildBuy(
gameActionSequence: 1,
vendorGuid: 0x40001000u,
amount: 25,
itemGuid: 0x50002001u,
alternateCurrencyId: 0u);
Assert.Equal(25,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
}
[Fact]
public void BuildBuy_AlternateCurrencyVendor_WritesTheVendorsTradeWcidTrailing()
{
byte[] body = VendorRequests.BuildBuy(
gameActionSequence: 1,
vendorGuid: 0x40001000u,
amount: 1,
itemGuid: 0x50002000u,
alternateCurrencyId: 0x12345678u);
Assert.Equal(0x12345678u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(28)));
}
[Fact]
public void BuildBuy_ListOverload_MultipleItems_WritesEachAmountGuidPairInOrder()
{
byte[] body = VendorRequests.BuildBuy(
gameActionSequence: 4,
vendorGuid: 0x40001000u,
items: new (int Amount, uint ItemGuid)[]
{
(1, 0x50002000u),
(10, 0x50002001u),
},
alternateCurrencyId: 0u);
// 24 + 2*8 = 40.
Assert.Equal(40, body.Length);
Assert.Equal(2u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
Assert.Equal(1,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(20)));
Assert.Equal(0x50002000u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(24)));
Assert.Equal(10,
BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(28)));
Assert.Equal(0x50002001u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(32)));
// Trailing currency still lands after every item pair.
Assert.Equal(0u,
BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(36)));
}
[Fact]
public void BuildBuy_SingleItemOverload_MatchesTheGeneralListOverload()
{
byte[] viaSingle = VendorRequests.BuildBuy(
gameActionSequence: 3,
vendorGuid: 0x40001000u,
amount: 5,
itemGuid: 0x50002000u,
alternateCurrencyId: 7u);
byte[] viaList = VendorRequests.BuildBuy(
gameActionSequence: 3,
vendorGuid: 0x40001000u,
items: new (int Amount, uint ItemGuid)[] { (5, 0x50002000u) },
alternateCurrencyId: 7u);
Assert.Equal(viaList, viaSingle);
}
}

View file

@ -0,0 +1,64 @@
using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Slice 6.3 — verifies <see cref="WorldSession.SendBuy"/> produces the same
/// wire bytes <see cref="VendorRequests.BuildBuy"/> does directly, using a
/// sequence number drawn from <see cref="WorldSession.NextGameActionSequence"/>.
/// Mirrors <c>WorldSessionChatTests</c>'s <c>GameActionCapture</c> test seam.
/// </summary>
public sealed class WorldSessionVendorTests
{
private static WorldSession NewSession()
{
var ep = new IPEndPoint(IPAddress.Loopback, 65001);
return new WorldSession(ep);
}
[Fact]
public void SendBuy_EmitsBytesIdenticalToVendorRequestsBuildBuy()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendBuy(
vendorGuid: 0x40001000u,
itemGuid: 0x50002000u,
amount: 3,
alternateCurrencyId: 0u);
byte[] expected = VendorRequests.BuildBuy(
gameActionSequence: 1,
vendorGuid: 0x40001000u,
amount: 3,
itemGuid: 0x50002000u,
alternateCurrencyId: 0u);
Assert.NotNull(captured);
Assert.Equal(expected, captured);
}
[Fact]
public void SendBuy_IncrementsTheSharedGameActionSequenceLikeEveryOtherSend()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendTalk("first"); // consumes sequence 1
session.SendBuy(0x40001000u, 0x50002000u, 1, 0u); // should be sequence 2
byte[] expected = VendorRequests.BuildBuy(
gameActionSequence: 2,
vendorGuid: 0x40001000u,
amount: 1,
itemGuid: 0x50002000u,
alternateCurrencyId: 0u);
Assert.Equal(expected, captured);
}
}

View file

@ -198,6 +198,56 @@ public sealed class RuntimeVendorLifecycleTests
Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId); Assert.Equal(0u, runtime.InventoryOwner.Vendor.VendorId);
} }
[Fact]
public void ApproachVendorEvent_MaterializesShopItemsIntoTheOwnedObjectTable()
{
// Slice 6.1: unlike Wire()'s throwaway ClientObjectTable (used by
// the tests above, which only assert on VendorState itself), this
// wires the dispatcher against the SAME table
// RuntimeInventoryState.Objects exposes, so the
// VendorShopItemMaterializer subscription RuntimeInventoryState's
// constructor installs is exercised end-to-end from the real wire
// parse through to ClientObjectTable.
using GameRuntime runtime = Create();
using IDisposable wiring = GameEventWiring.WireAll(
_dispatcher,
runtime.InventoryOwner.Objects,
new CombatState(),
new Spellbook(),
new ChatLog(),
vendor: runtime.InventoryOwner.Vendor);
Dispatch(BuildApproachVendorPayload(
vendorGuid: 0x40001000u,
categories: 0u, minValue: 0u, maxValue: 0u, dealsMagic: false,
buyPrice: 1f, sellPrice: 1f, currencyWcid: 0u, currencyAmount: 0u,
currencyName: "",
items:
[
new VendorItemFixture(
0x50002000u, 1, "Iron Sword", 42u, 0x1234u,
(uint)ItemType.Weapon, 250),
]));
ClientObject? shopItem = runtime.InventoryOwner.Objects.Get(0x50002000u);
Assert.NotNull(shopItem);
Assert.Equal(0x40001000u, shopItem!.ContainerId);
Assert.Equal("Iron Sword", shopItem.Name);
Assert.Equal(1, runtime.InventoryOwner.VendorItems.OwnedCount);
runtime.InventoryOwner.Vendor.Close();
Assert.Null(runtime.InventoryOwner.Objects.Get(0x50002000u));
Assert.Equal(0, runtime.InventoryOwner.VendorItems.OwnedCount);
// Close() is a client-local session end, not full disposal, so only
// the vendor-specific ownership dimensions are asserted here — the
// full IsConverged gate is exercised by DisposingInventoryOwner_
// ClearsTheOpenVendorSession below.
RuntimeInventoryOwnershipSnapshot snapshot = runtime.InventoryOwner.CaptureOwnership();
Assert.Equal(0u, snapshot.VendorId);
Assert.Equal(0, snapshot.MaterializedVendorItemCount);
}
[Fact] [Fact]
public void VendorId_IsTheLiveActiveVendorIdSeamSource() public void VendorId_IsTheLiveActiveVendorIdSeamSource()
{ {

View file

@ -0,0 +1,191 @@
using AcDream.Core.Items;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// Slice 6.1 — <see cref="VendorShopItemMaterializer"/> in isolation: the
/// diff/materialize/retire logic against a bare <see cref="VendorState"/> +
/// <see cref="ClientObjectTable"/> pair, independent of the wire parse
/// (<see cref="RuntimeVendorLifecycleTests"/> covers the end-to-end
/// ApproachVendor path).
/// </summary>
public sealed class VendorShopItemMaterializerTests
{
private const uint VendorGuid = 0x40001000u;
private const uint OtherVendorGuid = 0x40002000u;
private const uint ItemA = 0x50002000u;
private const uint ItemB = 0x50002001u;
private static VendorShopItem Item(uint guid, string name = "Item", int? descStackSize = null) =>
new(guid, StackSize: -1, WeenieClassId: 1u, Name: name, ItemType: (uint)ItemType.Misc,
IconId: 0x1234u, Value: 10, DescStackSize: descStackSize);
[Fact]
public void Apply_MaterializesEachShopItemWithVendorAsContainer()
{
var vendor = new VendorState();
var objects = new ClientObjectTable();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Iron Sword"), Item(ItemB, "Bread") });
ClientObject? a = objects.Get(ItemA);
ClientObject? b = objects.Get(ItemB);
Assert.NotNull(a);
Assert.NotNull(b);
Assert.Equal(VendorGuid, a!.ContainerId);
Assert.Equal(VendorGuid, b!.ContainerId);
Assert.Equal("Iron Sword", a.Name);
Assert.Equal(2, materializer.OwnedCount);
Assert.True(materializer.Owns(ItemA));
Assert.True(materializer.Owns(ItemB));
}
[Fact]
public void Close_RemovesEveryMaterializedItem()
{
var vendor = new VendorState();
var objects = new ClientObjectTable();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[] { Item(ItemA), Item(ItemB) });
vendor.Close();
Assert.Null(objects.Get(ItemA));
Assert.Null(objects.Get(ItemB));
Assert.Equal(0, materializer.OwnedCount);
}
[Fact]
public void Reset_RemovesEveryMaterializedItem()
{
var vendor = new VendorState();
var objects = new ClientObjectTable();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[] { Item(ItemA) });
vendor.Reset();
Assert.Null(objects.Get(ItemA));
Assert.Equal(0, materializer.OwnedCount);
}
[Fact]
public void DifferentVendorSupersedes_RemovesPriorVendorsItemsBeforeMaterializingTheNewOnes()
{
var vendor = new VendorState();
var objects = new ClientObjectTable();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "First Vendor Item") });
Assert.NotNull(objects.Get(ItemA));
const uint NewItem = 0x50003000u;
vendor.Apply(OtherVendorGuid, default, new[] { Item(NewItem, "Second Vendor Item") });
Assert.Null(objects.Get(ItemA));
ClientObject? replacement = objects.Get(NewItem);
Assert.NotNull(replacement);
Assert.Equal(OtherVendorGuid, replacement!.ContainerId);
Assert.Equal(1, materializer.OwnedCount);
}
[Fact]
public void Refreshed_SameVendor_DoesNotFireObjectRemovedForStillListedItems()
{
// A same-vendor re-approach (post-buy refresh) must not remove+
// re-add a guid that's still in stock -- see the class doc's "diff,
// not blanket remove-then-reinsert" rationale. A UI panel holding
// the guid (an open appraisal window) would see a false "it's gone"
// notice if this regressed to blanket removal.
var vendor = new VendorState();
var objects = new ClientObjectTable();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Chainmail") });
var removed = new List<uint>();
objects.ObjectRemoved += o => removed.Add(o.ObjectId);
// Same vendor id re-approaches with the SAME item guid still listed
// (e.g. a post-buy refresh where this item wasn't the one bought)
// but with a refreshed field value.
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Chainmail", descStackSize: 5) });
Assert.Empty(removed);
Assert.Equal(1, materializer.OwnedCount);
Assert.Equal(5, objects.Get(ItemA)!.StackSize);
}
[Fact]
public void Refreshed_ItemNoLongerListed_IsRemoved()
{
// A unique item sold out (bought up / delisted) between one
// ApproachVendor and the next same-vendor refresh.
var vendor = new VendorState();
var objects = new ClientObjectTable();
using var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[] { Item(ItemA), Item(ItemB) });
vendor.Apply(VendorGuid, default, new[] { Item(ItemB) });
Assert.Null(objects.Get(ItemA));
Assert.NotNull(objects.Get(ItemB));
Assert.Equal(1, materializer.OwnedCount);
}
[Fact]
public void CollidingGuid_AlreadyOwnedBySomethingElse_IsNeverClobbered()
{
// The Slice 6.1 collision policy: ACE's UniqueItemsForSale can list
// the EXACT guid a player last held (e.g. a sold-off item, or --
// worst case -- any other collision). If that guid is already in
// ClientObjectTable for a reason this materializer did not itself
// create, it must be left completely untouched, not silently
// reparented into the vendor's container.
var vendor = new VendorState();
var objects = new ClientObjectTable();
// Simulate a pre-existing, non-vendor-owned object at this guid --
// e.g. a live entity, or an item still sitting in someone's
// inventory/equipment.
const uint LiveOwner = 0x60000001u;
objects.AddOrUpdate(new ClientObject
{
ObjectId = ItemA,
Name = "Definitely Not A Shop Item",
ContainerId = LiveOwner,
});
using var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Shop Listing With A Colliding Guid") });
ClientObject? survivor = objects.Get(ItemA);
Assert.NotNull(survivor);
Assert.Equal("Definitely Not A Shop Item", survivor!.Name);
Assert.Equal(LiveOwner, survivor.ContainerId);
Assert.False(materializer.Owns(ItemA));
Assert.Equal(0, materializer.OwnedCount);
// The collision guid must also survive session close -- since this
// materializer never claimed it, it must never remove it either.
vendor.Close();
Assert.NotNull(objects.Get(ItemA));
}
[Fact]
public void Dispose_UnsubscribesFromVendorChanged()
{
var vendor = new VendorState();
var objects = new ClientObjectTable();
var materializer = new VendorShopItemMaterializer(vendor, objects);
vendor.Apply(VendorGuid, default, new[] { Item(ItemA) });
Assert.NotNull(objects.Get(ItemA));
materializer.Dispose();
// No further reaction once disposed -- a Close() after disposal
// must not throw and must not touch the table (nothing left
// subscribed to react).
vendor.Close();
Assert.NotNull(objects.Get(ItemA));
}
}