fix(vendor): re-gate residuals — MaxStackSize is the stack operand, wire-authored use radius, purse summaries
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

R1 the split bar's operand is the item's authored MaxStackSize —
three retail sites read pwd._maxStackSize directly (InqListSlotCount
pc:200052, buy-button cases pc:203996/204086) where ACE never fills
the desc stack and standard stock is unlimited. Threaded StackSizeMax
end to end with one shared resolver; the two literal _maxStackSize
sites are now byte-exact; AP-165 retired, AP-169 corrected.
R2 walk-to-vendor never opened because GetUseRadius used an UNCITED
3m Creature heuristic as the local stop distance while ACE's poll
demands the authored radius (default 0.6 m) — the walk stopped and
the Use fired far outside acceptance. Now reads the wire-authored
spawn UseRadius with ACE's exact fallback; heuristic constants
deleted. A first sabotage attempt was non-discriminating
(coincidental 0.6) and was corrected — the discriminating version is
what landed.
R3 the Buying/Selling purse summaries ("Buying %d %s worth %hsp" /
"You have %hsp") recovered from the binary data segment where BN
mis-attributes the Buy-side literal; wired to staging and money
changes on the four authored text elements; AP-166 narrowed to the
pending-sell highlight.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 15:23:10 +02:00
parent 68568a3a59
commit d003449bb4
11 changed files with 680 additions and 103 deletions

View file

@ -188,6 +188,11 @@ public sealed class VendorUiControllerTests
public readonly UiButton BuyAllButton;
public readonly UiButton BuyClearItemButton;
public readonly UiButton BuyClearListButton;
// R3: the Buying/Selling tabs' own staged summary text.
public readonly UiText BuyListText = new();
public readonly UiText BuyPurseText = new();
public readonly UiText SellListText = new();
public readonly UiText SellPurseText = new();
// Slice 6c: "Selling" tab staging widgets.
public readonly UiItemList SellingList = new();
public readonly UiButton SellItemButton;
@ -280,11 +285,15 @@ public sealed class VendorUiControllerTests
BuyingPage.AddChild(BuyAllButton);
BuyingPage.AddChild(BuyClearItemButton);
BuyingPage.AddChild(BuyClearListButton);
BuyingPage.AddChild(BuyListText);
BuyingPage.AddChild(BuyPurseText);
SellingPage.AddChild(SellingList);
SellingPage.AddChild(SellItemButton);
SellingPage.AddChild(SellAllButton);
SellingPage.AddChild(SellClearItemButton);
SellingPage.AddChild(SellClearListButton);
SellingPage.AddChild(SellListText);
SellingPage.AddChild(SellPurseText);
var layout = new ImportedLayout(root, new Dictionary<uint, UiElement>
{
@ -312,6 +321,10 @@ public sealed class VendorUiControllerTests
[VendorUiController.SellAllButtonId] = SellAllButton,
[VendorUiController.SellClearItemButtonId] = SellClearItemButton,
[VendorUiController.SellClearListButtonId] = SellClearListButton,
[VendorUiController.BuyingListTextId] = BuyListText,
[VendorUiController.BuyingPurseTextId] = BuyPurseText,
[VendorUiController.SellingListTextId] = SellListText,
[VendorUiController.SellingPurseTextId] = SellPurseText,
});
Window = RetailWindowFrame.Mount(
@ -1199,6 +1212,36 @@ public sealed class VendorUiControllerTests
Assert.Equal(25, h.Buys.Single().Amount);
}
/// <summary>
/// R1 gate-finding fix, task item 5 (2026-08-08): an UNLIMITED-STOCK
/// item with no wire <c>DescStackSize</c> (the real ACE shape for a
/// browse-list row — see <see cref="VendorSplitPolicy.ResolveAuthoredStackSize"/>'s
/// doc comment) but a real <see cref="VendorShopItem.MaxStackSize"/>
/// (1000, matching the live retail screenshot's Prismatic Taper) must
/// still let the player buy an arbitrary quantity up to that ceiling —
/// the slider isn't merely decorative. This harness doesn't mount
/// <c>SelectedObjectController</c> (production's toolbar owns split
/// seeding), so it stands in for "the toolbar showed a ceiling of 1000
/// (MaxStackSize) and the player dragged to 300" by seeding
/// <see cref="Harness.SplitQuantity"/> directly with that SAME ceiling.
/// </summary>
[Fact]
public void BuyButton_Press_UnlimitedStockItemUsingMaxStackSizeCeiling_SendsTheSliderQuantity()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Prismatic Taper", (uint)ItemType.SpellComponents, 300u, 1000,
DescStackSize: null, MaxStackSize: 1000),
});
h.SplitQuantity.Reset(1000u, initialValue: 300u);
h.BuyButton.OnClick!.Invoke();
Assert.Equal(300, h.Buys.Single().Amount);
}
[Fact]
public void BuyButton_Press_NonStackedItem_IgnoresAStaleSliderFromAPreviouslySelectedStackableItem()
{
@ -1443,7 +1486,7 @@ public sealed class VendorUiControllerTests
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
DescStackSize: 100, MaxStackSize: 100),
});
h.SplitQuantity.Reset(5000u, initialValue: 4990u);
h.AddButton.OnClick!.Invoke();
@ -2477,4 +2520,109 @@ public sealed class VendorUiControllerTests
Assert.Equal(1, h.BuyingList.GetNumUIItems());
}
// ══════════════════════════════════════════════════════════════════════
// R3 — the Buying/Selling tabs' own staged-count/total-value/purse text
// (grand-gate live evidence: retail's Buying tab shows "Buying 2 items
// worth 422p" / "You have 23p"; the strings are recovered byte-verbatim
// — see VendorUiController.BuildTransactionListText/BuildPurseText).
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void BuyingTabSummaryText_TracksStagedCountValueAndPlayerPurse()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
// Before staging: zero staged count/value, still grammatically
// plural ("0 items"), purse text is live regardless of staging.
Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText));
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
h.AddButton.OnClick!.Invoke();
// ComputeBuyTransactionValue: sellRate 1.5 * value 500 * quantity 1
// = 750, ceil(750 - 0.1) = 750.
Assert.Equal("Buying 1 item worth 750p", GetText(h.BuyListText));
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
h.BuyClearListButton.OnClick!.Invoke();
Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText));
}
/// <summary>
/// R3: a money change with no staging change at all must still repaint
/// the purse line — retail's own purse text always reads the LIVE
/// holding, with no "did staging change" gate (see
/// <c>VendorUiController.OnObjectMoneyChanged</c>'s doc comment).
/// </summary>
[Fact]
public void BuyingTabPurseText_UpdatesOnAPlayerMoneyChangeAloneWithNoStagingChange()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.CoinValue] = 42;
h.Objects.UpsertProperties(Harness.PlayerGuid, bundle);
Assert.Equal("You have 42p", GetText(h.BuyPurseText));
// Selling's purse text shares the SAME live holding read.
Assert.Equal("You have 42p", GetText(h.SellPurseText));
}
[Fact]
public void SellingTabSummaryText_TracksStagedCountValueAndPlayerPurse()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText));
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText));
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
// ComputeSellTransactionValue: SellProfile's BuyPrice 1.0 * value
// 100 * quantity 1 = 100, floor(100 + 0.1) = 100.
Assert.Equal("Selling 1 item worth 100p", GetText(h.SellListText));
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText));
h.SellClearListButton.OnClick!.Invoke();
Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText));
}
/// <summary>
/// Two staged units of the SAME stackable row: the count is the STAGED
/// QUANTITY total (2), not the row count (1) — matching the live
/// evidence screenshot's "Buying 2 items worth 422p" exactly (a single
/// stacked row, quantity 2).
/// </summary>
[Fact]
public void BuyingTabSummaryText_CountsStagedQuantityNotRowCountForAStackedItem()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Prismatic Taper", (uint)ItemType.SpellComponents, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(100u, initialValue: 2u);
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
// ComputeBuyTransactionValue: perUnit = value 1000 / DescStackSize
// 100 = 10; sellRate 1.5 * perUnit 10 * quantity 2 = 30,
// ceil(30 - 0.1) = 30.
Assert.Equal("Buying 2 items worth 30p", GetText(h.BuyListText));
}
}