fix(vendor): 6b/6c review corrections — pre-send guards, accumulating staging, trade-note exemption, drag-over tab switch, full-stack sells
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

All thirteen findings, each anchored in recovered bytes or pc reads:

Buy All now runs retail's four PRE-SEND guards in order (pyreal and
alt-currency affordability, container and item slot capacity; strings
recovered from .rdata at 0x007b57b4/0x007b5750) — a rejected batch can
no longer destroy the staged list. Staged adds ACCUMULATE with the
5000 cap ("I can't possibly sell you that much!..." @0x007b59d8) and
the shop rows decrement/restore per RemoveFromShop. The max-value sell
rejection exempts trade notes — the raw bytes at 0x005d1add are `not`
(bitwise), not the pseudo-C's misleading `!`, and the early ret skips
the min check too. BF_RETAINED gates selling end to end (the bit was
already on ClientObject; AP-164's three claims were all false once
traced — RETIRED). Dragging over the vendor window auto-opens the
Selling tab per UpdateDragOver — with a correction to the review's own
citation: token 0x100000cd is the SELLING page, the guard is
"don't reopen the current tab." Sells are full-stack-only (three
retail sites; "Cannot sell part of a stack" @0x007b57ec) and Sell Item
acts on the global selection unconditionally. The confirm string gains
its byte-true trailing '?', dies with the session, staged-row
highlights repaint, dead guids unstage with retail's shopping-list
notice, and move-to-use no longer walks to targets the dispatch would
refuse.

AP-162 narrowed, AP-164 retired, AP-167/AP-168 filed honest.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-08 12:50:28 +02:00
parent 92ea3977b6
commit c68ad1e646
11 changed files with 1314 additions and 99 deletions

View file

@ -411,6 +411,31 @@ public sealed class SelectionInteractionControllerTests
Assert.Empty(h.Transport.Uses);
}
/// <summary>
/// F11 (Slice 6b/6c review): a FAR target the eligibility gate will
/// refuse (not owned by the player, not useable) must never kick off a
/// speculative local approach at all — the prior <c>RequestUse</c>
/// ordering called <c>BeginApproach</c> before the eligibility check,
/// so an out-of-range unusable target still walked the player toward
/// it even though the dispatch immediately below was always going to
/// reject. The existing <see cref="RejectedWorldUseReleasesItsBusyReservation"/>
/// test never configured an approach at all (<c>TryGetApproach</c>
/// returns false unconditionally), so it could not have caught this —
/// this test explicitly combines "far" with "rejected."
/// </summary>
[Fact]
public void FarUnusableTargetIsRejectedWithoutApproaching()
{
var h = new Harness();
h.Query.Useable = false;
h.SetApproach(closeRange: false);
h.Controller.SendUse(Target);
Assert.Empty(h.Movement.Approaches);
Assert.Empty(h.Transport.Uses);
}
[Fact]
public void SynchronousMovementCallbackCannotDuplicateWorldUse()
{

View file

@ -207,6 +207,21 @@ public sealed class VendorUiControllerTests
public Harness()
{
// F1 (Slice 6b/6c review): a realistic player pack shape (102
// main-pack item slots, 7 side-pack/container slots — the
// conventional retail AC main-pack capacity) so the NEW Buy All
// capacity pre-checks have real room to work with; a stub
// ClientObject's default ItemsCapacity/ContainersCapacity=0
// would reject every Buy All. AddOrUpdate FIRST, then
// UpsertProperties SECOND — UpsertProperties mutates an
// EXISTING entry in place rather than replacing it wholesale.
Objects.AddOrUpdate(new ClientObject
{
ObjectId = PlayerGuid,
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
var bundle = new PropertyBundle();
bundle.Ints[(uint)PropertyInt.CoinValue] = DefaultPlayerCoinValue;
Objects.UpsertProperties(PlayerGuid, bundle);
@ -1385,6 +1400,116 @@ public sealed class VendorUiControllerTests
Assert.Equal(0, h.BuyingList.GetNumUIItems());
}
/// <summary>
/// F2 (Slice 6b/6c review, byte-verified <c>pc:202934</c>): re-adding an
/// already-staged item ACCUMULATES onto the existing staged quantity —
/// a prior version of this port upserted/overwrote. Observed through a
/// subsequent Buy All send (the one path that reads
/// <see cref="VendorStagingEntry.Quantity"/> directly, unlike Buy
/// Item's own live-slider read).
/// </summary>
[Fact]
public void AddToBuyList_ReAddingTheSameStackableItem_AccumulatesTheStagedQuantity()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(100u, initialValue: 10u);
h.AddButton.OnClick!.Invoke();
h.SplitQuantity.SetValue(15u);
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems()); // one row, not two
h.BuyAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (25, StackedItemGuid) }, items);
}
/// <summary>
/// F2: retail's 5000-unit cap (<c>VendorStagingList.MaxStagedQuantity</c>)
/// on an ACCUMULATE rejects with its own notice and leaves the entry
/// unchanged.
/// </summary>
[Fact]
public void AddToBuyList_AccumulatingPastTheCap_ShowsRetailsNoticeAndLeavesStagingUnchanged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(5000u, initialValue: 4990u);
h.AddButton.OnClick!.Invoke();
h.SplitQuantity.Reset(5000u, initialValue: 20u);
h.AddButton.OnClick!.Invoke();
Assert.Equal(new[] { VendorStagingList.TooMuchMessage }, h.SystemMessages);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
// Boost the player's coin total so a follow-up Buy All send is only
// gated by staging content, not F1's affordability guard, proving
// the rejected accumulate above left the entry at its PRE-add value.
h.Objects.Get(Harness.PlayerGuid)!.Properties.Ints[(uint)PropertyInt.CoinValue] = 10_000_000;
h.BuyAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
Assert.Equal(4990, items.Single().Amount);
}
/// <summary>
/// F2 (Slice 6b/6c review): port of <c>VendorItemsUI::RemoveFromShop</c>
/// (<c>0x004c3ce0</c>) — staging the vendor's ENTIRE limited supply of an
/// item hides its Items-tab row (matching retail's <c>DeleteItem</c>),
/// and un-staging restores it — <see cref="AvailableShopQuantity"/> is
/// recomputed fresh from the untouched snapshot each time, so no
/// separate "restore" code path is needed.
/// </summary>
[Fact]
public void StagingConsumesLimitedShopSupply_HidingTheRowWhenExhaustedAndRestoringItWhenUnstaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, 2, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
});
Assert.Equal(1, h.ItemList.GetNumUIItems());
h.AddButton.OnClick!.Invoke(); // stages 1 of 2 available
Assert.Equal(1, h.ItemList.GetNumUIItems()); // still visible — 1 remains
h.AddButton.OnClick!.Invoke(); // stages 2 of 2 available — exhausted
Assert.Equal(0, h.ItemList.GetNumUIItems()); // row hidden
Assert.Null(h.Selection.SelectedObjectId); // selection cleared, matching RemoveFromShop's own SetSelectedObject(0,0)
h.BuyClearListButton.OnClick!.Invoke(); // un-stage everything
Assert.Equal(1, h.ItemList.GetNumUIItems()); // row restored
Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId);
}
/// <summary>F2: retail's <c>var_c != 0xffffffff</c> guard — unlimited stock (StackSize == -1) is never hidden.</summary>
[Fact]
public void UnlimitedSupplyShopItem_IsNeverHiddenNoMatterHowMuchIsStaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100),
});
for (int i = 0; i < 5; i++)
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.ItemList.GetNumUIItems());
}
[Fact]
public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess()
{
@ -1427,6 +1552,90 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.BuyAlls);
}
// ══════════════════════════════════════════════════════════════════════
// F1 (Slice 6b/6c review) — Buy All's four client-side pre-send guards.
// Each blocking guard leaves staging fully intact; the existing
// BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess
// test above is the "all four pass" case (it already exercises the
// Harness's 102 item / 7 container capacity headroom).
// ══════════════════════════════════════════════════════════════════════
[Fact]
public void BuyAllButton_InsufficientPyrealFunds_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
// Value=2000, no DescStackSize -> perUnit=2000; SellPrice(2000, Armor,
// 1.5, 1) = ceil(3000 - 0.1) = 3000, well above the Harness's 1500 coin default.
h.State.Apply(VendorGuid, Profile(sellRate: 1.5f), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2000),
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientAltCurrency_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(
VendorGuid,
Profile(altCurrency: 0x12345678u, altName: "Trade Notes", altAmount: 10u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You don't have enough money" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientContainerSlots_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Birch Backpack", (uint)ItemType.Container, 200u, 100),
});
h.AddButton.OnClick!.Invoke();
h.Objects.Get(Harness.PlayerGuid)!.ContainersCapacity = 0;
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
}
[Fact]
public void BuyAllButton_InsufficientItemSlots_BlocksWithRetailsNoticeAndStagingIntact()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
h.Objects.Get(Harness.PlayerGuid)!.ItemsCapacity = 0;
h.BuyAllButton.OnClick!.Invoke();
Assert.Empty(h.BuyAlls);
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "You must empty some slots in your backpack first" }, h.SystemMessages);
}
[Fact]
public void BuyItemButton_BuysTheSelectedStagedItemAndRemovesItFromStagingOnSuccess()
{
@ -1483,6 +1692,39 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.Buys);
}
/// <summary>
/// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must
/// visibly move the highlight — a prior version of this port only
/// repainted the Buying/Selling strips when their OWN staging list
/// changed, so a pure selection change (clicking a DIFFERENT already-
/// staged row, no staging mutation) left both rows' <c>Selected</c>
/// flags stale.
/// </summary>
[Fact]
public void SelectingADifferentStagedBuyingRow_RepaintsBothRowsHighlight()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Helm", (uint)ItemType.Armor, 200u, 150),
});
h.ItemList.GetItem(0)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
h.ItemList.GetItem(1)!.Clicked?.Invoke();
h.AddButton.OnClick!.Invoke();
Assert.Equal(2, h.BuyingList.GetNumUIItems());
// AnotherArmorItemGuid is currently selected+highlighted (last staged).
UiItemSlot firstRow = h.BuyingList.GetItem(0)!;
UiItemSlot secondRow = h.BuyingList.GetItem(1)!;
firstRow.Clicked?.Invoke(); // a pure selection change — no staging mutation
Assert.Equal(firstRow.ItemId, h.Selection.SelectedObjectId);
Assert.True(firstRow.Selected);
Assert.False(secondRow.Selected);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6c — Selling tab drag-to-sell staging
// ══════════════════════════════════════════════════════════════════════
@ -1546,6 +1788,82 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.SystemMessages);
}
/// <summary>
/// F5 (Slice 6b/6c review): every drag test above (and
/// <see cref="HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt"/>
/// below) calls <c>OnDragOver</c>/<c>HandleDropRelease</c> DIRECTLY,
/// bypassing <see cref="UiRoot"/>'s real pointer pipeline entirely — the
/// review flagged that this can never exercise the NEW auto-switch
/// behavior (<c>PollDragOver</c>), which reacts to <see cref="UiRoot.DragSource"/>/
/// <see cref="UiRoot.MouseX"/>/<see cref="UiRoot.MouseY"/>, not a direct
/// method call. This test drives the WHOLE thing through
/// <see cref="UiRoot.OnMouseDown"/>/<see cref="UiRoot.OnMouseMove"/>/
/// <see cref="UiRoot.Tick"/>/<see cref="UiRoot.OnMouseUp"/>: lift a drag
/// from an (unrelated) source cell, hover it over the vendor window
/// while the Items tab is still open, let the periodic global-UI-time
/// pulse (retail's message 3) auto-switch to Selling, then release
/// ONTO the now-visible Selling list's real hit-test geometry.
/// </summary>
[Fact]
public void DragOverTheVendorWindow_ThroughTheRealPointerPipeline_AutoSwitchesToSellingAndStages()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
// The Selling list needs real screen geometry (and at least one
// empty-slot placeholder cell) to be hit-testable — the hand-built
// harness never runs a real DAT-driven layout pass, so this test
// gives it one explicitly rather than relying on production sizing.
// Exactly ONE cell's worth of width so the post-drop repaint has no
// room left for an extra empty-slot placeholder alongside the real
// staged item.
h.SellingList.Width = 32f;
h.SellingList.Height = 32f;
using (h.SellingList.DeferLayout()) { }
// A standalone drag SOURCE cell standing in for "the player's own
// inventory panel" (not modeled by this harness) — positioned well
// outside the vendor window (which occupies roughly (0,0)-(800,110)
// here) so it cannot be confused with any vendor widget.
var sourceCell = new UiItemSlot { Left = 700f, Top = 550f, Width = 32f, Height = 32f };
sourceCell.SetItem(PlayerOwnedArmorGuid, 0u);
h.Screen.AddChild(sourceCell);
Assert.True(h.ItemsPage.Visible);
Assert.False(h.SellingPage.Visible);
// Press on the source cell, then move past the 3px promotion
// threshold to somewhere INSIDE the vendor window (but not
// specifically over the Selling list) while the Items tab is still
// showing — mirrors a player dragging toward "the vendor" in
// general before the panel has switched tabs for them.
h.Screen.OnMouseDown(UiMouseButton.Left, 710, 560);
h.Screen.OnMouseMove(400, 50);
Assert.Same(sourceCell, h.Screen.DragSource);
Assert.False(h.SellingPage.Visible);
// F5: the auto-switch happens on the periodic global-UI-time pulse,
// not on the mouse-move itself — retail polls this from
// UpdateDragOver via UI message 3.
h.Screen.Tick(0.016, 1L);
Assert.True(h.SellingPage.Visible);
Assert.False(h.ItemsPage.Visible);
// Move onto the now-visible Selling list's real geometry and
// release — UiRoot's own hit-test resolves the drop, not a direct
// HandleDropRelease call.
h.Screen.OnMouseMove(20, 15);
h.Screen.OnMouseUp(UiMouseButton.Left, 20, 15);
Assert.Null(h.Screen.DragSource);
Assert.Equal(1, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
Assert.Empty(h.SystemMessages);
}
[Fact]
public void HandleDropRelease_AcceptableItem_StagesItSwitchesToSellingTabAndSelectsIt()
{
@ -1564,6 +1882,32 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.SystemMessages);
}
/// <summary>
/// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records
/// the item's FULL stack — retail's <c>AddItemToSell</c> stages via a
/// LITERAL <c>-1</c> "full stack" argument
/// (<c>gmVendorUI::AddItem(..., -1, ...)</c>, <c>pc:203595</c>), never a
/// slider read. A prior version of this port read the LIVE split
/// slider here instead — this proves a PARTIAL slider selection at
/// drop time does not leak into the staged (or sent) quantity.
/// </summary>
[Fact]
public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
h.SellAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items);
}
[Fact]
public void HandleDropRelease_WrongTargetList_IsIgnored()
{
@ -1631,6 +1975,35 @@ public sealed class VendorUiControllerTests
Assert.Equal(new[] { "You can only sell items you are carrying" }, h.SystemMessages);
}
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c> is now checked end to
/// end — an item whose TYPE matches the vendor's merchandise mask is
/// still rejected (as the generic <c>WrongType</c> message, matching
/// retail's OR'd branch) when its bitfield carries the bit.
/// </summary>
[Fact]
public void HandleDropRelease_RetainedItem_RejectsEvenWhenTheTypeMaskMatches()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = PlayerOwnedArmorGuid,
Name = "Heirloom Chainmail",
Type = ItemType.Armor,
Value = 100,
StackSize = 1,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Retained,
});
h.Objects.MoveItem(PlayerOwnedArmorGuid, Harness.PlayerGuid, 0);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(new[] { "You cannot sell that here" }, h.SystemMessages);
}
[Fact]
public void SellAllButton_SendsOneBatchedSellForEveryStagedEntryAndClearsStagingOnSuccess()
{
@ -1680,6 +2053,71 @@ public sealed class VendorUiControllerTests
Assert.Equal(0, h.SellingList.GetNumUIItems());
}
/// <summary>
/// F13 (Slice 6b/6c review): retail's Sell Item reads
/// <c>ACCWeenieObject::selectedID</c> UNCONDITIONALLY — there is no
/// "must be staged first" requirement. A prior version of this port
/// required a matching <c>_sellStaging</c> entry, which this test would
/// have failed against (no drop/staging happens here at all — only a
/// direct global selection).
/// </summary>
[Fact]
public void SellItemButton_ActsOnTheGlobalSelectionEvenWhenNeverStaged()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Selection.Select(PlayerOwnedArmorGuid, SelectionChangeSource.Vendor);
Assert.Equal(0, h.SellingList.GetNumUIItems()); // never staged/dropped
h.SellItemButton.OnClick!.Invoke();
(uint vendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(VendorGuid, vendorGuid);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedArmorGuid) }, items);
}
/// <summary>
/// F6 (Slice 6b/6c review, byte-verified <c>pc:201833-201864</c>):
/// <c>SellSingleItem</c> refuses a stackable item whose split slider is
/// not showing the FULL stack — shows the exact retail notice and sends
/// nothing.
/// </summary>
[Fact]
public void SellItemButton_PartialStackSelected_RefusesWithRetailsNoticeAndSendsNothing()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 5u); // a PARTIAL amount, not the full stack of 20
h.SellItemButton.OnClick!.Invoke();
Assert.Empty(h.Sells);
Assert.Equal(new[] { "Cannot sell part of a stack" }, h.SystemMessages);
}
/// <summary>
/// F6: once the slider shows the FULL stack, Sell Item proceeds and
/// sends amount <c>1</c> LITERALLY — not the 20-unit stack size —
/// matching retail's own literal <c>var_9c = 1</c> send.
/// </summary>
[Fact]
public void SellItemButton_FullStackSelected_SendsLiteralAmountOneNotTheStackSize()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 20u); // the FULL stack
h.SellItemButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (1, PlayerOwnedWeaponGuid) }, items);
}
[Fact]
public void SellClearItemButton_RemovesOnlyTheSelectedStagedEntryWithoutSelling()
{
@ -1713,6 +2151,65 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.Sells);
}
// ══════════════════════════════════════════════════════════════════════
// F10 (Slice 6b/6c review) — unstage on removal/dispossession.
// ══════════════════════════════════════════════════════════════════════
/// <summary>
/// Sell side — retail's <c>RecvNotice_ServerSaysMoveItem</c>
/// (<c>0x004c44a0</c>) silently unstages a staged sell item once it's
/// no longer trackable/owned by the player; this port's closest
/// reachable analogue is the item leaving <c>ClientObjectTable</c>
/// entirely.
/// </summary>
[Fact]
public void RemovingAStagedSellItemFromClientObjectTable_SilentlyUnstagesIt()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.Objects.Remove(PlayerOwnedArmorGuid);
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Empty(h.SystemMessages); // silent — matches retail's own site
}
/// <summary>
/// Buy side — a staged shop item that drops out of the vendor's
/// CURRENT stock (retired from <c>ClientObjectTable</c> — production's
/// <c>VendorShopItemMaterializer</c> does this on every Refreshed
/// transition that drops a guid; this hand-built harness doesn't mount
/// that class, so the test performs the SAME removal directly) unstages
/// with retail's exact "Removing %s from shopping list" notice
/// (<c>0x004c4246</c>).
/// </summary>
[Fact]
public void ShopItemLeavingClientObjectTable_UnstagesTheBuyEntryWithRetailsNotice()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = ArmorItemGuid,
Name = "Chainmail",
Type = ItemType.Armor,
ContainerId = VendorGuid,
});
h.AddButton.OnClick!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
h.Objects.Remove(ArmorItemGuid);
Assert.Equal(0, h.BuyingList.GetNumUIItems());
Assert.Equal(new[] { "Removing Chainmail from shopping list" }, h.SystemMessages);
}
// ══════════════════════════════════════════════════════════════════════
// Slice 6b/6c — X-close staging confirmation + session-boundary clears
// ══════════════════════════════════════════════════════════════════════
@ -1749,9 +2246,11 @@ public sealed class VendorUiControllerTests
Assert.True(h.Dialogs.IsOpen);
Assert.NotNull(h.ShownDialog);
// Exact retail string, read from the decompiled binary's data
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment.
// segment at 0x007b5bd8 — see CloseButtonPressed's doc comment. F7
// (Slice 6b/6c review): the raw bytes right after the declared
// string length are UTF-16LE for '?' before the null terminator.
Assert.Equal(
"You have not completed all transactions. Are you sure you want to leave this vendor",
"You have not completed all transactions. Are you sure you want to leave this vendor?",
string.Join(" ", Assert.IsType<UiText>(h.ShownDialog!.FindElement(
RetailConfirmationDialogView.MessageElementId)).LinesProvider().Select(static line => line.Text)));
}

