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

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:
Erik 2026-08-07 15:05:47 +02:00
parent e45c95b06c
commit 70f37dbd5c
4 changed files with 589 additions and 0 deletions

View 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
/// &gt;= 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-&gt;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-&gt;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;
}
}

View 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> -&gt; <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>();
}
}