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
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:
parent
c884a938e0
commit
97cf873870
19 changed files with 1577 additions and 65 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 <= 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 -> 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
110
src/AcDream.Core.Net/Messages/VendorRequests.cs
Normal file
110
src/AcDream.Core.Net/Messages/VendorRequests.cs
Normal 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, &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);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
45
src/AcDream.Core/Items/VendorSplitPolicy.cs
Normal file
45
src/AcDream.Core/Items/VendorSplitPolicy.cs
Normal 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 -> 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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
202
src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs
Normal file
202
src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs
Normal file
127
tests/AcDream.Core.Net.Tests/Messages/VendorRequestsTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs
Normal file
64
tests/AcDream.Core.Net.Tests/WorldSessionVendorTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue