diff --git a/src/AcDream.Core/Items/VendorPricing.cs b/src/AcDream.Core/Items/VendorPricing.cs
new file mode 100644
index 00000000..e880416e
--- /dev/null
+++ b/src/AcDream.Core/Items/VendorPricing.cs
@@ -0,0 +1,119 @@
+using System;
+
+namespace AcDream.Core.Items;
+
+///
+/// Retail vendor price DISPLAY math — the pure "what number does the shop
+/// list show" formula. Ported from the named-retail decompile,
+/// ShopSystem::BuyPrice / ShopSystem::SellPrice
+/// (docs/research/named-retail/acclient_2013_pseudo_c.txt:702082-702128,
+/// addresses 0x006B6120 / 0x006B6180 — read directly from the
+/// decompiled body, not just the research doc's paraphrase), cross-checked
+/// against ACE's server-authoritative
+/// Vendor.GetBuyCost/Vendor.GetSellCost
+/// (references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:573-599).
+///
+///
+/// Naming inversion warning (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:
+/// is NOT "the price you pay to buy something," it is
+/// "the price the vendor pays when it buys from you."
+///
+///
+///
+/// Retail vs. ACE — a literal difference that does not change any real
+/// output. The retail decomp's ShopSystem::BuyPrice/SellPrice
+/// 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 -1 sentinel (0xFFFFFFFF
+/// cast to int32_t). ACE's C# port collapses this to a two-way
+/// Math.Max(1, ...) 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
+/// Value and a vendor's authored buy/sell rate are always
+/// non-negative by game design, and is always
+/// >= 1, so rate * value * quantity 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
+/// docs/architecture/retail-divergence-register.md's scope note and
+/// CLAUDE.md's grep-named-first workflow.
+///
+///
+public static class VendorPricing
+{
+ ///
+ /// ShopSystem::BuyPrice (0x006B6120): the price the vendor
+ /// PAYS the player for units of an item
+ /// worth each, at the vendor's
+ /// . PromissoryNote items always use rate 1.0
+ /// regardless of the vendor's authored rate (retail
+ /// pc:702087-702090: if (arg2 != TYPE_PROMISSORY_NOTE) x87_r7
+ /// = arg3; else x87_r7 = 1f;).
+ ///
+ /// The item's per-unit Value (retail arg1).
+ /// The item's bitmask (retail arg2).
+ /// The vendor's authored buy rate (retail arg3 / this->buy_price).
+ /// Stack count being priced (retail arg4).
+ /// The buy price, clamped to a minimum of 1 for any non-negative result.
+ 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;
+ }
+
+ ///
+ /// ShopSystem::SellPrice (0x006B6180): the price the
+ /// player PAYS the vendor for units of an
+ /// item worth each, at the vendor's
+ /// . PromissoryNote items always use rate
+ /// 1.15 regardless of the vendor's authored rate (retail
+ /// pc:702112-702115: if (arg2 != TYPE_PROMISSORY_NOTE) x87_r7
+ /// = arg3; else x87_r7 = 1.14999998f; — the literal retail constant
+ /// is the float32 nearest-representable value to 1.15).
+ ///
+ /// The item's per-unit Value (retail arg1).
+ /// The item's bitmask (retail arg2).
+ /// The vendor's authored sell rate (retail arg3 / this->sell_price).
+ /// Stack count being priced (retail arg4).
+ /// The sell price, clamped to a minimum of 1 for any non-negative result.
+ 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;
+ }
+}
diff --git a/src/AcDream.Core/Items/VendorState.cs b/src/AcDream.Core/Items/VendorState.cs
new file mode 100644
index 00000000..e0a1cd3e
--- /dev/null
+++ b/src/AcDream.Core/Items/VendorState.cs
@@ -0,0 +1,185 @@
+using System;
+using System.Collections.Generic;
+
+namespace AcDream.Core.Items;
+
+///
+/// Domain-shaped projection of the wire ApproachVendor GameEvent's
+/// fixed profile prefix (buy/sell rates, currency, categories). The
+/// wire-shaped equivalent (VendorApproach.VendorProfile) lives in
+/// AcDream.Core.Net.Messages, which AcDream.Core cannot
+/// reference (dependency runs AcDream.Core.Net -> AcDream.Core,
+/// never the other way). This mirrors how ContainerContentEntry
+/// () is the domain projection of the wire
+/// ViewContentsEntry/CreateObject shapes — the Slice 5.3
+/// wiring glue (GameEventWiring.cs, which CAN see both layers) does
+/// the field-by-field conversion, the same way it already does for
+/// ViewContents today.
+///
+public readonly record struct VendorShopProfile(
+ uint MerchandiseItemTypes,
+ uint MerchandiseMinValue,
+ uint MerchandiseMaxValue,
+ bool DealMagicalItems,
+ float BuyPrice,
+ float SellPrice,
+ uint AlternateCurrencyWcid,
+ uint AlternateCurrencyAmount,
+ string AlternateCurrencyPluralName);
+
+///
+/// Domain-shaped projection of one ApproachVendor shop-list entry —
+/// only the fields Slice 5's browse scope needs (display + price math).
+/// The full PublicWeenieDesc the wire carries has ~40 optional
+/// fields; the rest are Slice 6+ concerns (or already live on the
+/// record once Slice 5.3 registers each
+/// shop item there per the research doc's §A.2 point 4 recommendation).
+///
+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
+{
+ /// A different vendor than whatever was previously open (or nothing) is now open.
+ Opened,
+ /// The SAME vendor id sent a fresh ApproachVendor (post-buy/sell refresh — Slice 6).
+ Refreshed,
+ /// The shop was closed (client-local distance/switch trigger — retail A.3).
+ Closed,
+ /// Session teardown (portal/reconnect/logout).
+ Reset,
+}
+
+public readonly record struct VendorTransition(
+ VendorStateTransitionKind Kind,
+ uint PreviousVendorId,
+ uint VendorId);
+
+///
+/// Owns the currently-open vendor shop snapshot: the vendor's guid, its
+/// shop terms, and its item-for-sale list. Structural sibling of
+/// (Slice 5 contract decision 1) — same
+/// "authoritative server-driven full-replace view... with a
+/// Changed event for presentation observers" shape, widened to also
+/// carry the profile + item list ExternalContainerState doesn't need
+/// (a container has no rates/currency/categories of its own).
+///
+///
+/// No request/current id gating. Unlike
+/// (which tracks a RequestedContainerId separate from
+/// CurrentContainerId 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 attemptOpenVendorID
+/// mode-2-vs-3 tab selection is deferred to Slice 6's sell-drag UI). Every
+/// ApproachVendor is unconditionally authoritative (research doc
+/// §A.3: "each ApproachVendor is a COMPLETE replace"), so
+/// is a single-phase call, not a request/apply pair.
+///
+///
+///
+/// distinguishes a brand-new vendor
+/// () from a same-vendor
+/// refresh (, which will
+/// only occur once Slice 6's buy/sell actions trigger a repeat
+/// ApproachVendor) so a future UI layer (Slice 5.4) can decide
+/// whether to reset its own sub-widgets — mirroring retail's
+/// gmVendorUI::OpenVendor, 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.
+///
+///
+public sealed class VendorState
+{
+ public uint VendorId { get; private set; }
+ public VendorShopProfile Profile { get; private set; }
+ public IReadOnlyList Items { get; private set; } = Array.Empty();
+
+ public event Action? Changed;
+
+ ///
+ /// Apply a full ApproachVendor snapshot. Returns false (no-op,
+ /// no event) for the sentinel guid 0 — matching
+ /// 's treatment of a
+ /// zero id as "not a real target."
+ ///
+ public bool Apply(uint vendorGuid, VendorShopProfile profile, IReadOnlyList 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;
+ }
+
+ ///
+ /// Clear the open shop (client-local close — distance watcher or a
+ /// different-vendor open superseding this one; see research doc §A.3).
+ /// Returns false if no vendor was open.
+ ///
+ public bool Close()
+ {
+ if (VendorId == 0u) return false;
+
+ uint previous = VendorId;
+ ClearFields();
+
+ Changed?.Invoke(new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u));
+ return true;
+ }
+
+ ///
+ /// Session-lifecycle teardown (portal-out/reconnect/logout). Fans the
+ /// transition out to every listener even if one
+ /// throws, matching 's
+ /// AggregateException-collecting shape so one broken observer cannot
+ /// prevent the others from converging.
+ ///
+ public bool Reset()
+ {
+ uint previous = VendorId;
+ bool changed = previous != 0u;
+ ClearFields();
+
+ var transition = new VendorTransition(VendorStateTransitionKind.Reset, previous, 0u);
+ Action? listeners = Changed;
+ if (listeners is not null)
+ {
+ List? failures = null;
+ foreach (Action 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();
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Items/VendorPricingTests.cs b/tests/AcDream.Core.Tests/Items/VendorPricingTests.cs
new file mode 100644
index 00000000..cd452fba
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Items/VendorPricingTests.cs
@@ -0,0 +1,130 @@
+using AcDream.Core.Items;
+
+namespace AcDream.Core.Tests.Items;
+
+///
+/// Conformance tests for . Golden values are
+/// hand-traced from ACE's Vendor.GetBuyCost/GetSellCost
+/// (references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:577-599):
+///
+/// GetBuyCost: Math.Max(1, (int)Math.Floor(((float)buyRate * value) + 0.1))
+/// GetSellCost: Math.Max(1, (uint)Math.Ceiling(((float)sellRate * value) - 0.1))
+///
+/// widened here to also thread the quantity multiplier ACE's own
+/// call sites don't need (its GetBuyCost(WorldObject) overload always
+/// prices exactly one item; retail's ShopSystem::BuyPrice/SellPrice
+/// — docs/research/named-retail/acclient_2013_pseudo_c.txt:702082-702128
+/// — carries the explicit arg4 stack-count parameter this port
+/// preserves). Every golden value below was computed by hand from that same
+/// rate * value * quantity, floor/ceil-with-0.1-fudge formula; see
+/// each test's comment for the arithmetic. See 's
+/// doc comment for why retail's literal three-way branch (kept here) and
+/// ACE's two-way Math.Max clamp agree for every one of these cases.
+///
+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));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Items/VendorStateTests.cs b/tests/AcDream.Core.Tests/Items/VendorStateTests.cs
new file mode 100644
index 00000000..3705662e
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Items/VendorStateTests.cs
@@ -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();
+ state.Changed += changes.Add;
+
+ Assert.False(state.Apply(0u, default, Array.Empty()));
+
+ Assert.Equal(0u, state.VendorId);
+ Assert.Empty(changes);
+ }
+
+ [Fact]
+ public void Apply_NewVendor_PublishesOpenedAndStoresSnapshot()
+ {
+ var state = new VendorState();
+ var changes = new List();
+ 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());
+
+ var changes = new List();
+ 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()));
+
+ 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());
+
+ var changes = new List();
+ state.Changed += changes.Add;
+
+ Assert.True(state.Apply(0x40000004u, default, Array.Empty()));
+
+ 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();
+ 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());
+
+ 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(() => 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);
+ }
+}