feat(vendor): Slice 6 buy arc — shop items are real objects, vendor selection is THE selection, and Buy works (0x005F)
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
Three ordered pieces in one landing (the shared controller/composition files carry all three; the internal order was 6.1 -> 6.2 -> 6.3): 6.1 VendorShopItemMaterializer diff-merges the shop list into the live ClientObjectTable on VendorState transitions (so client-local close and session teardown retire the entries too) and never claims a guid it did not add — ACE's UniqueItemsForSale can re-list a guid a player once held (AP-163 files the collision-skip; no retail counterpart traced). Right-click examine on shop items now routes through the ordinary appraisal path — the 5.4 F7c blocker dissolves with the table entries. 6.2 SelectionChangeSource.Vendor: row clicks, auto-select, and examine all flow through the canonical SelectionState; the status bar and the existing byte-faithful StackSplitQuantityState slider light up unmodified. VendorSplitPolicy is the single 0xDC41CB0 mask owner; the slider VALUE seeds to 1 for exempt items while maxSplitSize keeps the stack (the splitSize/maxSplitSize distinction, research §B.3). Selection clears at retail's actual site — VendorItemsUI::RemoveFromShop (pc:202848), not a CloseVendor-level clear that does not exist. 6.3 BuildBuy (0x005F): vendorGuid, count, (i32 amount, u32 guid) pairs, and the trailing alternateCurrencyId the REAL client sends (CM_Vendor::Event_Buy pc:689288) though ACE's reader ignores it. TryBuy rides the EXISTING J5.2 one-request-at-a-time reservation and completes on UseDone; the Buy button disables while a request is in flight. The reconciliation round-trip (money property update, inventory CreateObject, ApproachVendor refresh -> panel rebuild) is proven by a synthetic-inbound test against existing machinery — no new owner. Register: AP-161 narrowed (selection + examine residuals close; staging/Sell remain; double-click-to-buy confirmed ABSENT from retail with negative evidence cited — we match retail). AP-162 files the conscious no-client-side-affordability-precheck deferral. Clean-room complete solution: 11,368 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c884a938e0
commit
97cf873870
19 changed files with 1577 additions and 65 deletions
|
|
@ -17,7 +17,13 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
|
|||
long ShortcutDispatchFailureCount,
|
||||
long TransactionDispatchFailureCount,
|
||||
// Slice 5.3: the sole open vendor shop id, 0 when no session is open.
|
||||
uint VendorId)
|
||||
uint VendorId,
|
||||
// Slice 6.1: guids VendorShopItemMaterializer currently owns in
|
||||
// ClientObjectTable. Must reach 0 alongside VendorId — a nonzero count
|
||||
// here with VendorId already 0 would mean materialized shop items
|
||||
// outlived their session (the exact regression the removal-lifecycle
|
||||
// requirement guards against).
|
||||
int MaterializedVendorItemCount)
|
||||
{
|
||||
public bool IsConverged =>
|
||||
IsDisposed
|
||||
|
|
@ -30,7 +36,8 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot(
|
|||
&& ItemManaCount == 0
|
||||
&& ShortcutCount == 0
|
||||
&& ShortcutSubscriberCount == 0
|
||||
&& VendorId == 0u;
|
||||
&& VendorId == 0u
|
||||
&& MaterializedVendorItemCount == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -58,6 +65,13 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
// Changed event for presentation observers" shape, generation-gated
|
||||
// and torn down alongside the rest of this owner's children.
|
||||
Vendor = new VendorState();
|
||||
// Slice 6.1: materializes/retires ApproachVendor shop items into the
|
||||
// SAME ClientObjectTable this owner exposes as Objects — see
|
||||
// VendorShopItemMaterializer's class doc for why it subscribes to
|
||||
// Vendor.Changed directly rather than the ApproachVendor wire
|
||||
// handler (it must also react to RuntimeVendorRangeQuery's
|
||||
// client-local Close() and this owner's own Reset()/Dispose()).
|
||||
VendorItems = new VendorShopItemMaterializer(Vendor, _entityObjects.Objects);
|
||||
View = new InventoryStateView(this);
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +81,7 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
public ShortcutStore Shortcuts { get; }
|
||||
public InventoryTransactionState Transactions { get; }
|
||||
public VendorState Vendor { get; }
|
||||
public VendorShopItemMaterializer VendorItems { get; }
|
||||
public IRuntimeInventoryStateView View { get; }
|
||||
public bool IsDisposed => _disposed;
|
||||
|
||||
|
|
@ -83,7 +98,8 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
Shortcuts.SubscriberCount,
|
||||
Shortcuts.DispatchFailureCount,
|
||||
Transactions.DispatchFailureCount,
|
||||
Vendor.VendorId);
|
||||
Vendor.VendorId,
|
||||
VendorItems.OwnedCount);
|
||||
|
||||
public void ResetExternalContainer() => ExternalContainers.Reset();
|
||||
public void ResetTransactions() => Transactions.ResetSession();
|
||||
|
|
@ -155,7 +171,13 @@ public sealed class RuntimeInventoryState : IDisposable
|
|||
try
|
||||
{
|
||||
Try(() => ExternalContainers.Reset(), ref failures);
|
||||
// Vendor.Reset() must run BEFORE VendorItems.Dispose() —
|
||||
// Reset() fires Changed synchronously, which is what drives the
|
||||
// materializer's own retire pass; disposing first would
|
||||
// unsubscribe before that pass runs and strand materialized
|
||||
// items in ClientObjectTable past session teardown.
|
||||
Try(() => Vendor.Reset(), ref failures);
|
||||
Try(VendorItems.Dispose, ref failures);
|
||||
Try(ItemMana.Clear, ref failures);
|
||||
Try(Shortcuts.Dispose, ref failures);
|
||||
Try(Transactions.Dispose, ref failures);
|
||||
|
|
|
|||
202
src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs
Normal file
202
src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.Runtime.Gameplay;
|
||||
|
||||
/// <summary>
|
||||
/// Slice 6.1: materializes each <c>ApproachVendor</c> shop-list item into the
|
||||
/// SAME <see cref="ClientObjectTable"/> Runtime issues identity into for
|
||||
/// spawned entities (J3.5's "Runtime issues identity before App hydration"
|
||||
/// ownership — see <c>ObjectTableWiring.ApplyEntitySpawn</c>, which is
|
||||
/// invoked from <c>RuntimeEntityObjectLifetime</c>, never directly from
|
||||
/// <c>GameEventWiring</c>/<c>AcDream.Core.Net</c>). Owned by
|
||||
/// <see cref="RuntimeInventoryState"/> alongside the <see cref="VendorState"/>
|
||||
/// it observes and the exact <see cref="ClientObjectTable"/> instance
|
||||
/// <c>RuntimeEntityObjectLifetime</c> owns.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why subscribe to <see cref="VendorState.Changed"/> instead of the
|
||||
/// ApproachVendor wire handler.</b> A listener registered only on the
|
||||
/// ApproachVendor GameEvent (<c>GameEventWiring.cs</c>) would only ever see
|
||||
/// the wire-driven <c>Opened</c>/<c>Refreshed</c> transitions. Two of the
|
||||
/// four transition kinds never touch the wire at all:
|
||||
/// <c>RuntimeVendorRangeQuery.EnforceRange</c>'s distance-triggered
|
||||
/// <see cref="VendorState.Close"/> and this owner's own
|
||||
/// <see cref="VendorState.Reset"/> teardown (session reset / portal-out /
|
||||
/// logout / final disposal). Subscribing directly to <c>Changed</c> reacts
|
||||
/// uniformly to every source, matching the Slice 6 contract's "on session
|
||||
/// Close/Replace/Reset, the materialized shop items leave the table."
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Retail anchor.</b> <c>gmVendorUI::OpenVendor</c> materializes each
|
||||
/// list item as a full <c>CWeenieObject</c> in <c>ClientObjMaintSystem</c>
|
||||
/// (research doc <c>docs/research/2026-08-08-slice5-vendor-browse-research.md</c>
|
||||
/// §A.2 point 4, pc:203720-203748) — vendor items are ordinary client
|
||||
/// objects with no spatial presence, not a separate lightweight record.
|
||||
/// §C.1 of the Slice 6 research doc names the load-bearing consequence:
|
||||
/// without a live <see cref="ClientObjectTable"/> entry,
|
||||
/// <c>SelectedObjectController</c>'s name/stack resolvers (Slice 6.2) and
|
||||
/// <c>AppraisalUiController.Apply</c> (this slice's examine wiring) both
|
||||
/// come up blank. <c>gmVendorUI::CloseVendor</c> (pc:202080) is the retail
|
||||
/// teardown site this class mirrors for the removal half.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Diff, not blanket remove-then-reinsert.</b> ACE's own
|
||||
/// <c>Vendor.LoadInventory</c>
|
||||
/// (<c>references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:126-172</c>)
|
||||
/// assigns each <c>DefaultItemsForSale</c> entry a real server guid ONCE, at
|
||||
/// first load — that guid is stable across every later
|
||||
/// <c>ApproachVendor</c> for the SAME vendor, so a post-buy/sell
|
||||
/// <c>Refreshed</c> snapshot reuses the same guids for stock that's still in
|
||||
/// supply. Removing and immediately re-adding a still-present guid would
|
||||
/// fire a spurious <c>ObjectRemoved</c>/<c>ObjectAdded</c> pair for every
|
||||
/// unrelated line item on every refresh — a UI panel holding that guid (an
|
||||
/// open appraisal window, Slice 6.2's <c>SelectionState</c>) would see a
|
||||
/// false "it's gone" notice. Only guids that left this vendor's stock (sold
|
||||
/// out, or a DIFFERENT vendor entirely superseded this one) are removed;
|
||||
/// every still-present guid is merge-upserted via the ordinary
|
||||
/// <see cref="ClientObjectTable.Ingest"/> path (a harmless no-op if
|
||||
/// genuinely unchanged, a field refresh otherwise). On a supersede (a
|
||||
/// DIFFERENT vendor's <c>Opened</c> transition), the retire pass runs before
|
||||
/// the materialize pass in the SAME call, satisfying the contract's "on
|
||||
/// REPLACE, the old vendor's items go before the new ones land."
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Collision policy.</b> ACE's <c>UniqueItemsForSale</c>
|
||||
/// (<c>Vendor.cs:34,638</c>) keeps the EXACT <c>WorldObject</c> — and
|
||||
/// therefore the exact guid — a player last held when they sold it to this
|
||||
/// vendor. If that guid is already present in <see cref="ClientObjectTable"/>
|
||||
/// for a reason THIS materializer did not itself create (a live entity, an
|
||||
/// item still sitting in someone's inventory/equipment, or any other
|
||||
/// collision), <see cref="ClientObjectTable.Ingest"/>-ing vendor-owned
|
||||
/// <see cref="WeenieData"/> over it would silently reparent a real object
|
||||
/// into the vendor's container. Per the project's no-workarounds-without-
|
||||
/// approval rule this is a SKIP, not a best-effort overwrite: a guid this
|
||||
/// class did not itself add to its owned set on the previous cycle is
|
||||
/// treated as owned by someone else and is left completely untouched (not
|
||||
/// materialized, not tracked, not later removed by this class either). The
|
||||
/// vendor row still renders correctly regardless —
|
||||
/// <c>VendorUiController</c> reads display fields straight off
|
||||
/// <see cref="VendorShopItem"/>, never through <see cref="ClientObjectTable"/>
|
||||
/// — only that one item's status-bar/appraisal projection stays whatever it
|
||||
/// already was, which is safe by construction and never corrupts a real
|
||||
/// object's ownership.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class VendorShopItemMaterializer : IDisposable
|
||||
{
|
||||
private readonly VendorState _vendor;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly HashSet<uint> _ownedGuids = new();
|
||||
private bool _disposed;
|
||||
|
||||
public VendorShopItemMaterializer(VendorState vendor, ClientObjectTable objects)
|
||||
{
|
||||
_vendor = vendor ?? throw new ArgumentNullException(nameof(vendor));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_vendor.Changed += OnVendorTransition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Count of guids this materializer currently owns in
|
||||
/// <see cref="ClientObjectTable"/>. Zero once the session is closed/
|
||||
/// reset — feeds <see cref="RuntimeInventoryOwnershipSnapshot"/>'s
|
||||
/// convergence gate.
|
||||
/// </summary>
|
||||
public int OwnedCount => _ownedGuids.Count;
|
||||
|
||||
/// <summary>True if <paramref name="guid"/> is a shop item this materializer put in the table.</summary>
|
||||
public bool Owns(uint guid) => _ownedGuids.Contains(guid);
|
||||
|
||||
private void OnVendorTransition(VendorTransition transition)
|
||||
{
|
||||
IReadOnlyList<VendorShopItem> currentItems = _vendor.Items;
|
||||
var stillListed = new HashSet<uint>(currentItems.Count);
|
||||
foreach (VendorShopItem item in currentItems)
|
||||
stillListed.Add(item.ItemGuid);
|
||||
|
||||
// Retire every guid we own that fell out of the new snapshot (sold
|
||||
// out, session closed/reset, or a different vendor superseded this
|
||||
// one — in every one of those cases stillListed is missing it).
|
||||
// Runs BEFORE the materialize loop below: "on REPLACE, the old
|
||||
// vendor's items go before the new ones land."
|
||||
foreach (uint guid in _ownedGuids)
|
||||
{
|
||||
if (!stillListed.Contains(guid))
|
||||
_objects.Remove(guid);
|
||||
}
|
||||
|
||||
var nextOwned = new HashSet<uint>(currentItems.Count);
|
||||
foreach (VendorShopItem item in currentItems)
|
||||
{
|
||||
bool ownedAlready = _ownedGuids.Contains(item.ItemGuid);
|
||||
if (!ownedAlready && _objects.Get(item.ItemGuid) is not null)
|
||||
{
|
||||
// Collision guard — see class doc. Never take ownership of a
|
||||
// guid this materializer did not itself add.
|
||||
Console.Error.WriteLine(
|
||||
"[VendorShopItemMaterializer] skipped guid=0x"
|
||||
+ item.ItemGuid.ToString("X8")
|
||||
+ " — already present in ClientObjectTable and not "
|
||||
+ "owned by this vendor session.");
|
||||
continue;
|
||||
}
|
||||
|
||||
_objects.Ingest(ToWeenieData(item, transition.VendorId));
|
||||
nextOwned.Add(item.ItemGuid);
|
||||
}
|
||||
|
||||
_ownedGuids.Clear();
|
||||
foreach (uint guid in nextOwned)
|
||||
_ownedGuids.Add(guid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Field mapping from the domain-shaped <see cref="VendorShopItem"/> to
|
||||
/// the wire-shaped merge patch <see cref="ClientObjectTable.Ingest"/>
|
||||
/// expects. <see cref="VendorShopItem.DescStackSize"/> — not
|
||||
/// <see cref="VendorShopItem.StackSize"/>, ItemProfile's separate packed
|
||||
/// SUPPLY-count field — is the wire equivalent of an ordinary
|
||||
/// CreateObject's own StackSize field (see the doc comment on
|
||||
/// <see cref="VendorShopItem.DescStackSize"/>). Every field
|
||||
/// <see cref="VendorShopItem"/> doesn't carry (capacity, equip mask,
|
||||
/// combat use, etc.) is passed null, leaving it untouched on a refresh
|
||||
/// and defaulted on a fresh object per <see cref="WeenieData"/>'s
|
||||
/// null-preserving merge contract.
|
||||
/// </summary>
|
||||
private static WeenieData ToWeenieData(VendorShopItem item, uint vendorId) => new(
|
||||
Guid: item.ItemGuid,
|
||||
Name: item.Name,
|
||||
Type: item.ItemType is { } t ? (ItemType)t : null,
|
||||
WeenieClassId: item.WeenieClassId,
|
||||
IconId: item.IconId,
|
||||
IconOverlayId: item.IconOverlayId,
|
||||
IconUnderlayId: item.IconUnderlayId,
|
||||
Effects: item.Effects,
|
||||
Value: item.Value,
|
||||
StackSize: item.DescStackSize,
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: vendorId,
|
||||
WielderId: 0u,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null,
|
||||
PluralName: item.PluralName);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_vendor.Changed -= OnVendorTransition;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue