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>();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue