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
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:
parent
68568a3a59
commit
d003449bb4
11 changed files with 680 additions and 103 deletions
|
|
@ -88,13 +88,15 @@ public sealed class WorldSelectionQueryTests
|
|||
uint objectDescriptionFlags = 0u,
|
||||
ushort instance = 1,
|
||||
float scale = 1f,
|
||||
Quaternion? rotation = null)
|
||||
Quaternion? rotation = null,
|
||||
float? useRadius = null)
|
||||
{
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, instance) with
|
||||
{
|
||||
ItemType = (uint)type,
|
||||
Useability = useability,
|
||||
ObjectDescriptionFlags = objectDescriptionFlags,
|
||||
UseRadius = useRadius,
|
||||
};
|
||||
Runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = Runtime.MaterializeLiveEntity(
|
||||
|
|
@ -360,6 +362,71 @@ public sealed class WorldSelectionQueryTests
|
|||
Assert.Equal(2f, approach.TargetHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2 gate-finding fix (2026-08-08, grand-gate walk-to-vendor
|
||||
/// regression): retail reads the TARGET's own wire-authored
|
||||
/// <c>PublicWeenieDesc::_useRadius</c> directly for range/approach
|
||||
/// purposes (<c>CPlayerSystem::RegisterObjectRangeHandler</c>,
|
||||
/// <c>pc:195159</c>/<c>203677</c>/<c>210429</c> — <c>203677</c> is
|
||||
/// <c>gmVendorUI::OpenVendor</c>'s own range-handler registration,
|
||||
/// reading <c>eax->pwd._useRadius</c> for a VENDOR target
|
||||
/// specifically). ACE's server-side acceptance test
|
||||
/// (<c>WorldObject_Use.cs:47-55</c>, <c>IsWithinUseRadiusOf</c>) reads
|
||||
/// the SAME wire field, falling back to 0.6f only when the object
|
||||
/// genuinely doesn't author one.
|
||||
/// <para>
|
||||
/// A prior CLIENT-SIDE item-type heuristic (3m for any
|
||||
/// <see cref="ItemType.Creature"/>, 2m for a "large object" flag
|
||||
/// combination) ignored the wire field entirely. For a typical vendor
|
||||
/// NPC (Creature-typed, authoring a much tighter UseRadius than 3m)
|
||||
/// this made the client's own local "arrived" test satisfied well
|
||||
/// outside ACE's real acceptance zone: <see cref="AcDream.Core.Physics.Motion.MoveToManager"/>
|
||||
/// would stop walking and <see cref="SelectionInteractionController.RequestUse"/>
|
||||
/// would dispatch Use the instant this too-loose local threshold was
|
||||
/// crossed, long before the player was ever within ACE's own
|
||||
/// poll-based <c>WithinUseRadius</c> check — reproducing the exact
|
||||
/// "Use lost silently" defect the AP-170/G3 arrival-gate fix was
|
||||
/// meant to close, just from a different angle (a too-loose LOCAL
|
||||
/// arrival threshold, not a missing arrival gate).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApproachUsesTheCreatureTargetsOwnWireAuthoredUseRadius()
|
||||
{
|
||||
var h = new Harness();
|
||||
// A vendor NPC: Creature-typed, wire-authored UseRadius DIFFERENT
|
||||
// from both the old 3m heuristic AND the 0.6f no-wire-value
|
||||
// fallback, so this test actually discriminates "read the wire"
|
||||
// from either the retired heuristic or a coincidental default match.
|
||||
h.Add(Target, new Vector3(2f, 0f, 0f), ItemType.Creature, useRadius: 1.5f);
|
||||
|
||||
Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach));
|
||||
|
||||
Assert.Equal(1.5f, approach.UseRadius);
|
||||
// 2m > 1.5m: genuinely out of range, must arm a walk — not the old
|
||||
// heuristic's false "already close enough" (2m <= 3m).
|
||||
Assert.False(approach.IsCloseRange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Companion to <see cref="ApproachUsesTheCreatureTargetsOwnWireAuthoredUseRadius"/>:
|
||||
/// when the wire genuinely carries no UseRadius (retail's zeroed-struct
|
||||
/// default / ACE's <c>wo.UseRadius == null</c> case), the fallback is
|
||||
/// ACE's own flat 0.6f default (<c>WorldObject_Use.cs:50</c>,
|
||||
/// <c>useRadius ?? 0.6f</c>) — not a Creature-specific guess.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApproachFallsBackToAceDefaultUseRadiusWhenTheWireOmitsIt()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Add(Target, new Vector3(2f, 0f, 0f), ItemType.Creature);
|
||||
|
||||
Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach));
|
||||
|
||||
Assert.Equal(0.6f, approach.UseRadius);
|
||||
Assert.False(approach.IsCloseRange);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickupAndUseabilityPreserveIndependentRetailGates()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -674,7 +674,8 @@ public class SelectedObjectControllerTests
|
|||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// G2 (grand-gate finding): C4 passed by manually hand-setting
|
||||
/// G2 (grand-gate finding), CORRECTED for R1 (2026-08-08, register
|
||||
/// AP-169). 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
|
||||
|
|
@ -685,12 +686,25 @@ public class SelectedObjectControllerTests
|
|||
/// 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.
|
||||
/// <para>
|
||||
/// R1 correction: the G2 fix's own live re-test showed its
|
||||
/// packed-supply-count fallback (this test's ORIGINAL fixture: packed
|
||||
/// <c>StackSize=100</c>, a bounded finite quantity) does not survive a
|
||||
/// STANDARD retail listing, which has UNLIMITED stock (packed
|
||||
/// <c>StackSize=-1</c>) — the fallback produced nothing usable and the
|
||||
/// live retail screenshot that triggered the re-test showed the real
|
||||
/// operand is the item's authored <see cref="VendorShopItem.MaxStackSize"/>
|
||||
/// (1000 for a Prismatic Taper), not any supply count. This fixture now
|
||||
/// matches that exact ACE-realistic shape: unlimited packed supply, no
|
||||
/// <c>DescStackSize</c> (ACE never sets it — see the class doc above),
|
||||
/// <c>MaxStackSize=1000</c> from the wire.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void G2_VendorStackSelection_ThroughRealMaterializer_ShowsSplitSlider()
|
||||
{
|
||||
const uint vendorGuid = 0x70000011u;
|
||||
const uint arrowsGuid = 0x60009011u;
|
||||
const uint taperGuid = 0x60009011u;
|
||||
|
||||
ImportedLayout layout = FixtureLoader.LoadToolbar();
|
||||
var objects = new ClientObjectTable();
|
||||
|
|
@ -730,41 +744,46 @@ public class SelectedObjectControllerTests
|
|||
&& 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
|
||||
// G2 root cause, R1-corrected: 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.
|
||||
// back null on the real wire. A STANDARD listing (unlimited stock,
|
||||
// packed StackSize=-1) is the common case the live re-test actually
|
||||
// hit — matching the retail screenshot's Prismatic Taper, not a
|
||||
// bounded-supply item like the original fixture's "100 arrows".
|
||||
// MaxStackSize=1000 IS reliably populated by ACE (an ordinary
|
||||
// weenie property), which is the field the toolbar ceiling now
|
||||
// resolves through — see VendorSplitPolicy.ResolveAuthoredStackSize.
|
||||
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"),
|
||||
taperGuid, StackSize: -1, WeenieClassId: 5u, Name: "Prismatic Taper",
|
||||
ItemType: (uint)ItemType.SpellComponents, IconId: 200u, Value: 100,
|
||||
DescStackSize: null, MaxStackSize: 1000, PluralName: "Prismatic Tapers"),
|
||||
});
|
||||
|
||||
// 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);
|
||||
Assert.NotNull(objects.Get(taperGuid));
|
||||
Assert.Equal(1000, objects.Get(taperGuid)!.StackSize);
|
||||
|
||||
selection.Select(arrowsGuid, SelectionChangeSource.Vendor);
|
||||
selection.Select(taperGuid, SelectionChangeSource.Vendor);
|
||||
|
||||
var slider = Assert.IsType<UiScrollbar>(
|
||||
layout.FindElement(SelectedObjectController.StackSizeSliderId));
|
||||
Assert.True(slider.Visible);
|
||||
Assert.Equal(100u, splitQuantity.Maximum);
|
||||
Assert.Equal(1000u, splitQuantity.Maximum);
|
||||
|
||||
controller.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,21 +117,28 @@ public sealed class VendorShopItemMaterializerTests
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// G2 (grand-gate finding): a REAL ACE vendor listing carries
|
||||
/// R1 gate-finding fix (2026-08-08, register AP-169 correction,
|
||||
/// replacing the retired <c>Apply_NoDescStackSize_FallsBackToPackedSupplyCount</c>
|
||||
/// G2 test below). A REAL ACE vendor listing for an UNLIMITED-STOCK
|
||||
/// stackable item (e.g. a Prismatic Taper) carries
|
||||
/// <c>DescStackSize=null</c> (ACE's <c>Vendor.LoadInventoryItem</c> never
|
||||
/// calls <c>wo.SetStackSize</c> on the browse-list WorldObject) and only
|
||||
/// the packed <see cref="VendorShopItem.StackSize"/> supply-count field
|
||||
/// (<c>VendorShopCreateListStackSize</c>) names a real quantity. Before
|
||||
/// the fix, <c>ToWeenieData</c> read only <c>DescStackSize</c>, so
|
||||
/// <see cref="ClientObject.StackSize"/> came back 1 for every vendor
|
||||
/// item — the toolbar split slider (which gates on
|
||||
/// <c>stackSize > 1</c>) never appeared for ANY vendor stack. This
|
||||
/// pins the fallback: no <c>DescStackSize</c>, packed
|
||||
/// <c>StackSize=100</c> -> <c>ClientObject.StackSize</c> resolves to
|
||||
/// 100, not 1.
|
||||
/// calls <c>wo.SetStackSize</c> on the browse-list WorldObject) AND the
|
||||
/// packed <see cref="VendorShopItem.StackSize"/> supply-count field at
|
||||
/// its UNLIMITED sentinel (<c>-1</c>) — so neither wire "how many"
|
||||
/// signal names a usable ceiling. The G2 fix's packed-supply-count
|
||||
/// fallback (the ORIGINAL version of this test) produced nothing usable
|
||||
/// for exactly this case — a live re-test confirmed the toolbar split
|
||||
/// bar stayed hidden, matching the live retail screenshot report (see
|
||||
/// AP-169's corrected story). This pins the CORRECT fallback:
|
||||
/// <see cref="VendorShopItem.MaxStackSize"/> — the item TYPE's authored
|
||||
/// stack ceiling, which ACE DOES reliably populate (an ordinary weenie
|
||||
/// property, not an instance-specific "how many for sale" count) — no
|
||||
/// <c>DescStackSize</c>, packed <c>StackSize=-1</c> (unlimited),
|
||||
/// <c>MaxStackSize=1000</c> -> <c>ClientObject.StackSize</c> resolves
|
||||
/// to 1000, not 1 and not the (nonsensical, unbounded) packed field.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_NoDescStackSize_FallsBackToPackedSupplyCount()
|
||||
public void Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize()
|
||||
{
|
||||
var vendor = new VendorState();
|
||||
var objects = new ClientObjectTable();
|
||||
|
|
@ -140,21 +147,27 @@ public sealed class VendorShopItemMaterializerTests
|
|||
vendor.Apply(VendorGuid, default, new[]
|
||||
{
|
||||
new VendorShopItem(
|
||||
ItemA, StackSize: 100, WeenieClassId: 1u, Name: "Arrow",
|
||||
ItemType: (uint)ItemType.MissileWeapon, IconId: 0x1234u, Value: 100,
|
||||
DescStackSize: null),
|
||||
ItemA, StackSize: -1, WeenieClassId: 1u, Name: "Prismatic Taper",
|
||||
ItemType: (uint)ItemType.SpellComponents, IconId: 0x1234u, Value: 100,
|
||||
DescStackSize: null, MaxStackSize: 1000),
|
||||
});
|
||||
|
||||
Assert.Equal(100, objects.Get(ItemA)!.StackSize);
|
||||
ClientObject item = objects.Get(ItemA)!;
|
||||
Assert.Equal(1000, item.StackSize);
|
||||
Assert.Equal(1000, item.StackSizeMax);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// G2 companion: the packed field's -1 "unlimited supply" sentinel has
|
||||
/// no bounded per-row purchase cap in the wire shape today, so it must
|
||||
/// fall through to the safe non-splittable default (1) rather than
|
||||
/// literally propagating -1 (which would read as a huge unsigned
|
||||
/// "stack size" to <see cref="SelectedObjectController"/>'s
|
||||
/// <c>stackSize > 1</c> gate).
|
||||
/// Sabotage-adjacent control: the SAME unlimited-stock listing but with
|
||||
/// <see cref="VendorShopItem.MaxStackSize"/> ALSO absent (neither wire
|
||||
/// "how many" signal usable at all) still degrades to the safe
|
||||
/// non-splittable default (1) rather than literally propagating the
|
||||
/// packed field's <c>-1</c> sentinel (which would read as a huge
|
||||
/// unsigned "stack size" to <c>SelectedObjectController</c>'s
|
||||
/// <c>stackSize > 1</c> gate). Proves
|
||||
/// <see cref="Apply_UnlimitedStockNoDescStackSize_FallsBackToMaxStackSize"/>'s
|
||||
/// 1000 result comes from the MaxStackSize field specifically, not from
|
||||
/// some other code path that would resolve to 1000 regardless.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Apply_UnlimitedSupplySentinel_FallsBackToNonSplittableDefault()
|
||||
|
|
@ -163,7 +176,7 @@ public sealed class VendorShopItemMaterializerTests
|
|||
var objects = new ClientObjectTable();
|
||||
using var materializer = new VendorShopItemMaterializer(vendor, objects);
|
||||
|
||||
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Bread") }); // StackSize: -1, DescStackSize: null
|
||||
vendor.Apply(VendorGuid, default, new[] { Item(ItemA, "Bread") }); // StackSize: -1, DescStackSize: null, MaxStackSize: null
|
||||
|
||||
Assert.Equal(1, objects.Get(ItemA)!.StackSize);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue