feat(core): Slice 5.2 — VendorState + retail's exact vendor price math
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

VendorState sits beside ExternalContainerState (contract decision 1)
with the same shape: private setters, Changed event, Reset with
AggregateException fanout; domain-shaped like ContainerContentEntry
since Core cannot reference Core.Net. No Runtime wiring, no UI — 5.3's
job.

VendorPricing ports ShopSystem::BuyPrice/SellPrice (0x006B6120/
0x006B6180) faithfully: retail's literal three-way branch survives,
including the unreachable-with-real-data negative -1 sentinel that
ACE's Math.Max(1, ...) collapse erases — equivalence for legitimate
inputs is hand-proven and documented rather than silently assumed.
Seven conformance tests with hand-derived golden values (float32
semantics verified independently), covering rate=1.0, fractional
rates, value=0, the rounding-sensitive halfway case, stack
multipliers, the ItemType rate-override branch, and the sentinel.

Clean-room complete solution with 5.1+5.2 in place: 11,291 passed /
4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 15:05:47 +02:00
parent e45c95b06c
commit 70f37dbd5c
4 changed files with 589 additions and 0 deletions

View file

@ -0,0 +1,119 @@
using System;
namespace AcDream.Core.Items;
/// <summary>
/// Retail vendor price DISPLAY math — the pure "what number does the shop
/// list show" formula. Ported from the named-retail decompile,
/// <c>ShopSystem::BuyPrice</c> / <c>ShopSystem::SellPrice</c>
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:702082-702128</c>,
/// addresses <c>0x006B6120</c> / <c>0x006B6180</c> — read directly from the
/// decompiled body, not just the research doc's paraphrase), cross-checked
/// against ACE's server-authoritative
/// <c>Vendor.GetBuyCost</c>/<c>Vendor.GetSellCost</c>
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:573-599</c>).
///
/// <para>
/// <b>Naming inversion warning</b> (research doc §A.2, repeated here because
/// it is the single easiest mistake to make wiring this up): retail's "buy
/// price"/"buy rate" is the rate the VENDOR pays when IT buys FROM the
/// player — i.e. what the player receives when selling an item to the
/// vendor. "Sell price"/"sell rate" is the rate the vendor charges when IT
/// sells TO the player — i.e. what the player pays to buy an item from the
/// vendor. The names read backwards from English-first intuition:
/// <see cref="BuyPrice"/> is NOT "the price you pay to buy something," it is
/// "the price the vendor pays when it buys from you."
/// </para>
///
/// <para>
/// <b>Retail vs. ACE — a literal difference that does not change any real
/// output.</b> The retail decomp's <c>ShopSystem::BuyPrice</c>/<c>SellPrice</c>
/// use a three-way branch: an EXACT zero floor/ceil result returns 1
/// (a transaction can never be free), a POSITIVE result returns unchanged,
/// and a NEGATIVE result returns retail's <c>-1</c> sentinel (<c>0xFFFFFFFF</c>
/// cast to <c>int32_t</c>). ACE's C# port collapses this to a two-way
/// <c>Math.Max(1, ...)</c> clamp, which silently rounds any negative result
/// UP to 1 instead of returning -1. These two shapes are NOT literally
/// identical, but they are byte-identical for every value ACE's own server
/// (or any legitimate retail vendor) ever computes with: an item's
/// <c>Value</c> and a vendor's authored buy/sell rate are always
/// non-negative by game design, and <paramref name="quantity"/> is always
/// &gt;= 1, so <c>rate * value * quantity</c> can never be negative and the
/// retail decomp's negative branch is provably unreachable for any real
/// input — both formulas therefore always agree. This port keeps retail's
/// literal three-way branch (not ACE's simplified clamp) per this project's
/// "port faithfully, do not simplify" rule — see
/// <c>docs/architecture/retail-divergence-register.md</c>'s scope note and
/// CLAUDE.md's grep-named-first workflow.
/// </para>
/// </summary>
public static class VendorPricing
{
/// <summary>
/// <c>ShopSystem::BuyPrice</c> (<c>0x006B6120</c>): the price the vendor
/// PAYS the player for <paramref name="quantity"/> units of an item
/// worth <paramref name="perUnitValue"/> each, at the vendor's
/// <paramref name="buyRate"/>. PromissoryNote items always use rate 1.0
/// regardless of the vendor's authored rate (retail
/// <c>pc:702087-702090</c>: <c>if (arg2 != TYPE_PROMISSORY_NOTE) x87_r7
/// = arg3; else x87_r7 = 1f;</c>).
/// </summary>
/// <param name="perUnitValue">The item's per-unit <c>Value</c> (retail <c>arg1</c>).</param>
/// <param name="itemType">The item's <see cref="ItemType"/> bitmask (retail <c>arg2</c>).</param>
/// <param name="buyRate">The vendor's authored buy rate (retail <c>arg3</c> / <c>this-&gt;buy_price</c>).</param>
/// <param name="quantity">Stack count being priced (retail <c>arg4</c>).</param>
/// <returns>The buy price, clamped to a minimum of 1 for any non-negative result.</returns>
public static int BuyPrice(int perUnitValue, uint itemType, float buyRate, int quantity)
{
float rate = itemType == (uint)ItemType.PromissoryNote ? 1f : buyRate;
// pc:702092: `((rate * value) * quantity) + 0.1`, then floor(), then
// truncate to int32 (_ftol2 on an already-integral double is exact).
// Widened to double for the multiply — .NET has no 80-bit extended
// (x87 long double) type; double is the closest available and the
// 0.1 margin is many orders of magnitude larger than any float/
// double precision gap at realistic AC item-value magnitudes, so
// this never changes which integer floor()/ceil() lands on.
double raw = (double)rate * perUnitValue * quantity;
int floored = (int)Math.Floor(raw + 0.1);
// pc:702096-702102: exact zero -> 1; non-negative -> unchanged;
// negative -> retail's -1 sentinel (unreachable for real data — see
// the type doc comment).
if (floored == 0) return 1;
if (floored >= 0) return floored;
return -1;
}
/// <summary>
/// <c>ShopSystem::SellPrice</c> (<c>0x006B6180</c>): the price the
/// player PAYS the vendor for <paramref name="quantity"/> units of an
/// item worth <paramref name="perUnitValue"/> each, at the vendor's
/// <paramref name="sellRate"/>. PromissoryNote items always use rate
/// 1.15 regardless of the vendor's authored rate (retail
/// <c>pc:702112-702115</c>: <c>if (arg2 != TYPE_PROMISSORY_NOTE) x87_r7
/// = arg3; else x87_r7 = 1.14999998f;</c> — the literal retail constant
/// is the float32 nearest-representable value to 1.15).
/// </summary>
/// <param name="perUnitValue">The item's per-unit <c>Value</c> (retail <c>arg1</c>).</param>
/// <param name="itemType">The item's <see cref="ItemType"/> bitmask (retail <c>arg2</c>).</param>
/// <param name="sellRate">The vendor's authored sell rate (retail <c>arg3</c> / <c>this-&gt;sell_price</c>).</param>
/// <param name="quantity">Stack count being priced (retail <c>arg4</c>).</param>
/// <returns>The sell price, clamped to a minimum of 1 for any non-negative result.</returns>
public static int SellPrice(int perUnitValue, uint itemType, float sellRate, int quantity)
{
float rate = itemType == (uint)ItemType.PromissoryNote ? 1.15f : sellRate;
// pc:702117: `((rate * value) * quantity) - 0.1`, then ceil(), then
// truncate to int32. See BuyPrice's comment for the double-widening
// rationale.
double raw = (double)rate * perUnitValue * quantity;
int ceiled = (int)Math.Ceiling(raw - 0.1);
// pc:702121-702127: exact zero -> 1; positive -> unchanged;
// non-positive-but-nonzero (i.e. negative) -> retail's -1 sentinel.
if (ceiled == 0) return 1;
if (ceiled > 0) return ceiled;
return -1;
}
}

View file

@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Items;
/// <summary>
/// Domain-shaped projection of the wire <c>ApproachVendor</c> GameEvent's
/// fixed profile prefix (buy/sell rates, currency, categories). The
/// wire-shaped equivalent (<c>VendorApproach.VendorProfile</c>) lives in
/// <c>AcDream.Core.Net.Messages</c>, which <c>AcDream.Core</c> cannot
/// reference (dependency runs <c>AcDream.Core.Net</c> -&gt; <c>AcDream.Core</c>,
/// never the other way). This mirrors how <c>ContainerContentEntry</c>
/// (<see cref="ClientObjectTable"/>) is the domain projection of the wire
/// <c>ViewContentsEntry</c>/<c>CreateObject</c> shapes — the Slice 5.3
/// wiring glue (<c>GameEventWiring.cs</c>, which CAN see both layers) does
/// the field-by-field conversion, the same way it already does for
/// <c>ViewContents</c> today.
/// </summary>
public readonly record struct VendorShopProfile(
uint MerchandiseItemTypes,
uint MerchandiseMinValue,
uint MerchandiseMaxValue,
bool DealMagicalItems,
float BuyPrice,
float SellPrice,
uint AlternateCurrencyWcid,
uint AlternateCurrencyAmount,
string AlternateCurrencyPluralName);
/// <summary>
/// Domain-shaped projection of one <c>ApproachVendor</c> shop-list entry —
/// only the fields Slice 5's browse scope needs (display + price math).
/// The full <c>PublicWeenieDesc</c> the wire carries has ~40 optional
/// fields; the rest are Slice 6+ concerns (or already live on the
/// <see cref="ClientObjectTable"/> record once Slice 5.3 registers each
/// shop item there per the research doc's §A.2 point 4 recommendation).
/// </summary>
public readonly record struct VendorShopItem(
uint ItemGuid,
// -1 = unlimited supply (retail ItemProfile's sign-extended packed
// stack-size field).
int StackSize,
uint WeenieClassId,
string? Name,
uint? ItemType,
uint IconId,
int? Value);
public enum VendorStateTransitionKind
{
/// <summary>A different vendor than whatever was previously open (or nothing) is now open.</summary>
Opened,
/// <summary>The SAME vendor id sent a fresh ApproachVendor (post-buy/sell refresh — Slice 6).</summary>
Refreshed,
/// <summary>The shop was closed (client-local distance/switch trigger — retail A.3).</summary>
Closed,
/// <summary>Session teardown (portal/reconnect/logout).</summary>
Reset,
}
public readonly record struct VendorTransition(
VendorStateTransitionKind Kind,
uint PreviousVendorId,
uint VendorId);
/// <summary>
/// Owns the currently-open vendor shop snapshot: the vendor's guid, its
/// shop terms, and its item-for-sale list. Structural sibling of
/// <see cref="ExternalContainerState"/> (Slice 5 contract decision 1) — same
/// "authoritative server-driven full-replace view... with a
/// <c>Changed</c> event for presentation observers" shape, widened to also
/// carry the profile + item list <c>ExternalContainerState</c> doesn't need
/// (a container has no rates/currency/categories of its own).
///
/// <para>
/// <b>No request/current id gating.</b> Unlike <see cref="ExternalContainerState"/>
/// (which tracks a <c>RequestedContainerId</c> separate from
/// <c>CurrentContainerId</c> to survive ACE sending ViewContents for nested
/// containers out of order), Slice 5 has no request-correlation token to
/// gate against (contract decision 4 — retail's <c>attemptOpenVendorID</c>
/// mode-2-vs-3 tab selection is deferred to Slice 6's sell-drag UI). Every
/// <c>ApproachVendor</c> is unconditionally authoritative (research doc
/// §A.3: "each ApproachVendor is a COMPLETE replace"), so <see cref="Apply"/>
/// is a single-phase call, not a request/apply pair.
/// </para>
///
/// <para>
/// <see cref="VendorTransition.Kind"/> distinguishes a brand-new vendor
/// (<see cref="VendorStateTransitionKind.Opened"/>) from a same-vendor
/// refresh (<see cref="VendorStateTransitionKind.Refreshed"/>, which will
/// only occur once Slice 6's buy/sell actions trigger a repeat
/// <c>ApproachVendor</c>) so a future UI layer (Slice 5.4) can decide
/// whether to reset its own sub-widgets — mirroring retail's
/// <c>gmVendorUI::OpenVendor</c>, which skips sub-UI teardown on a
/// same-vendor reopen (research doc §A.3/§B.1 point 1) but this class does
/// not itself perform any UI orchestration.
/// </para>
/// </summary>
public sealed class VendorState
{
public uint VendorId { get; private set; }
public VendorShopProfile Profile { get; private set; }
public IReadOnlyList<VendorShopItem> Items { get; private set; } = Array.Empty<VendorShopItem>();
public event Action<VendorTransition>? Changed;
/// <summary>
/// Apply a full ApproachVendor snapshot. Returns <c>false</c> (no-op,
/// no event) for the sentinel guid 0 — matching
/// <see cref="ExternalContainerState.RequestOpen"/>'s treatment of a
/// zero id as "not a real target."
/// </summary>
public bool Apply(uint vendorGuid, VendorShopProfile profile, IReadOnlyList<VendorShopItem> items)
{
ArgumentNullException.ThrowIfNull(items);
if (vendorGuid == 0u) return false;
uint previous = VendorId;
bool sameVendor = previous != 0u && previous == vendorGuid;
VendorId = vendorGuid;
Profile = profile;
Items = items;
Changed?.Invoke(new VendorTransition(
sameVendor ? VendorStateTransitionKind.Refreshed : VendorStateTransitionKind.Opened,
previous,
vendorGuid));
return true;
}
/// <summary>
/// Clear the open shop (client-local close — distance watcher or a
/// different-vendor open superseding this one; see research doc §A.3).
/// Returns <c>false</c> if no vendor was open.
/// </summary>
public bool Close()
{
if (VendorId == 0u) return false;
uint previous = VendorId;
ClearFields();
Changed?.Invoke(new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u));
return true;
}
/// <summary>
/// Session-lifecycle teardown (portal-out/reconnect/logout). Fans the
/// transition out to every <see cref="Changed"/> listener even if one
/// throws, matching <see cref="ExternalContainerState.Reset"/>'s
/// AggregateException-collecting shape so one broken observer cannot
/// prevent the others from converging.
/// </summary>
public bool Reset()
{
uint previous = VendorId;
bool changed = previous != 0u;
ClearFields();
var transition = new VendorTransition(VendorStateTransitionKind.Reset, previous, 0u);
Action<VendorTransition>? listeners = Changed;
if (listeners is not null)
{
List<Exception>? failures = null;
foreach (Action<VendorTransition> listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error) { (failures ??= []).Add(error); }
}
if (failures is not null)
throw new AggregateException(
"One or more vendor-state reset observers failed.",
failures);
}
return changed;
}
private void ClearFields()
{
VendorId = 0u;
Profile = default;
Items = Array.Empty<VendorShopItem>();
}
}

