using System;
using System.Collections.Generic;
namespace AcDream.Core.Items;
///
/// Domain-shaped projection of the wire ApproachVendor GameEvent's
/// fixed profile prefix (buy/sell rates, currency, categories). The
/// wire-shaped equivalent (VendorApproach.VendorProfile) lives in
/// AcDream.Core.Net.Messages, which AcDream.Core cannot
/// reference (dependency runs AcDream.Core.Net -> AcDream.Core,
/// never the other way). This mirrors how ContainerContentEntry
/// () is the domain projection of the wire
/// ViewContentsEntry/CreateObject shapes — the Slice 5.3
/// wiring glue (GameEventWiring.cs, which CAN see both layers) does
/// the field-by-field conversion, the same way it already does for
/// ViewContents today.
///
public readonly record struct VendorShopProfile(
uint MerchandiseItemTypes,
uint MerchandiseMinValue,
uint MerchandiseMaxValue,
bool DealMagicalItems,
float BuyPrice,
float SellPrice,
uint AlternateCurrencyWcid,
uint AlternateCurrencyAmount,
string AlternateCurrencyPluralName);
///
/// Domain-shaped projection of one ApproachVendor shop-list entry —
/// only the fields Slice 5's browse scope needs (display + price math).
/// The full PublicWeenieDesc the wire carries has ~40 optional
/// fields; the rest are Slice 6+ concerns (or already live on the
/// record once Slice 5.3 registers each
/// shop item there per the research doc's §A.2 point 4 recommendation).
///
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,
// Slice 5.3 review fix 2: the ITEM'S OWN authored stack depth (retail
// PublicWeenieDesc::_stackSize, wire AcDream.Core.Net.Messages.
// PublicWeenieDescBody.StackSize) -- the divisor VendorPricing.PerUnitValue
// needs to turn Value's STACK-TOTAL wire number into a per-unit display
// price (VendorProfile::VendorSellPrice/VendorBuyPrice, 0x005D1B00/
// 0x005D1B70). This is a DIFFERENT wire field from StackSize above:
// that one is ItemProfile's packed SUPPLY count (how many the vendor has
// in stock), this one is how many units make up one priced stack (e.g.
// 50 for a stack of arrows). Nullable because the wire field is
// conditionally present (weenieFlags-gated) -- absent maps to null here,
// matching retail's own zeroed-struct default of 0 for the same case
// (see VendorPricing.PerUnitValue's <= 0 guard).
int? DescStackSize = null,
// Grand-gate finding R1, corrected at the 2026-08-08 live-evidence
// re-fix (register AP-169, second correction): the item TYPE's authored
// stack ceiling (retail PublicWeenieDesc::_maxStackSize, wire
// AcDream.Core.Net.Messages.PublicWeenieDescBody.StackSizeMax). This is
// retail's PRIMARY vendor-owned quantity operand, read literally by
// VendorItemsUI::UpdateItemsList (0x004c1ea0, pc:201085-201133 — each
// browse row displays min(remaining, _maxStackSize), plain
// _maxStackSize for unlimited supply), InqListSlotCount (pc:200052),
// and the Buying tab's Buy Item/Clear Item removal rule
// (gmVendorUI::HandleButtonClicks cases 0x100000c9/0x100000cb,
// pc:203996/204086) — never the instance _stackSize. The live ACE wire
// (vendor-diag 2026-08-08) carries descStackSize=1 for every browse
// row, so any desc-first read collapses every vendor stack to 1 — see
// VendorSplitPolicy.ResolveAuthoredStackSize (max-first, desc
// fallback) and the register (AP-169).
int? MaxStackSize = null,
// Review finding F5 (Slice 5.4 review): PublicWeenieDescBody already
// carries these three (IconOverlayId/IconUnderlayId/UiEffects) — see
// AcDream.Core.Net.Messages.PublicWeenieDescBody. Mirrors
// ClientObjectTable's ClientObject.IconUnderlayId/IconOverlayId/Effects
// naming exactly, so VendorUiController can forward them to
// _resolveIcon the same way ExternalContainerController.CreateCell does
// (item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId,
// item.Effects). 0 = "not sent", same as ClientObject's convention.
uint IconUnderlayId = 0u,
uint IconOverlayId = 0u,
uint Effects = 0u,
// Review finding F2/F3 (Slice 5.4 review): retail's NAME_PLURAL display
// (ACCWeenieObject::GetObjectName, pc:409056-409132) reads
// PublicWeenieDesc::_plural_name -- wire AcDream.Core.Net.Messages.
// PublicWeenieDescBody.PluralName. Null/empty is a real, retail-modeled
// case (no plural authored): GetObjectName falls back to the singular
// Name unchanged, not an auto-pluralized "Name+s".
string? PluralName = null);
public enum VendorStateTransitionKind
{
/// A different vendor than whatever was previously open (or nothing) is now open.
Opened,
/// The SAME vendor id sent a fresh ApproachVendor (post-buy/sell refresh — Slice 6).
Refreshed,
/// The shop was closed (client-local distance/switch trigger — retail A.3).
Closed,
/// Session teardown (portal/reconnect/logout).
Reset,
}
public readonly record struct VendorTransition(
VendorStateTransitionKind Kind,
uint PreviousVendorId,
uint VendorId);
///
/// Owns the currently-open vendor shop snapshot: the vendor's guid, its
/// shop terms, and its item-for-sale list. Structural sibling of
/// (Slice 5 contract decision 1) — same
/// "authoritative server-driven full-replace view... with a
/// Changed event for presentation observers" shape, widened to also
/// carry the profile + item list ExternalContainerState doesn't need
/// (a container has no rates/currency/categories of its own).
///
///
/// No request/current id gating. Unlike
/// (which tracks a RequestedContainerId separate from
/// CurrentContainerId 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 attemptOpenVendorID
/// mode-2-vs-3 tab selection is deferred to Slice 6's sell-drag UI). Every
/// ApproachVendor is unconditionally authoritative (research doc
/// §A.3: "each ApproachVendor is a COMPLETE replace"), so
/// is a single-phase call, not a request/apply pair.
///
///
///
/// distinguishes a brand-new vendor
/// () from a same-vendor
/// refresh (, which will
/// only occur once Slice 6's buy/sell actions trigger a repeat
/// ApproachVendor) so a future UI layer (Slice 5.4) can decide
/// whether to reset its own sub-widgets — mirroring retail's
/// gmVendorUI::OpenVendor, 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.
///
///
public sealed class VendorState
{
public uint VendorId { get; private set; }
public VendorShopProfile Profile { get; private set; }
public IReadOnlyList Items { get; private set; } = Array.Empty();
public event Action? Changed;
///
/// Apply a full ApproachVendor snapshot. Returns false (no-op,
/// no event) for the sentinel guid 0 — matching
/// 's treatment of a
/// zero id as "not a real target."
///
public bool Apply(uint vendorGuid, VendorShopProfile profile, IReadOnlyList items)
{
ArgumentNullException.ThrowIfNull(items);
if (vendorGuid == 0u) return false;
uint previous = VendorId;
bool sameVendor = previous != 0u && previous == vendorGuid;
VendorId = vendorGuid;
Profile = profile;
Items = items;
var transition = new VendorTransition(
sameVendor ? VendorStateTransitionKind.Refreshed : VendorStateTransitionKind.Opened,
previous,
vendorGuid);
// Review finding F8 (Slice 5.4 review): match Close()'s per-listener
// isolation + catch-and-log shape instead of a bare Invoke — Apply()
// is called from the SAME per-frame inbound-message-dispatch boundary
// (GameEventDispatcher.Dispatch's ApproachVendor handler) that
// Close()'s doc comment already establishes must survive a broken
// observer, and a bare Invoke lets the FIRST listener's exception
// starve every listener registered after it (e.g. a plugin panel
// wired after the retail VendorUiController).
Action? listeners = Changed;
if (listeners is not null)
{
foreach (Action listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error)
{
Console.Error.WriteLine(
$"[VendorState] Apply() observer threw: {error.Message}");
}
}
}
return true;
}
///
/// Clear the open shop (client-local close — distance watcher or a
/// different-vendor open superseding this one; see research doc §A.3).
/// Returns false if no vendor was open.
///
///
/// Slice 5.3 review fix 3. Unlike , a failing
/// observer here is never rethrown. Close()'s
/// production caller (RuntimeVendorRangeQuery.EnforceRange) runs
/// inside the per-frame post-network-command-phase callback
/// (GameRuntime.CreateLocalPlayerFrameController's post-network
/// phase) with no try/catch anywhere up the frame-loop chain — an
/// propagating out of here, 's
/// shape, would kill the frame. keeps that
/// collect-and-rethrow shape because ITS callers (session
/// reset/portal-out/logout — a rare, explicit teardown boundary) already
/// tolerate/handle it (e.g. RuntimeInventoryState.Dispose's own
/// Try(...) wrapper collects 's failures
/// alongside every other child's). This still fans out to every listener
/// via GetInvocationList() (one broken observer must not starve
/// another — same resilience as ), but LOGS each
/// failure instead of collecting it into an exception, matching
/// GameEventDispatcher.Dispatch's own boundary contract
/// (src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117 —
/// catch, Console.Error.WriteLine, never rethrow, "the decode
/// thread must survive handler failures"): a per-frame boundary must
/// survive its own observers' failures the same way. Not silent
/// swallowing — the failure surfaces on
/// exactly the way the dispatcher's do.
///
public bool Close()
{
if (VendorId == 0u) return false;
uint previous = VendorId;
ClearFields();
var transition = new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u);
Action? listeners = Changed;
if (listeners is not null)
{
foreach (Action listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error)
{
Console.Error.WriteLine(
$"[VendorState] Close() observer threw: {error.Message}");
}
}
}
return true;
}
///
/// Session-lifecycle teardown (portal-out/reconnect/logout). Fans the
/// transition out to every listener even if one
/// throws, matching 's
/// AggregateException-collecting shape so one broken observer cannot
/// prevent the others from converging.
///
public bool Reset()
{
uint previous = VendorId;
bool changed = previous != 0u;
ClearFields();
var transition = new VendorTransition(VendorStateTransitionKind.Reset, previous, 0u);
Action? listeners = Changed;
if (listeners is not null)
{
List? failures = null;
foreach (Action 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();
}
}