diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index 58f90537..c2a7e6e0 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -619,7 +619,7 @@ private void RebuildItemList(bool reselectFirst) foreach (VendorShopItem item in items) { if (((item.ItemType ?? 0u) & maskValue) == 0u) continue; - if (AvailableShopQuantity(item) <= 0) continue; + if (IsShopSupplyConsumedByStaging(item)) continue; firstItem ??= item; if (item.ItemGuid == selectedGuid) selectedStillVisible = true; @@ -670,12 +670,23 @@ private void RebuildItemList(bool reselectFirst) } } - private int AvailableShopQuantity(VendorShopItem item) + /// + /// True when staging has used up a limited shop row, which takes it out of + /// the browse list. A row that is not staged is always listed, whatever + /// amount the vendor sent, 0 included. A staged row with a limited amount + /// is removed once the listed amount minus the total staged is 0 or less, + /// so a row listed with 0 leaves the list as soon as anything is staged. + /// Only means the vendor + /// never runs out. The remainder is recomputed from the untouched listing + /// on every call, so un-staging puts the row back without a restore path. + /// + private bool IsShopSupplyConsumedByStaging(VendorShopItem item) { - if (item.StackSize < 0) - return int.MaxValue; - int staged = _buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry) ? entry.Quantity : 0; - return item.StackSize - staged; + if (item.StackSize == VendorComponentFill.UnlimitedStock) + return false; + if (!_buyStaging.TryGet(item.ItemGuid, out VendorStagingEntry entry)) + return false; + return item.StackSize - entry.Quantity <= 0; } private void RefreshItemsTabAvailability() => RebuildItemList(reselectFirst: false); diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index acfce6d7..b38e7938 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -1333,6 +1333,48 @@ public void UnlimitedSupplyShopItem_IsNeverHiddenNoMatterHowMuchIsStaged() Assert.Equal(1, h.ItemList.GetNumUIItems()); } + [Fact] + public void ZeroStockShopItem_IsListedWhenTheVendorOpens() + { + // Servers list many rows with an amount of 0. Every listed row is + // shown at open; only staging can take a row out of the list. + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, 0, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100), + new VendorShopItem(AnotherArmorItemGuid, -1, 4u, "Buckler", (uint)ItemType.Armor, 900u, 40), + }); + + Assert.Equal(2, h.ItemList.GetNumUIItems()); + Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); + Assert.Equal(ArmorItemGuid, h.Selection.SelectedObjectId); + } + + [Fact] + public void StagingAZeroStockShopItem_StagesItAndTakesTheRowOutOfTheList() + { + // A listed amount of 0 is a limited amount: staging is not refused, + // and the first unit staged leaves 0 - 1 remaining, which removes the + // row and clears the selection, exactly as exhausting any limited row. + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem(ArmorItemGuid, 0, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 100), + }); + + h.AddButton.OnClick!.Invoke(); + + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(ArmorItemGuid, h.BuyingList.GetItem(0)!.ItemId); + Assert.Equal(0, h.ItemList.GetNumUIItems()); + Assert.Null(h.Selection.SelectedObjectId); + + h.BuyClearListButton.OnClick!.Invoke(); + + Assert.Equal(1, h.ItemList.GetNumUIItems()); + Assert.Equal(ArmorItemGuid, h.ItemList.GetItem(0)!.ItemId); + } + [Fact] public void BuyAllButton_SendsOneBatchedBuyForEveryStagedEntryAndClearsStagingOnSuccess() {