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
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:
parent
92ea3977b6
commit
c68ad1e646
11 changed files with 1314 additions and 99 deletions
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 >> 16)) & 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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue