feat(core): Slice 5.2 — VendorState + retail's exact vendor price math
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
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>
This commit is contained in:
parent
e45c95b06c
commit
70f37dbd5c
4 changed files with 589 additions and 0 deletions
130
tests/AcDream.Core.Tests/Items/VendorPricingTests.cs
Normal file
130
tests/AcDream.Core.Tests/Items/VendorPricingTests.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
155
tests/AcDream.Core.Tests/Items/VendorStateTests.cs
Normal file
155
tests/AcDream.Core.Tests/Items/VendorStateTests.cs
Normal file
|
|
@ -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<VendorTransition>();
|
||||
state.Changed += changes.Add;
|
||||
|
||||
Assert.False(state.Apply(0u, default, Array.Empty<VendorShopItem>()));
|
||||
|
||||
Assert.Equal(0u, state.VendorId);
|
||||
Assert.Empty(changes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_NewVendor_PublishesOpenedAndStoresSnapshot()
|
||||
{
|
||||
var state = new VendorState();
|
||||
var changes = new List<VendorTransition>();
|
||||
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<VendorShopItem>());
|
||||
|
||||
var changes = new List<VendorTransition>();
|
||||
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<VendorShopItem>()));
|
||||
|
||||
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<VendorShopItem>());
|
||||
|
||||
var changes = new List<VendorTransition>();
|
||||
state.Changed += changes.Add;
|
||||
|
||||
Assert.True(state.Apply(0x40000004u, default, Array.Empty<VendorShopItem>()));
|
||||
|
||||
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<VendorTransition>();
|
||||
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<VendorShopItem>());
|
||||
|
||||
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<AggregateException>(() => 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue