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); + } +}