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>
130 lines
6.8 KiB
C#
130 lines
6.8 KiB
C#
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));
|
|
}
|
|
}
|