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
119
src/AcDream.Core/Items/VendorPricing.cs
Normal file
119
src/AcDream.Core/Items/VendorPricing.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Items;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail vendor price DISPLAY math — the pure "what number does the shop
|
||||||
|
/// list show" formula. Ported from the named-retail decompile,
|
||||||
|
/// <c>ShopSystem::BuyPrice</c> / <c>ShopSystem::SellPrice</c>
|
||||||
|
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:702082-702128</c>,
|
||||||
|
/// addresses <c>0x006B6120</c> / <c>0x006B6180</c> — read directly from the
|
||||||
|
/// decompiled body, not just the research doc's paraphrase), cross-checked
|
||||||
|
/// against ACE's server-authoritative
|
||||||
|
/// <c>Vendor.GetBuyCost</c>/<c>Vendor.GetSellCost</c>
|
||||||
|
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:573-599</c>).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Naming inversion warning</b> (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:
|
||||||
|
/// <see cref="BuyPrice"/> is NOT "the price you pay to buy something," it is
|
||||||
|
/// "the price the vendor pays when it buys from you."
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Retail vs. ACE — a literal difference that does not change any real
|
||||||
|
/// output.</b> The retail decomp's <c>ShopSystem::BuyPrice</c>/<c>SellPrice</c>
|
||||||
|
/// 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 <c>-1</c> sentinel (<c>0xFFFFFFFF</c>
|
||||||
|
/// cast to <c>int32_t</c>). ACE's C# port collapses this to a two-way
|
||||||
|
/// <c>Math.Max(1, ...)</c> 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
|
||||||
|
/// <c>Value</c> and a vendor's authored buy/sell rate are always
|
||||||
|
/// non-negative by game design, and <paramref name="quantity"/> is always
|
||||||
|
/// >= 1, so <c>rate * value * quantity</c> 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
|
||||||
|
/// <c>docs/architecture/retail-divergence-register.md</c>'s scope note and
|
||||||
|
/// CLAUDE.md's grep-named-first workflow.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class VendorPricing
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ShopSystem::BuyPrice</c> (<c>0x006B6120</c>): the price the vendor
|
||||||
|
/// PAYS the player for <paramref name="quantity"/> units of an item
|
||||||
|
/// worth <paramref name="perUnitValue"/> each, at the vendor's
|
||||||
|
/// <paramref name="buyRate"/>. PromissoryNote items always use rate 1.0
|
||||||
|
/// regardless of the vendor's authored rate (retail
|
||||||
|
/// <c>pc:702087-702090</c>: <c>if (arg2 != TYPE_PROMISSORY_NOTE) x87_r7
|
||||||
|
/// = arg3; else x87_r7 = 1f;</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="perUnitValue">The item's per-unit <c>Value</c> (retail <c>arg1</c>).</param>
|
||||||
|
/// <param name="itemType">The item's <see cref="ItemType"/> bitmask (retail <c>arg2</c>).</param>
|
||||||
|
/// <param name="buyRate">The vendor's authored buy rate (retail <c>arg3</c> / <c>this->buy_price</c>).</param>
|
||||||
|
/// <param name="quantity">Stack count being priced (retail <c>arg4</c>).</param>
|
||||||
|
/// <returns>The buy price, clamped to a minimum of 1 for any non-negative result.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>ShopSystem::SellPrice</c> (<c>0x006B6180</c>): the price the
|
||||||
|
/// player PAYS the vendor for <paramref name="quantity"/> units of an
|
||||||
|
/// item worth <paramref name="perUnitValue"/> each, at the vendor's
|
||||||
|
/// <paramref name="sellRate"/>. PromissoryNote items always use rate
|
||||||
|
/// 1.15 regardless of the vendor's authored rate (retail
|
||||||
|
/// <c>pc:702112-702115</c>: <c>if (arg2 != TYPE_PROMISSORY_NOTE) x87_r7
|
||||||
|
/// = arg3; else x87_r7 = 1.14999998f;</c> — the literal retail constant
|
||||||
|
/// is the float32 nearest-representable value to 1.15).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="perUnitValue">The item's per-unit <c>Value</c> (retail <c>arg1</c>).</param>
|
||||||
|
/// <param name="itemType">The item's <see cref="ItemType"/> bitmask (retail <c>arg2</c>).</param>
|
||||||
|
/// <param name="sellRate">The vendor's authored sell rate (retail <c>arg3</c> / <c>this->sell_price</c>).</param>
|
||||||
|
/// <param name="quantity">Stack count being priced (retail <c>arg4</c>).</param>
|
||||||
|
/// <returns>The sell price, clamped to a minimum of 1 for any non-negative result.</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/AcDream.Core/Items/VendorState.cs
Normal file
185
src/AcDream.Core/Items/VendorState.cs
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Items;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain-shaped projection of the wire <c>ApproachVendor</c> GameEvent's
|
||||||
|
/// fixed profile prefix (buy/sell rates, currency, categories). The
|
||||||
|
/// wire-shaped equivalent (<c>VendorApproach.VendorProfile</c>) lives in
|
||||||
|
/// <c>AcDream.Core.Net.Messages</c>, which <c>AcDream.Core</c> cannot
|
||||||
|
/// reference (dependency runs <c>AcDream.Core.Net</c> -> <c>AcDream.Core</c>,
|
||||||
|
/// never the other way). This mirrors how <c>ContainerContentEntry</c>
|
||||||
|
/// (<see cref="ClientObjectTable"/>) is the domain projection of the wire
|
||||||
|
/// <c>ViewContentsEntry</c>/<c>CreateObject</c> shapes — the Slice 5.3
|
||||||
|
/// wiring glue (<c>GameEventWiring.cs</c>, which CAN see both layers) does
|
||||||
|
/// the field-by-field conversion, the same way it already does for
|
||||||
|
/// <c>ViewContents</c> today.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct VendorShopProfile(
|
||||||
|
uint MerchandiseItemTypes,
|
||||||
|
uint MerchandiseMinValue,
|
||||||
|
uint MerchandiseMaxValue,
|
||||||
|
bool DealMagicalItems,
|
||||||
|
float BuyPrice,
|
||||||
|
float SellPrice,
|
||||||
|
uint AlternateCurrencyWcid,
|
||||||
|
uint AlternateCurrencyAmount,
|
||||||
|
string AlternateCurrencyPluralName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain-shaped projection of one <c>ApproachVendor</c> shop-list entry —
|
||||||
|
/// only the fields Slice 5's browse scope needs (display + price math).
|
||||||
|
/// The full <c>PublicWeenieDesc</c> the wire carries has ~40 optional
|
||||||
|
/// fields; the rest are Slice 6+ concerns (or already live on the
|
||||||
|
/// <see cref="ClientObjectTable"/> record once Slice 5.3 registers each
|
||||||
|
/// shop item there per the research doc's §A.2 point 4 recommendation).
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <summary>A different vendor than whatever was previously open (or nothing) is now open.</summary>
|
||||||
|
Opened,
|
||||||
|
/// <summary>The SAME vendor id sent a fresh ApproachVendor (post-buy/sell refresh — Slice 6).</summary>
|
||||||
|
Refreshed,
|
||||||
|
/// <summary>The shop was closed (client-local distance/switch trigger — retail A.3).</summary>
|
||||||
|
Closed,
|
||||||
|
/// <summary>Session teardown (portal/reconnect/logout).</summary>
|
||||||
|
Reset,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct VendorTransition(
|
||||||
|
VendorStateTransitionKind Kind,
|
||||||
|
uint PreviousVendorId,
|
||||||
|
uint VendorId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the currently-open vendor shop snapshot: the vendor's guid, its
|
||||||
|
/// shop terms, and its item-for-sale list. Structural sibling of
|
||||||
|
/// <see cref="ExternalContainerState"/> (Slice 5 contract decision 1) — same
|
||||||
|
/// "authoritative server-driven full-replace view... with a
|
||||||
|
/// <c>Changed</c> event for presentation observers" shape, widened to also
|
||||||
|
/// carry the profile + item list <c>ExternalContainerState</c> doesn't need
|
||||||
|
/// (a container has no rates/currency/categories of its own).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>No request/current id gating.</b> Unlike <see cref="ExternalContainerState"/>
|
||||||
|
/// (which tracks a <c>RequestedContainerId</c> separate from
|
||||||
|
/// <c>CurrentContainerId</c> 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 <c>attemptOpenVendorID</c>
|
||||||
|
/// mode-2-vs-3 tab selection is deferred to Slice 6's sell-drag UI). Every
|
||||||
|
/// <c>ApproachVendor</c> is unconditionally authoritative (research doc
|
||||||
|
/// §A.3: "each ApproachVendor is a COMPLETE replace"), so <see cref="Apply"/>
|
||||||
|
/// is a single-phase call, not a request/apply pair.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="VendorTransition.Kind"/> distinguishes a brand-new vendor
|
||||||
|
/// (<see cref="VendorStateTransitionKind.Opened"/>) from a same-vendor
|
||||||
|
/// refresh (<see cref="VendorStateTransitionKind.Refreshed"/>, which will
|
||||||
|
/// only occur once Slice 6's buy/sell actions trigger a repeat
|
||||||
|
/// <c>ApproachVendor</c>) so a future UI layer (Slice 5.4) can decide
|
||||||
|
/// whether to reset its own sub-widgets — mirroring retail's
|
||||||
|
/// <c>gmVendorUI::OpenVendor</c>, 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VendorState
|
||||||
|
{
|
||||||
|
public uint VendorId { get; private set; }
|
||||||
|
public VendorShopProfile Profile { get; private set; }
|
||||||
|
public IReadOnlyList<VendorShopItem> Items { get; private set; } = Array.Empty<VendorShopItem>();
|
||||||
|
|
||||||
|
public event Action<VendorTransition>? Changed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Apply a full ApproachVendor snapshot. Returns <c>false</c> (no-op,
|
||||||
|
/// no event) for the sentinel guid 0 — matching
|
||||||
|
/// <see cref="ExternalContainerState.RequestOpen"/>'s treatment of a
|
||||||
|
/// zero id as "not a real target."
|
||||||
|
/// </summary>
|
||||||
|
public bool Apply(uint vendorGuid, VendorShopProfile profile, IReadOnlyList<VendorShopItem> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear the open shop (client-local close — distance watcher or a
|
||||||
|
/// different-vendor open superseding this one; see research doc §A.3).
|
||||||
|
/// Returns <c>false</c> if no vendor was open.
|
||||||
|
/// </summary>
|
||||||
|
public bool Close()
|
||||||
|
{
|
||||||
|
if (VendorId == 0u) return false;
|
||||||
|
|
||||||
|
uint previous = VendorId;
|
||||||
|
ClearFields();
|
||||||
|
|
||||||
|
Changed?.Invoke(new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session-lifecycle teardown (portal-out/reconnect/logout). Fans the
|
||||||
|
/// transition out to every <see cref="Changed"/> listener even if one
|
||||||
|
/// throws, matching <see cref="ExternalContainerState.Reset"/>'s
|
||||||
|
/// AggregateException-collecting shape so one broken observer cannot
|
||||||
|
/// prevent the others from converging.
|
||||||
|
/// </summary>
|
||||||
|
public bool Reset()
|
||||||
|
{
|
||||||
|
uint previous = VendorId;
|
||||||
|
bool changed = previous != 0u;
|
||||||
|
ClearFields();
|
||||||
|
|
||||||
|
var transition = new VendorTransition(VendorStateTransitionKind.Reset, previous, 0u);
|
||||||
|
Action<VendorTransition>? listeners = Changed;
|
||||||
|
if (listeners is not null)
|
||||||
|
{
|
||||||
|
List<Exception>? failures = null;
|
||||||
|
foreach (Action<VendorTransition> 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<VendorShopItem>();
|
||||||
|
}
|
||||||
|
}
|
||||||
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