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)); } // ---- PerUnitValue (Slice 5.3 review fix 2) ----------------------------- // VendorProfile::VendorSellPrice/VendorBuyPrice (0x005D1B00/0x005D1B70, // pc:484801-484813): stackSize <= 0 ? value : value / stackSize // (INTEGER division of the wire's stack-TOTAL value by the item's own // authored PublicWeenieDesc.StackSize). // ---- 7. Stack of 50 arrows: the motivating case ------------------------ // Wire Value=500 is the price for the WHOLE stack of 50 arrows; // per-unit must equal the single-arrow price of 10. [Fact] public void StackOf50Arrows_DividesToThePerArrowValue() { Assert.Equal(10, VendorPricing.PerUnitValue(500, descStackSize: 50)); } // ---- 8. descStackSize <= 0 guard ---------------------------------------- // Zero and negative both take retail's "no division" branch — the // stack-total value passes through unchanged. [Fact] public void DescStackSizeZeroOrNegative_ReturnsValueUnchanged() { Assert.Equal(250, VendorPricing.PerUnitValue(250, descStackSize: 0)); Assert.Equal(250, VendorPricing.PerUnitValue(250, descStackSize: -1)); } // ---- 9. descStackSize absent (null) -------------------------------------- // A non-stackable item's wire PWD never carries a StackSize field at // all; null must be treated exactly like retail's zeroed struct // default (0) -- no division, value unchanged. [Fact] public void DescStackSizeAbsent_ReturnsValueUnchanged() { Assert.Equal(250, VendorPricing.PerUnitValue(250, descStackSize: null)); } // ---- 10. Non-exact division truncates toward zero ----------------------- // 100 / 3 = 33.33... -> retail's plain integer divide truncates to 33, // same as .NET's int division. [Fact] public void NonExactDivision_TruncatesTowardZero() { Assert.Equal(33, VendorPricing.PerUnitValue(100, descStackSize: 3)); } }