View file

@ -136,6 +136,83 @@ public sealed class VendorSellAcceptabilityTests
Assert.Equal(VendorSellRejection.None, rejection);
}
/// <summary>
/// F3 (Slice 6b/6c review, byte-verified at 0x005d1add): a trade note
/// above the vendor's max value is EXEMPT — the actual x86 is
/// <c>(~(itemTypeMask &gt;&gt; 16)) &amp; 4</c>, and PromissoryNote's bit
/// (0x00040000) lands exactly on that mask, zeroing the result.
/// </summary>
[Fact]
public void PromissoryNoteAboveMaxValueIsExemptFromTooValuable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: (uint)ItemType.PromissoryNote,
perUnitValue: 999_999,
merchandiseItemTypes: (uint)ItemType.PromissoryNote,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.None, rejection);
}
/// <summary>An ordinary (non-note) item above max value is still rejected — the exemption is note-specific.</summary>
[Fact]
public void NonPromissoryNoteAboveMaxValueIsStillTooValuable()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 999_999,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: 1000u);
Assert.Equal(VendorSellRejection.TooValuable, rejection);
}
/// <summary>
/// F4 (Slice 6b/6c review): <c>BF_RETAINED</c>
/// (<see cref="PublicWeenieFlags.Retained"/>, 0x01000000) folds into
/// the SAME <see cref="VendorSellRejection.WrongType"/> outcome as a
/// genuine type mismatch — retail's InqAcceptability ORs the two
/// conditions together (pc:005d1aa7).
/// </summary>
[Fact]
public void RetainedItemIsRejectedAsWrongTypeEvenWhenTheTypeMaskMatches()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit,
publicWeenieBitfield: (uint)PublicWeenieFlags.Retained);
Assert.Equal(VendorSellRejection.WrongType, rejection);
}
/// <summary>An item with OTHER bitfield bits set (not Retained) is unaffected.</summary>
[Fact]
public void NonRetainedBitfieldDoesNotAffectAcceptability()
{
VendorSellRejection rejection = VendorSellAcceptability.Evaluate(
ownedByPlayer: true,
containedItemCount: 0,
itemTypeMask: Armor,
perUnitValue: 100,
merchandiseItemTypes: Armor,
merchandiseMinValue: 0u,
merchandiseMaxValue: NoLimit,
publicWeenieBitfield: (uint)PublicWeenieFlags.Stuck);
Assert.Equal(VendorSellRejection.None, rejection);
}
[Fact]
public void AnOrdinaryAcceptableItemReturnsNone()
{

View file

@ -26,8 +26,14 @@ public sealed class VendorStagingListTests
Assert.False(list.IsEmpty);
}
/// <summary>
/// F2 (Slice 6b/6c review, byte-verified <c>pc:202934</c>): re-adding an
/// already-staged guid ACCUMULATES onto the existing quantity — a prior
/// version of this port upserted/overwrote instead, which this test
/// used to assert (20, not 25).
/// </summary>
[Fact]
public void AddingTheSameGuidTwiceUpsertsRatherThanDuplicating()
public void AddingTheSameGuidTwiceAccumulatesRatherThanDuplicatingOrOverwriting()
{
var list = new VendorStagingList();
@ -35,7 +41,64 @@ public sealed class VendorStagingListTests
list.Add(ItemA, 20);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(20, entry.Quantity);
Assert.Equal(25, entry.Quantity);
}
[Fact]
public void AddReturnsAddedOnASuccessfulStage()
{
var list = new VendorStagingList();
Assert.Equal(VendorStagingAddOutcome.Added, list.Add(ItemA, 5));
Assert.Equal(VendorStagingAddOutcome.Added, list.Add(ItemA, 5));
}
/// <summary>
/// F2: retail's 5000-unit cap (<c>0x1388</c>) guards the ACCUMULATE
/// path only — exceeding it on a re-add rejects and leaves the entry
/// COMPLETELY UNCHANGED (<c>pc:202936-202951</c>).
/// </summary>
[Fact]
public void AddAccumulatingPastTheCapIsRejectedAndLeavesTheEntryUnchanged()
{
var list = new VendorStagingList();
list.Add(ItemA, VendorStagingList.MaxStagedQuantity - 10);
VendorStagingAddOutcome outcome = list.Add(ItemA, 11);
Assert.Equal(VendorStagingAddOutcome.Capped, outcome);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(VendorStagingList.MaxStagedQuantity - 10, entry.Quantity);
}
[Fact]
public void AddAccumulatingExactlyToTheCapSucceeds()
{
var list = new VendorStagingList();
list.Add(ItemA, VendorStagingList.MaxStagedQuantity - 10);
VendorStagingAddOutcome outcome = list.Add(ItemA, 10);
Assert.Equal(VendorStagingAddOutcome.Added, outcome);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(VendorStagingList.MaxStagedQuantity, entry.Quantity);
}
/// <summary>
/// F2: a BRAND-NEW entry has no cap in retail's own <c>AddToBuyList</c>
/// — the cap check lives only inside the "found an existing match"
/// branch (<c>label_4c3e20</c>'s insert has none, <c>pc:202895-202923</c>).
/// </summary>
[Fact]
public void AddOfABrandNewEntryHasNoCapEvenAboveTheThreshold()
{
var list = new VendorStagingList();
VendorStagingAddOutcome outcome = list.Add(ItemA, VendorStagingList.MaxStagedQuantity + 500);
Assert.Equal(VendorStagingAddOutcome.Added, outcome);
VendorStagingEntry entry = Assert.Single(list.Entries);
Assert.Equal(VendorStagingList.MaxStagedQuantity + 500, entry.Quantity);
}
[Theory]
@ -46,8 +109,9 @@ public sealed class VendorStagingListTests
{
var list = new VendorStagingList();
list.Add(guid, quantity);
VendorStagingAddOutcome outcome = list.Add(guid, quantity);
Assert.Equal(VendorStagingAddOutcome.Ignored, outcome);
Assert.True(list.IsEmpty);
}