acdream/tests/AcDream.Core.Tests/Items/VendorPricingTests.cs
Erik 70f37dbd5c
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
feat(core): Slice 5.2 — VendorState + retail's exact vendor price math
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>
2026-08-07 15:05:47 +02:00

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