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