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
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>
291 lines
14 KiB
C#
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> -> <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>();
|
|
}
|
|
}
|