View file

@ -0,0 +1,130 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
/// <summary>
/// Conformance tests for <see cref="VendorPricing"/>. Golden values are
/// hand-traced from ACE's <c>Vendor.GetBuyCost</c>/<c>GetSellCost</c>
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:577-599</c>):
/// <code>
/// GetBuyCost: Math.Max(1, (int)Math.Floor(((float)buyRate * value) + 0.1))
/// GetSellCost: Math.Max(1, (uint)Math.Ceiling(((float)sellRate * value) - 0.1))
/// </code>
/// widened here to also thread the <c>quantity</c> multiplier ACE's own
/// call sites don't need (its <c>GetBuyCost(WorldObject)</c> overload always
/// prices exactly one item; retail's <c>ShopSystem::BuyPrice</c>/<c>SellPrice</c>
/// — <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:702082-702128</c>
/// — carries the explicit <c>arg4</c> stack-count parameter this port
/// preserves). Every golden value below was computed by hand from that same
/// <c>rate * value * quantity</c>, floor/ceil-with-0.1-fudge formula; see
/// each test's comment for the arithmetic. See <see cref="VendorPricing"/>'s
/// doc comment for why retail's literal three-way branch (kept here) and
/// ACE's two-way <c>Math.Max</c> clamp agree for every one of these cases.
/// </summary>
public sealed class VendorPricingTests
{
// ---- 1. rate = 1.0 (baseline, whole numbers) --------------------------
// raw = 1.0 * 100 * 1 = 100.0
// BuyPrice: floor(100.0 + 0.1) = floor(100.1) = 100
// SellPrice: ceil(100.0 - 0.1) = ceil(99.9) = 100
[Fact]
public void RateOne_WholeNumberValue_PassesThroughUnchanged()
{
Assert.Equal(100, VendorPricing.BuyPrice(100, (uint)ItemType.Misc, 1.0f, 1));
Assert.Equal(100, VendorPricing.SellPrice(100, (uint)ItemType.Misc, 1.0f, 1));
}
// ---- 2. Fractional rate + stack quantity > 1 --------------------------
// raw = 0.75 * 37 * 2 = 55.5 (exact in float32/double — 0.75 = 3/4)
// BuyPrice: floor(55.5 + 0.1) = floor(55.6) = 55
// SellPrice: ceil(55.5 - 0.1) = ceil(55.4) = 56
// (This is ALSO the "rounding-sensitive" halfway case for this
// particular rate/value/quantity combination — floor-with-fudge and
// ceil-with-fudge deterministically resolve the same 55.5 raw value to
// two DIFFERENT integers depending on direction, which a naive
// Math.Round(55.5) could not do consistently.)
[Fact]
public void FractionalRate_WithStackQuantity_RoundsPerDirection()
{
Assert.Equal(55, VendorPricing.BuyPrice(37, (uint)ItemType.Misc, 0.75f, 2));
Assert.Equal(56, VendorPricing.SellPrice(37, (uint)ItemType.Misc, 0.75f, 2));
}
// ---- 3. value = 0 ------------------------------------------------------
// raw = 1.0 * 0 * 1 = 0.0
// BuyPrice: floor(0.0 + 0.1) = floor(0.1) = 0 -> exact-zero guard -> 1
// SellPrice: ceil(0.0 - 0.1) = ceil(-0.1) = 0 -> exact-zero guard -> 1
// Demonstrates the "a transaction can never be free" floor in BOTH
// directions, including SellPrice's ceil(-0.1) landing on 0 (not -1)
// because Math.Ceiling rounds toward positive infinity.
[Fact]
public void ZeroValue_ClampsToMinimumOne()
{
Assert.Equal(1, VendorPricing.BuyPrice(0, (uint)ItemType.Misc, 1.0f, 1));
Assert.Equal(1, VendorPricing.SellPrice(0, (uint)ItemType.Misc, 1.0f, 1));
}
// ---- 4. Rounding-sensitive halfway case --------------------------------
// raw = 0.5 * 41 * 1 = 20.5 (exact — 0.5 = 1/2)
// BuyPrice: floor(20.5 + 0.1) = floor(20.6) = 20
// SellPrice: ceil(20.5 - 0.1) = ceil(20.4) = 21
// A naive round-to-nearest of 20.5 is ambiguous (round-half-to-even
// gives 20, round-half-away-from-zero gives 21) and — critically — would
// give the SAME answer for both buy and sell. Retail's formula is
// deterministic AND asymmetric: BuyPrice always rounds DOWN (in the
// vendor's favor, since it's what the vendor pays out) and SellPrice
// always rounds UP (also in the vendor's favor, since it's what the
// vendor charges) at an exact halfway point.
[Fact]
public void HalfwayRawValue_BuyRoundsDownSellRoundsUp()
{
Assert.Equal(20, VendorPricing.BuyPrice(41, (uint)ItemType.Misc, 0.5f, 1));
Assert.Equal(21, VendorPricing.SellPrice(41, (uint)ItemType.Misc, 0.5f, 1));
}
// ---- 5. Larger value + stack multiplier --------------------------------
// raw = 2.5 * 1000 * 5 = 12500.0
// BuyPrice: floor(12500.0 + 0.1) = floor(12500.1) = 12500
// SellPrice: ceil(12500.0 - 0.1) = ceil(12499.9) = 12500
// Exercises the quantity multiplier at a magnitude where a
// single-precision-only intermediate could plausibly drift; 2.5, 1000,
// and 5 are all exactly representable in float32, so this proves the
// multiply chain is exact at this scale, not merely "close enough".
[Fact]
public void LargeValueWithStackMultiplier_ComputesExactly()
{
Assert.Equal(12500, VendorPricing.BuyPrice(1000, (uint)ItemType.Misc, 2.5f, 5));
Assert.Equal(12500, VendorPricing.SellPrice(1000, (uint)ItemType.Misc, 2.5f, 5));
}
// ---- 6. PromissoryNote item-type rate override -------------------------
// itemType == PromissoryNote overrides the PASSED-IN rate entirely:
// BuyPrice uses a hardcoded 1.0, SellPrice uses a hardcoded 1.15
// (retail pc:702087-702090 / :702112-702115), regardless of what the
// vendor's own buy_price/sell_price fields say. rate=3.0 is deliberately
// supplied below to prove it gets ignored.
// BuyPrice: raw = 1.0 * 100 * 1 = 100.0; floor(100.1) = 100
// SellPrice: raw = 1.15 * 100 * 1 = 115.0; ceil(114.9) = 115
[Fact]
public void PromissoryNote_IgnoresSuppliedRate_UsesHardcodedOverride()
{
const uint promissoryNote = (uint)ItemType.PromissoryNote;
Assert.Equal(100, VendorPricing.BuyPrice(100, promissoryNote, buyRate: 3.0f, quantity: 1));
Assert.Equal(115, VendorPricing.SellPrice(100, promissoryNote, sellRate: 3.0f, quantity: 1));
}
// ---- Bonus: retail's literal negative-result sentinel ------------------
// Not reachable with any legitimate item (Value and vendor rates are
// always non-negative by game design — see the type doc comment), but
// included to prove the literal three-way retail branch survived the
// port rather than silently collapsing to ACE's Math.Max(1, ...) clamp.
// raw = 1.0 * -50 * 1 = -50.0
// BuyPrice: floor(-50.0 + 0.1) = floor(-49.9) = -50 -> negative -> -1
// SellPrice: ceil(-50.0 - 0.1) = ceil(-50.1) = -50 -> negative -> -1
[Fact]
public void SyntheticNegativeValue_ReturnsRetailSentinelNotClampedToOne()
{
Assert.Equal(-1, VendorPricing.BuyPrice(-50, (uint)ItemType.Misc, 1.0f, 1));
Assert.Equal(-1, VendorPricing.SellPrice(-50, (uint)ItemType.Misc, 1.0f, 1));
}
}

