fix(vendor): grand-gate findings — wire-truth container counts, the live split bar, arrival-gated use, prepend-order race
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

Four live findings, each with the paper-verification failure named:

G1 the container-capacity guard counted containers by a local
type/capacity heuristic that over-classifies ordinary items;
retail buckets from the wire's ContainerProperties at insert. Now
reads ClientObjectTable's existing ContainerTypeHint (AP-168 narrowed
to the shop-stock half; a pre-check must never false-block).
G2 the amount bar never showed live because ACE never sets StackSize
on browse listings — DescStackSize is null for every real vendor item
and the C4 paper test hand-set the field, bypassing the materializer.
The materializer now falls back to the packed supply count (AP-169,
ACE adaptation); the new test drives the REAL materializer.
G3 an out-of-range Use now dispatches ON ARRIVAL (pickup's shape):
ACE's HandleActionUseItem only opens the vendor when the Use finds
the player in range — a click-time send is greeted and dropped
(AP-170, ACE adaptation; retail's server walks the player, ACE
does not).
G4 bought items appended because ACE's placement echo (UIQueue) can
beat the CreateObject (SmartboxQueue) — cross-queue, no ordering
guarantee — and the early echo was silently dropped. ClientObjectTable
now stashes unresolved placements and replays them at Ingest: buys
land at the retail list head. No register row — this RESTORES parity.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 14:03:57 +02:00
parent c68ad1e646
commit 68568a3a59
12 changed files with 1140 additions and 90 deletions

View file

@ -673,6 +673,102 @@ public class SelectedObjectControllerTests
// fails, and the failure is what gets fixed).
// ══════════════════════════════════════════════════════════════════════
/// <summary>
/// G2 (grand-gate finding): C4 passed by manually hand-setting
/// <c>ClientObject.StackSize</c> directly, bypassing BOTH the real
/// <see cref="VendorShopItemMaterializer"/> that populates it from a
/// live <c>ApproachVendor</c> snapshot AND the real
/// <c>ObjectUpdated</c>/<c>ObjectAdded</c> event wiring. This test drives
/// the same scenario through the REAL materializer — <see cref="VendorState.Apply"/>
/// fires <see cref="VendorState.Changed"/>, the REAL
/// <see cref="VendorShopItemMaterializer"/> (subscribed exactly like
/// production's <c>RuntimeInventoryState</c> constructor) ingests the
/// shop item into the SAME <see cref="ClientObjectTable"/>, and only
/// THEN is the item selected — closing the gap the C4 harness left open.
/// </summary>
[Fact]
public void G2_VendorStackSelection_ThroughRealMaterializer_ShowsSplitSlider()
{
const uint vendorGuid = 0x70000011u;
const uint arrowsGuid = 0x60009011u;
ImportedLayout layout = FixtureLoader.LoadToolbar();
var objects = new ClientObjectTable();
var vendor = new VendorState();
var selection = new SelectionState();
var splitQuantity = new StackSplitQuantityState();
// REAL materializer, wired exactly like RuntimeInventoryState's
// constructor (RuntimeInventoryState.cs:74):
// VendorItems = new VendorShopItemMaterializer(Vendor, _entityObjects.Objects);
using var materializer = new VendorShopItemMaterializer(vendor, objects);
SelectedObjectController controller = SelectedObjectController.Bind(
layout,
selection,
subscribeHealthChanged: _ => { },
unsubscribeHealthChanged: _ => { },
subscribeItemManaChanged: _ => { },
unsubscribeItemManaChanged: _ => { },
isHealthTarget: _ => false,
isOwnedByPlayer: _ => false,
name: guid => objects.Get(guid)?.GetAppropriateName(),
healthPercent: _ => 0f,
hasHealth: _ => false,
stackSize: guid => (uint)(objects.Get(guid)?.StackSize ?? 0),
sendQueryHealth: _ => { },
manaPercent: _ => 0f,
sendQueryItemMana: _ => { },
datFont: null,
splitQuantity: splitQuantity,
// REAL subscription this time (C4 used no-op lambdas here).
subscribeObjectUpdated: h => objects.ObjectUpdated += h,
unsubscribeObjectUpdated: h => objects.ObjectUpdated -= h,
isVendorSplitExempt: guid =>
vendor.VendorId != 0u
&& objects.Get(guid) is { } vendorCandidate
&& vendorCandidate.ContainerId == vendor.VendorId
&& VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type));
// G2 root cause: a REAL ACE vendor listing, byte-for-byte. ACE's
// Vendor.LoadInventoryItem (Vendor.cs:144-172) builds the browse-list
// WorldObject via WorldObjectFactory.CreateNewWorldObject and sets
// ONLY wo.VendorShopCreateListStackSize = stackSize ?? -1 (the "how
// many available" packed dword — our VendorShopItem.StackSize) —
// it NEVER calls wo.SetStackSize(...), so the per-item
// PublicWeenieDesc's own conditional StackSize field (our
// DescStackSize — retail's pwd._stackSize, what
// GameEventApproachVendor.cs:60's SerializeGameDataOnly walks) comes
// back null on the real wire. StackSize=100 (packed "100 for sale"),
// DescStackSize=null (ACE never sets it) is what a real vendor
// listing looks like, NOT the DescStackSize=100 shape used above.
vendor.Apply(
vendorGuid,
new VendorShopProfile(0u, 0u, 0u, false, 1.0f, 1.5f, 0u, 0u, ""),
new[]
{
new VendorShopItem(
arrowsGuid, StackSize: 100, WeenieClassId: 5u, Name: "Arrow",
ItemType: (uint)ItemType.MissileWeapon, IconId: 200u, Value: 100,
DescStackSize: null, PluralName: "Arrows"),
});
// The item is now materialized (ClientObjectTable.Ingest ran inside
// VendorState.Apply's Changed dispatch) BEFORE selection, exactly
// like a real click on VendorUiController's item list.
Assert.NotNull(objects.Get(arrowsGuid));
Assert.Equal(100, objects.Get(arrowsGuid)!.StackSize);
selection.Select(arrowsGuid, SelectionChangeSource.Vendor);
var slider = Assert.IsType<UiScrollbar>(
layout.FindElement(SelectedObjectController.StackSizeSliderId));
Assert.True(slider.Visible);
Assert.Equal(100u, splitQuantity.Maximum);
controller.Dispose();
}
[Fact]
public void C4_VendorOwnedSplitExemptStackSelection_MatchesRetailsToolbarPresentation()
{