acdream/src/AcDream.Core/Items/VendorState.cs
Erik 02b735ba4a
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
fix(vendor): evidence-based pass — max-first stack ceiling; the local player resolves never-animated MoveTo targets
Both chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:

The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).

Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.

AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.

Clean-room complete solution: 11,536 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 17:17:04 +02:00

291 lines
14 KiB
C#

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,
// 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
{
/// <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;
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<VendorTransition>? listeners = Changed;
if (listeners is not null)
{
foreach (Action<VendorTransition> listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error)
{
Console.Error.WriteLine(
$"[VendorState] Apply() observer threw: {error.Message}");
}
}
}
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>
/// <remarks>
/// <b>Slice 5.3 review fix 3.</b> Unlike <see cref="Reset"/>, a failing
/// <see cref="Changed"/> observer here is never rethrown. <c>Close()</c>'s
/// production caller (<c>RuntimeVendorRangeQuery.EnforceRange</c>) runs
/// inside the per-frame post-network-command-phase callback
/// (<c>GameRuntime.CreateLocalPlayerFrameController</c>'s post-network
/// phase) with no try/catch anywhere up the frame-loop chain — an
/// <see cref="AggregateException"/> propagating out of here, <see cref="Reset"/>'s
/// shape, would kill the frame. <see cref="Reset"/> 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. <c>RuntimeInventoryState.Dispose</c>'s own
/// <c>Try(...)</c> wrapper collects <see cref="Reset"/>'s failures
/// alongside every other child's). This still fans out to every listener
/// via <c>GetInvocationList()</c> (one broken observer must not starve
/// another — same resilience as <see cref="Reset"/>), but LOGS each
/// failure instead of collecting it into an exception, matching
/// <c>GameEventDispatcher.Dispatch</c>'s own boundary contract
/// (<c>src/AcDream.Core.Net/Messages/GameEventDispatcher.cs:95-117</c> —
/// catch, <c>Console.Error.WriteLine</c>, 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 <see cref="Console.Error"/>
/// exactly the way the dispatcher's do.
/// </remarks>
public bool Close()
{
if (VendorId == 0u) return false;
uint previous = VendorId;
ClearFields();
var transition = new VendorTransition(VendorStateTransitionKind.Closed, previous, 0u);
Action<VendorTransition>? listeners = Changed;
if (listeners is not null)
{
foreach (Action<VendorTransition> listener in listeners.GetInvocationList())
{
try { listener(transition); }
catch (Exception error)
{
Console.Error.WriteLine(
$"[VendorState] Close() observer threw: {error.Message}");
}
}
}
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>();
}
}