View file

@ -0,0 +1,155 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class VendorStateTests
{
[Fact]
public void Apply_ZeroGuid_IsANoOp()
{
var state = new VendorState();
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
Assert.False(state.Apply(0u, default, Array.Empty<VendorShopItem>()));
Assert.Equal(0u, state.VendorId);
Assert.Empty(changes);
}
[Fact]
public void Apply_NewVendor_PublishesOpenedAndStoresSnapshot()
{
var state = new VendorState();
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
var profile = new VendorShopProfile(
MerchandiseItemTypes: (uint)ItemType.MeleeWeapon,
MerchandiseMinValue: 1,
MerchandiseMaxValue: 5000,
DealMagicalItems: true,
BuyPrice: 0.5f,
SellPrice: 1.5f,
AlternateCurrencyWcid: 0,
AlternateCurrencyAmount: 0,
AlternateCurrencyPluralName: string.Empty);
var items = new[]
{
new VendorShopItem(0x50000A01u, 3, 42u, "Iron Dagger", (uint)ItemType.MeleeWeapon, 0x06001234u, 25),
};
Assert.True(state.Apply(0x40000001u, profile, items));
Assert.Equal(0x40000001u, state.VendorId);
Assert.Equal(profile, state.Profile);
Assert.Same(items, state.Items);
var change = Assert.Single(changes);
Assert.Equal(VendorStateTransitionKind.Opened, change.Kind);
Assert.Equal(0u, change.PreviousVendorId);
Assert.Equal(0x40000001u, change.VendorId);
}
[Fact]
public void Apply_SameVendorAgain_PublishesRefreshedNotOpened()
{
var state = new VendorState();
state.Apply(0x40000002u, default, Array.Empty<VendorShopItem>());
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
// Slice 6 territory (a post-buy/sell ApproachVendor refresh) — but
// the state owner's job of distinguishing "same shop" from "new
// shop" belongs here regardless of what triggers the repeat call.
Assert.True(state.Apply(0x40000002u, default, Array.Empty<VendorShopItem>()));
var change = Assert.Single(changes);
Assert.Equal(VendorStateTransitionKind.Refreshed, change.Kind);
Assert.Equal(0x40000002u, change.PreviousVendorId);
Assert.Equal(0x40000002u, change.VendorId);
}
[Fact]
public void Apply_DifferentVendor_PublishesOpenedWithPreviousId()
{
var state = new VendorState();
state.Apply(0x40000003u, default, Array.Empty<VendorShopItem>());
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
Assert.True(state.Apply(0x40000004u, default, Array.Empty<VendorShopItem>()));
var change = Assert.Single(changes);
Assert.Equal(VendorStateTransitionKind.Opened, change.Kind);
Assert.Equal(0x40000003u, change.PreviousVendorId);
Assert.Equal(0x40000004u, change.VendorId);
Assert.Equal(0x40000004u, state.VendorId);
}
[Fact]
public void Close_WithNothingOpen_IsANoOp()
{
var state = new VendorState();
Assert.False(state.Close());
}
[Fact]
public void Close_ClearsSnapshotAndPublishesClosed()
{
var state = new VendorState();
state.Apply(0x40000005u, default, new[]
{
new VendorShopItem(0x50000A02u, 1, 7u, "Rock", (uint)ItemType.Misc, 0u, 1),
});
var changes = new List<VendorTransition>();
state.Changed += changes.Add;
Assert.True(state.Close());
Assert.Equal(0u, state.VendorId);
Assert.Equal(default(VendorShopProfile), state.Profile);
Assert.Empty(state.Items);
var change = Assert.Single(changes);
Assert.Equal(VendorStateTransitionKind.Closed, change.Kind);
Assert.Equal(0x40000005u, change.PreviousVendorId);
Assert.Equal(0u, change.VendorId);
}
[Fact]
public void Reset_RetryRepublishesAndOneObserverCannotStarveAnother()
{
var state = new VendorState();
state.Apply(0x40000006u, default, Array.Empty<VendorShopItem>());
bool fail = true;
int delivered = 0;
state.Changed += _ =>
{
if (fail)
{
fail = false;
throw new InvalidOperationException("transient");
}
};
state.Changed += transition =>
{
Assert.Equal(VendorStateTransitionKind.Reset, transition.Kind);
delivered++;
};
Assert.Throws<AggregateException>(() => state.Reset());
Assert.Equal(1, delivered);
Assert.Equal(0u, state.VendorId);
// Second reset: nothing left to clear, but observers still run
// (mirrors ExternalContainerState.Reset — the retry is what proves
// one failing observer above didn't wedge state.VendorId).
Assert.False(state.Reset());
Assert.Equal(2, delivered);
}
}