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
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