diff --git a/pom.xml b/pom.xml index 5cf6a4c..9f5e6f9 100644 --- a/pom.xml +++ b/pom.xml @@ -14,8 +14,8 @@ full 0.2.1 - 0.2.0 - 2.0.0 + 0.3.0 + 2.4.0 @@ -92,6 +92,13 @@ 5.14.0 test + + + org.xerial + sqlite-jdbc + 3.49.1.0 + test + diff --git a/src/main/java/net/tfminecraft/marketblock/manager/TradeManager.java b/src/main/java/net/tfminecraft/marketblock/manager/TradeManager.java index 1ec13a7..2f35e76 100644 --- a/src/main/java/net/tfminecraft/marketblock/manager/TradeManager.java +++ b/src/main/java/net/tfminecraft/marketblock/manager/TradeManager.java @@ -1,5 +1,9 @@ package net.tfminecraft.marketblock.manager; +import java.util.Optional; +import java.util.Map; +import java.util.WeakHashMap; + import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -19,22 +23,26 @@ import net.tfminecraft.marketblock.Cache; import net.tfminecraft.marketblock.MarketBlock; -import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Result; import net.tfminecraft.marketblock.events.MarketSaleEvent; import net.tfminecraft.marketblock.inventory.holder.MBGUI; import net.tfminecraft.marketblock.inventory.holder.MBHolder; import net.tfminecraft.marketblock.loader.CategoryLoader; import net.tfminecraft.marketblock.loader.TradeLoader; import net.tfminecraft.marketblock.trade.Category; +import net.tfminecraft.marketblock.trade.MarketSale; import net.tfminecraft.marketblock.trade.Trade; import net.tfminecraft.marketblock.util.DemandSchedule; import net.tfminecraft.marketblock.util.InventoryUtils; import net.tfminecraft.marketblock.util.PriceCalculator; import net.tfminecraft.marketblock.util.SaleTake; +import net.tfminecraft.tlibs.character.CharacterRef; public class TradeManager implements Listener { InventoryManager inv = new InventoryManager(); private BukkitTask demandTask; + private final Map handledSales = new WeakHashMap<>(); public void update() { for(Player p : Bukkit.getOnlinePlayers()) { @@ -131,6 +139,7 @@ public void tradeClick(Player p, MBHolder h, ItemStack i, InventoryClickEvent e) return; } + if (e != null && handledSales.put(e, true) != null) return; String id = getItemId(h.getType(), i); if (id == null) return; @@ -147,16 +156,36 @@ public void tradeClick(Player p, MBHolder h, ItemStack i, InventoryClickEvent e) p.sendMessage("§cYou don't have enough items for this trade."); return; } - SaleTake take = InventoryUtils.removeItems(p, tradePath, requiredAmount); - if (take.total() == 0) { + Optional seller = Denars.active(p); + if (seller.isEmpty()) { + p.sendMessage("§cChoose a character before you sell."); + return; + } + SaleTake take = InventoryUtils.planTake(p, tradePath, requiredAmount); + if (take.total() != requiredAmount) { p.sendMessage("§cThe market could not take the items for this trade."); return; } - p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f); double base = PriceCalculator.calculatePrice(trade); double price = applyFreshness(base, take); - DenarEconomy.getMoneyManager().addMoney(p, price, false, true); + long cents = Math.round(price * 100); + long tax = Denars.taxFor(seller.get(), cents); + try { + if (new MarketSale(MarketBlock.plugin.getDataFolder().toPath().resolve("pending-sales"), + seller.get(), take, cents).settle() != Result.APPLIED) { + p.sendMessage("§cThe market could not pay you, so it kept nothing."); + return; + } + } catch (Exception failure) { + MarketBlock.plugin.getLogger().warning("Sale needs review for " + p.getUniqueId() + ": " + failure); + p.sendMessage("§cThis sale needs staff review before you sell again."); + return; + } + p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f); sendSaleBreakdown(p, take, (int) requiredAmount, base, price); + if (tax > 0) { + p.sendMessage("§7 Tax: " + formatMoney(tax / 100.0) + "d"); + } trade.sell(); Bukkit.getPluginManager().callEvent(new MarketSaleEvent(p, trade, price, requiredAmount)); update(); diff --git a/src/main/java/net/tfminecraft/marketblock/trade/MarketSale.java b/src/main/java/net/tfminecraft/marketblock/trade/MarketSale.java new file mode 100644 index 0000000..cdb2b45 --- /dev/null +++ b/src/main/java/net/tfminecraft/marketblock/trade/MarketSale.java @@ -0,0 +1,87 @@ +package net.tfminecraft.marketblock.trade; + +import java.io.IOException; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Properties; +import java.util.UUID; + +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.marketblock.util.SaleTake; +import net.tfminecraft.tlibs.character.CharacterRef; + +/** + * One sale to the market. Its operation ID is fixed when the sale is planned, so settling it again + * returns the ledger's first result instead of paying twice, and the goods are taken once, only + * after the payment is applied. + */ +public final class MarketSale { + private final String opId; + private final CharacterRef seller; + private final SaleTake take; + private final long cents; + private final Path intent; + private boolean recorded; + private boolean taken; + + public MarketSale(Path folder, CharacterRef seller, SaleTake take, long cents) { + this.opId = "market:" + UUID.randomUUID(); + this.seller = seller; + this.take = take; + this.cents = cents; + this.intent = folder.resolve(seller.ownerId() + ".properties"); + } + + public String opId() { + return opId; + } + + /** Pays the seller's pouch from the mint, taxed, then takes the goods if the payment applied. */ + public Result settle() throws IOException { + if (taken) return Result.APPLIED; + if (!recorded && !Denars.taxReady()) return Result.UNAVAILABLE; + if (!recorded) record(); + Result result = Denars.status(opId).orElseGet( + () -> Denars.earn(opId, seller, Denars.pouch(seller), cents, true)); + if (result == Result.APPLIED) { + take.apply(); + taken = true; + } + if (result == Result.APPLIED || result == Result.REFUSED) { + Files.deleteIfExists(intent); + syncDirectory(); + } + return result; + } + + private void record() throws IOException { + Files.createDirectories(intent.getParent()); + try (FileChannel parent = FileChannel.open(intent.getParent().getParent(), StandardOpenOption.READ)) { + parent.force(true); + } + Properties data = new Properties(); + data.setProperty("operation", opId); + data.setProperty("owner", seller.ownerId().toString()); + data.setProperty("character", seller.characterId().toString()); + data.setProperty("grossCents", Long.toString(cents)); + data.setProperty("items", take.description()); + // CREATE_NEW also blocks another payment when an interrupted sale needs reconciliation. + try (FileChannel channel = FileChannel.open(intent, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + data.store(Channels.newOutputStream(channel), "Pending market sale; reconcile before removing"); + channel.force(true); + } + syncDirectory(); + recorded = true; + } + + private void syncDirectory() throws IOException { + try (FileChannel directory = FileChannel.open(intent.getParent(), StandardOpenOption.READ)) { + directory.force(true); + } + } +} diff --git a/src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java b/src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java index 0955c83..4cce30b 100644 --- a/src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java +++ b/src/main/java/net/tfminecraft/marketblock/util/InventoryUtils.java @@ -26,7 +26,11 @@ public static boolean hasEnough(Player p, String path, double requiredAmount) { && getTotalAmount(p, path) >= requiredAmount; } - public static SaleTake removeItems(Player p, String path, double amountToRemove) { + /** + * Plans which matching stacks a sale would take, without changing the inventory. Call + * {@link SaleTake#apply()} to take them. + */ + public static SaleTake planTake(Player p, String path, double amountToRemove) { SaleTake take = new SaleTake(); if (!Double.isFinite(amountToRemove) || amountToRemove <= 0 || amountToRemove > Integer.MAX_VALUE @@ -35,7 +39,9 @@ public static SaleTake removeItems(Player p, String path, double amountToRemove) } double remaining = amountToRemove; - for (ItemStack item : p.getInventory().getContents()) { + ItemStack[] contents = p.getInventory().getContents(); + for (int slot = 0; slot < contents.length; slot++) { + ItemStack item = contents[slot]; if (item == null || item.getType().isAir()) continue; if (!TLibs.getItemAPI().getChecker().checkItemWithPath(item, path)) continue; @@ -43,14 +49,8 @@ public static SaleTake removeItems(Player p, String path, double amountToRemove) int stackAmount = item.getAmount(); int taken = stackAmount <= remaining ? stackAmount : (int) remaining; take.add(FreshnessLookup.stepId(item), taken); - - if (stackAmount <= remaining) { - remaining -= stackAmount; - item.setAmount(0); - } else { - item.setAmount(stackAmount - taken); - remaining = 0; - } + take.plan(p.getInventory(), slot, taken); + remaining -= taken; if (remaining <= 0) break; } diff --git a/src/main/java/net/tfminecraft/marketblock/util/SaleTake.java b/src/main/java/net/tfminecraft/marketblock/util/SaleTake.java index e485e0a..b6f8430 100644 --- a/src/main/java/net/tfminecraft/marketblock/util/SaleTake.java +++ b/src/main/java/net/tfminecraft/marketblock/util/SaleTake.java @@ -1,11 +1,21 @@ package net.tfminecraft.marketblock.util; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; import java.util.Map; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +/** The goods a sale takes: counts per freshness step and the planned amount from each stack. */ public class SaleTake { + private record Part(PlayerInventory inventory, int slot, ItemStack stack, int amount) { + } + private final Map counts = new LinkedHashMap<>(); + private final List parts = new ArrayList<>(); public void add(String stepId, int amount) { if (amount <= 0) { @@ -15,6 +25,26 @@ public void add(String stepId, int amount) { counts.merge(key, amount, Integer::sum); } + void plan(PlayerInventory inventory, int slot, int amount) { + parts.add(new Part(inventory, slot, inventory.getItem(slot).clone(), amount)); + } + + /** Takes the planned amounts from their stacks. */ + public void apply() { + for (Part part : parts) { + if (!part.stack().equals(part.inventory().getItem(part.slot()))) { + throw new IllegalStateException("Paid market goods changed before removal"); + } + } + for (Part part : parts) { + part.inventory().getItem(part.slot()).setAmount(part.stack().getAmount() - part.amount()); + } + } + + public String description() { + return parts.stream().map(part -> "slot " + part.slot() + ": " + part.amount() + " x " + part.stack()).toList().toString(); + } + public Map getCounts() { return counts; } diff --git a/src/test/java/net/tfminecraft/marketblock/TestMoney.java b/src/test/java/net/tfminecraft/marketblock/TestMoney.java new file mode 100644 index 0000000..cf444f3 --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/TestMoney.java @@ -0,0 +1,100 @@ +package net.tfminecraft.marketblock; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; +import net.tfminecraft.denareconomy.DenarEconomy; +import net.tfminecraft.denareconomy.api.TaxLeg; +import net.tfminecraft.denareconomy.api.TaxPolicy; +import net.tfminecraft.denareconomy.ledger.AccountIds; +import net.tfminecraft.denareconomy.ledger.Ledger; +import net.tfminecraft.tlibs.character.CharacterIdentity; +import net.tfminecraft.tlibs.character.CharacterInfo; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.CharacterStatus; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.ServicePriority; +import org.mockbukkit.mockbukkit.MockBukkit; + +/** A real DenarEconomy ledger in a temporary folder and a character provider for tests. */ +public final class TestMoney implements CharacterIdentity { + private final Map sessions = new HashMap<>(); + private final Map characters = new HashMap<>(); + private long nextSession; + + /** Opens the ledger and registers the provider with the running MockBukkit server. */ + public static TestMoney open(Path folder) { + DenarEconomy.setLedger(Ledger.open(folder.resolve("ledger.db").toFile(), + Logger.getLogger("MarketBlockTest"), AccountIds::frozen)); + TestMoney money = new TestMoney(); + Bukkit.getServicesManager().register(CharacterIdentity.class, money, + MockBukkit.createMockPlugin(), ServicePriority.Normal); + return money; + } + + /** Closes the ledger. Call before MockBukkit.unmock(). */ + public static void close() { + DenarEconomy.getLedger().close(); + DenarEconomy.setLedger(Ledger.unavailable()); + } + + /** Taxes every earning at this percentage into a guild's holding account. */ + public static String taxAt(int percent) { + String account = AccountIds.guildTax(UUID.randomUUID()); + TaxPolicy policy = (earner, gross) -> Optional.of(new TaxLeg(account, gross * percent / 100)); + Bukkit.getServicesManager().register(TaxPolicy.class, policy, + MockBukkit.createMockPlugin(), ServicePriority.Normal); + return account; + } + + /** Gives the player a new living character and makes it current. */ + public CharacterRef play(Player player) { + CharacterRef ref = new CharacterRef(player.getUniqueId(), UUID.randomUUID()); + characters.put(ref.characterId(), new CharacterInfo(ref, "Char", CharacterStatus.ALIVE, null, + Set.of(), 0, null)); + sessions.put(player.getUniqueId(), new CharacterSession(ref, ++nextSession)); + return ref; + } + + public void stop(Player player) { + sessions.remove(player.getUniqueId()); + } + + @Override + public Optional currentSession(Player player) { + return Optional.ofNullable(sessions.get(player.getUniqueId())); + } + + @Override + public boolean isCurrent(CharacterSession session) { + return sessions.containsValue(session); + } + + @Override + public Optional info(UUID characterId) { + return Optional.ofNullable(characters.get(characterId)); + } + + @Override + public Optional> characters(UUID ownerId) { + return Optional.of(characters.values().stream() + .filter(c -> c.ref().ownerId().equals(ownerId)).toList()); + } + + @Override + public Optional lastPlayed(UUID ownerId) { + return Optional.empty(); + } + + @Override + public String displayName(Player player) { + return player.getName(); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/manager/TradeManagerTest.java b/src/test/java/net/tfminecraft/marketblock/manager/TradeManagerTest.java index 32620b3..3c67359 100644 --- a/src/test/java/net/tfminecraft/marketblock/manager/TradeManagerTest.java +++ b/src/test/java/net/tfminecraft/marketblock/manager/TradeManagerTest.java @@ -5,10 +5,11 @@ import java.util.ArrayList; import java.util.List; -import net.tfminecraft.denareconomy.DenarEconomy; -import net.tfminecraft.denareconomy.managers.MoneyManager; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Result; import net.tfminecraft.marketblock.Cache; import net.tfminecraft.marketblock.MarketTestSupport; +import net.tfminecraft.marketblock.TestMoney; import net.tfminecraft.marketblock.events.MarketSaleEvent; import net.tfminecraft.marketblock.inventory.holder.MBGUI; import net.tfminecraft.marketblock.inventory.holder.MBHolder; @@ -20,6 +21,7 @@ import net.tfminecraft.marketblock.util.InventoryUtils; import net.tfminecraft.marketblock.util.SaleTake; import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.character.CharacterRef; import net.tfminecraft.tlibs.objects.api.BlockAPI; import net.tfminecraft.tlibs.objects.api.subapi.BlockChecker; import org.bukkit.Bukkit; @@ -34,6 +36,7 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataType; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockbukkit.mockbukkit.entity.PlayerMock; @@ -42,10 +45,14 @@ class TradeManagerTest extends MarketTestSupport { private TradeManager manager; private PlayerMock player; private Category category; + private TestMoney money; + private CharacterRef seller; @BeforeEach void prepareMarket() { player = server.addPlayer(); + money = TestMoney.open(directory); + seller = money.play(player); manager = new TradeManager(); category = new Category(); CategoryLoader.categories.put(category.getId(), category); @@ -57,42 +64,85 @@ void prepareMarket() { when(checker.checkItemWithPath(any(), eq("v.carrot"))).thenAnswer(i -> i.getArgument(0).getType() == Material.CARROT); } + @AfterEach + void closeLedger() { + TestMoney.close(); + } + @Test - void successfulSaleConsumesGoodsPaysOnceAndPublishesMatchingEvent() { + void successfulSaleConsumesGoodsPaysTheSellingCharacterOnceAndPublishesMatchingEvent() { var trade = trade("sale", 4); + String taxes = TestMoney.taxAt(10); player.getInventory().setItem(0, new ItemStack(Material.CARROT, 2)); player.getInventory().setItem(1, new ItemStack(Material.CARROT, 4)); var holder = openTrade(); - var money = mock(MoneyManager.class); - try (var economy = mockStatic(DenarEconomy.class); var freshness = mockStatic(FreshnessLookup.class)) { - economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + try (var freshness = mockStatic(FreshnessLookup.class)) { freshness.when(() -> FreshnessLookup.stepId(any())).thenReturn("stale", "fresh"); manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); - verify(money).addMoney(player, 6.0, false, true); - assertEquals(0, player.getInventory().getItem(0).getAmount()); - assertEquals(2, player.getInventory().getItem(1).getAmount()); - assertEquals("§aSold 4 for 6d", player.nextMessage()); - assertEquals("§7 Base: 8d", player.nextMessage()); - assertEquals("§7 Fresh x2: 100%", player.nextMessage()); - assertEquals("§7 Stale x2: 50%", player.nextMessage()); - var events = server.getPluginManager().getFiredEvents().filter(MarketSaleEvent.class::isInstance).map(MarketSaleEvent.class::cast).toList(); - assertEquals(1, events.size()); - var event = events.getFirst(); - assertSame(player, event.getPlayer()); - assertSame(trade, event.getTrade()); - assertEquals(6, event.getPrice()); - assertEquals(4, event.getAmount()); - assertSame(MarketSaleEvent.getHandlerList(), event.getHandlers()); } + assertEquals(540, Denars.balance(Denars.pouch(seller))); + assertEquals(60, Denars.balance(taxes)); + assertEquals(0, player.getInventory().getItem(0).getAmount()); + assertEquals(2, player.getInventory().getItem(1).getAmount()); + assertEquals("§aSold 4 for 6d", player.nextMessage()); + assertEquals("§7 Base: 8d", player.nextMessage()); + assertEquals("§7 Fresh x2: 100%", player.nextMessage()); + assertEquals("§7 Stale x2: 50%", player.nextMessage()); + assertEquals("§7 Tax: 0.6d", player.nextMessage()); + assertNull(player.nextMessage()); + var events = server.getPluginManager().getFiredEvents().filter(MarketSaleEvent.class::isInstance).map(MarketSaleEvent.class::cast).toList(); + assertEquals(1, events.size()); + var event = events.getFirst(); + assertSame(player, event.getPlayer()); + assertSame(trade, event.getTrade()); + assertEquals(6, event.getPrice()); + assertEquals(4, event.getAmount()); + assertSame(MarketSaleEvent.getHandlerList(), event.getHandlers()); + } + + @Test + void replayingTheSameClickDoesNotSellAnotherBatch() { + trade("sale", 2); + var holder = openTrade(); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 5)); + var event = clickEvent(tagged("trade_id", "sale"), player.getOpenInventory().getTopInventory()); + manager.click(event); + manager.click(event); + assertEquals(3, player.getInventory().getItem(0).getAmount()); + assertEquals(800, Denars.balance(Denars.pouch(seller))); + } + + @Test + void interruptedPostingBlocksNewCallbacksAndSurvivesManagerRecreation() throws Exception { + trade("sale", 2); + var holder = openTrade(); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 5)); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.earn(anyString(), eq(seller), anyString(), anyLong(), eq(true))) + .thenAnswer(call -> { + call.callRealMethod(); + throw new IllegalStateException("interrupted after posting"); + }); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + } + assertEquals("§cThis sale needs staff review before you sell again.", player.nextMessage()); + var intent = directory.resolve("pending-sales/" + player.getUniqueId() + ".properties"); + String saved = java.nio.file.Files.readString(intent); + assertTrue(saved.contains("character=" + seller.characterId())); + manager = new TradeManager(); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals("§cThis sale needs staff review before you sell again.", player.nextMessage()); + assertEquals(saved, java.nio.file.Files.readString(intent)); + assertEquals(800, Denars.balance(Denars.pouch(seller))); + assertEquals(5, player.getInventory().getItem(0).getAmount()); + server.getPluginManager().assertEventNotFired(MarketSaleEvent.class); } @Test void freshSalesAndUnknownFreshnessHaveAccurateMessagesAndRounding() { var trade = trade("sale", 2); var holder = openTrade(); - var money = mock(MoneyManager.class); - try (var economy = mockStatic(DenarEconomy.class); var freshness = mockStatic(FreshnessLookup.class)) { - economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + try (var freshness = mockStatic(FreshnessLookup.class)) { freshness.when(() -> FreshnessLookup.stepId(any())).thenReturn("fresh", "custom"); for (String step : List.of("fresh", "custom")) { trade.setDemand(1.234); @@ -105,20 +155,51 @@ void freshSalesAndUnknownFreshnessHaveAccurateMessagesAndRounding() { } assertNull(player.nextMessage()); } - verify(money, times(2)).addMoney(player, 0.99, false, true); } + assertEquals(198, Denars.balance(Denars.pouch(seller))); + } + + @Test + void aSellerWithNoCurrentCharacterIsRefusedAndKeepsTheGoods() { + trade("sale", 2); + money.stop(player); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 3)); + manager.tradeClick(player, openTrade(), tagged("trade_id", "sale"), null); + assertEquals("§cChoose a character before you sell.", player.nextMessage()); + assertNull(player.nextMessage()); + assertEquals(3, player.getInventory().getItem(0).getAmount()); + assertEquals(0, Denars.balance(Denars.pouch(seller))); + server.getPluginManager().assertEventNotFired(MarketSaleEvent.class); + } + + @Test + void aRefusedOrUnavailablePaymentTakesNothing() { + trade("sale", 2); + var holder = openTrade(); + String pouch = Denars.pouch(seller); + assertEquals(Result.APPLIED, Denars.transfer("fund", "staff:test", Denars.MINT, pouch, Long.MAX_VALUE - 1)); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 3)); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals("§cThe market could not pay you, so it kept nothing.", player.nextMessage()); + assertEquals(Long.MAX_VALUE - 1, Denars.balance(pouch)); + TestMoney.close(); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals("§cThe market could not pay you, so it kept nothing.", player.nextMessage()); + assertNull(player.nextMessage()); + assertEquals(3, player.getInventory().getItem(0).getAmount()); + server.getPluginManager().assertEventNotFired(MarketSaleEvent.class); } @Test void anEmptyRemovalCannotPayEvenIfTheInventoryCheckPreviouslySucceeded() { trade("sale", 2); var holder = openTrade(); - try (var inventory = mockStatic(InventoryUtils.class); var economy = mockStatic(DenarEconomy.class)) { + try (var inventory = mockStatic(InventoryUtils.class)) { inventory.when(() -> InventoryUtils.hasEnough(player, "v.carrot", 2)).thenReturn(true); - inventory.when(() -> InventoryUtils.removeItems(player, "v.carrot", 2)).thenReturn(new SaleTake()); + inventory.when(() -> InventoryUtils.planTake(player, "v.carrot", 2)).thenReturn(new SaleTake()); manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); assertEquals("§cThe market could not take the items for this trade.", player.nextMessage()); - economy.verifyNoInteractions(); + assertEquals(0, Denars.balance(Denars.pouch(seller))); server.getPluginManager().assertEventNotFired(MarketSaleEvent.class); } } @@ -132,13 +213,11 @@ void externallySuppliedFreshnessCountsIgnoreZeroEntriesAndDisplayUnnamedStagesSa take.getCounts().put("fresh", 0); take.getCounts().put("unknown", 0); take.getCounts().put(null, 2); - var money = mock(MoneyManager.class); - try (var inventory = mockStatic(InventoryUtils.class); var economy = mockStatic(DenarEconomy.class)) { + try (var inventory = mockStatic(InventoryUtils.class)) { inventory.when(() -> InventoryUtils.hasEnough(player, "v.carrot", 2)).thenReturn(true); - inventory.when(() -> InventoryUtils.removeItems(player, "v.carrot", 2)).thenReturn(take); - economy.when(DenarEconomy::getMoneyManager).thenReturn(money); + inventory.when(() -> InventoryUtils.planTake(player, "v.carrot", 2)).thenReturn(take); manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); - verify(money).addMoney(player, 8.0, false, true); + assertEquals(800, Denars.balance(Denars.pouch(seller))); assertEquals("§aSold 2 for 8d", player.nextMessage()); assertEquals("§7 Base: 8d", player.nextMessage()); assertEquals("§7 Fresh x2: 100%", player.nextMessage()); @@ -150,21 +229,19 @@ void externallySuppliedFreshnessCountsIgnoreZeroEntriesAndDisplayUnnamedStagesSa void missingGoodsInvalidAmountsDeletedOffersAndDecorationsDoNotPay() { trade("sale", 2); var holder = openTrade(); - try (var economy = mockStatic(DenarEconomy.class)) { - manager.tradeClick(player, holder, new ItemStack(Material.STONE), null); - manager.tradeClick(player, holder, tagged("trade_id", "missing"), null); - assertEquals("§cThe market no longer buys this.", player.nextMessage()); + manager.tradeClick(player, holder, new ItemStack(Material.STONE), null); + manager.tradeClick(player, holder, tagged("trade_id", "missing"), null); + assertEquals("§cThe market no longer buys this.", player.nextMessage()); + manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals("§cYou don't have enough items for this trade.", player.nextMessage()); + for (double amount : new double[] {0, -1, 0.5, Double.NaN, Double.POSITIVE_INFINITY}) { + trade("sale", amount); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 3)); manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); + assertEquals(3, player.getInventory().getItem(0).getAmount()); assertEquals("§cYou don't have enough items for this trade.", player.nextMessage()); - for (double amount : new double[] {0, -1, 0.5, Double.NaN, Double.POSITIVE_INFINITY}) { - trade("sale", amount); - player.getInventory().setItem(0, new ItemStack(Material.CARROT, 3)); - manager.tradeClick(player, holder, tagged("trade_id", "sale"), null); - assertEquals(3, player.getInventory().getItem(0).getAmount()); - assertEquals("§cYou don't have enough items for this trade.", player.nextMessage()); - } - economy.verifyNoInteractions(); } + assertEquals(0, Denars.balance(Denars.pouch(seller))); } @Test diff --git a/src/test/java/net/tfminecraft/marketblock/trade/MarketSaleTest.java b/src/test/java/net/tfminecraft/marketblock/trade/MarketSaleTest.java new file mode 100644 index 0000000..492191f --- /dev/null +++ b/src/test/java/net/tfminecraft/marketblock/trade/MarketSaleTest.java @@ -0,0 +1,132 @@ +package net.tfminecraft.marketblock.trade; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.Optional; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.marketblock.TestMoney; +import net.tfminecraft.marketblock.util.FreshnessLookup; +import net.tfminecraft.marketblock.util.InventoryUtils; +import net.tfminecraft.tlibs.TLibs; +import net.tfminecraft.tlibs.objects.api.ItemAPI; +import net.tfminecraft.tlibs.objects.api.subapi.ItemChecker; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockito.MockedStatic; + +class MarketSaleTest { + @TempDir Path folder; + private PlayerMock player; + private TestMoney money; + private MockedStatic tlibs; + private MockedStatic freshness; + + @BeforeEach + void setUp() { + player = MockBukkit.mock().addPlayer(); + money = TestMoney.open(folder); + var checker = mock(ItemChecker.class); + var api = mock(ItemAPI.class); + when(api.getChecker()).thenReturn(checker); + when(checker.checkItemWithPath(any(ItemStack.class), eq("v.carrot"))) + .thenAnswer(call -> call.getArgument(0).getType() == Material.CARROT); + tlibs = mockStatic(TLibs.class); + tlibs.when(TLibs::getItemAPI).thenReturn(api); + freshness = mockStatic(FreshnessLookup.class); + freshness.when(() -> FreshnessLookup.stepId(any(ItemStack.class))).thenReturn("fresh"); + } + + @AfterEach + void tearDown() { + freshness.close(); + tlibs.close(); + TestMoney.close(); + MockBukkit.unmock(); + } + + @Test + void settlingASaleAgainPaysAndTakesTheGoodsOnlyOnce() throws Exception { + var seller = money.play(player); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 5)); + var sale = new MarketSale(folder.resolve("sales"), seller, InventoryUtils.planTake(player, "v.carrot", 2), 250); + assertEquals(5, player.getInventory().getItem(0).getAmount()); + + assertEquals(Result.APPLIED, sale.settle()); + assertEquals(Result.APPLIED, sale.settle()); + + assertEquals(250, Denars.balance(Denars.pouch(seller))); + assertEquals(-250, Denars.balance(Denars.MINT)); + assertEquals(3, player.getInventory().getItem(0).getAmount()); + assertEquals(Optional.of(Result.APPLIED), Denars.status(sale.opId())); + assertTrue(sale.opId().startsWith("market:")); + } + + @Test + void aSaleRefusedByTheLedgerTakesNothingAndCanBeRetried() throws Exception { + var seller = money.play(player); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 5)); + var sale = new MarketSale(folder.resolve("sales"), seller, InventoryUtils.planTake(player, "v.carrot", 2), 250); + TestMoney.close(); + + assertEquals(Result.UNAVAILABLE, sale.settle()); + assertEquals(5, player.getInventory().getItem(0).getAmount()); + + TestMoney.open(folder); + assertEquals(Result.APPLIED, sale.settle()); + assertEquals(250, Denars.balance(Denars.pouch(seller))); + assertEquals(3, player.getInventory().getItem(0).getAmount()); + } + @Test + void paidRetryUsesStoredResultAfterTaxChangesAndRefusesChangedGoods() throws Exception { + var seller = money.play(player); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 5)); + var sale = new MarketSale(folder.resolve("sales"), seller, + InventoryUtils.planTake(player, "v.carrot", 2), 250); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.earn(anyString(), any(), anyString(), anyLong(), eq(true))) + .thenAnswer(call -> { + assertTrue(java.nio.file.Files.readString(folder.resolve("sales/" + seller.ownerId() + ".properties")) + .contains("grossCents=250")); + call.callRealMethod(); + throw new IllegalStateException("interrupted"); + }); + assertThrows(IllegalStateException.class, sale::settle); + } + TestMoney.taxAt(20); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 4)); + assertThrows(IllegalStateException.class, sale::settle); + assertEquals(250, Denars.balance(Denars.pouch(seller))); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 5)); + assertEquals(Result.APPLIED, sale.settle()); + assertEquals(250, Denars.balance(Denars.pouch(seller))); + assertEquals(3, player.getInventory().getItem(0).getAmount()); + } + + + @Test void waitingForFactionTaxDoesNotCreateAnAmbiguousSale() throws Exception { + var seller=money.play(player); + MockBukkit.createMockPlugin("SimpleFactions"); + player.getInventory().setItem(0,new ItemStack(Material.CARROT,5)); + var sale=new MarketSale(folder.resolve("sales"),seller,InventoryUtils.planTake(player,"v.carrot",2),250); + assertEquals(Result.UNAVAILABLE,sale.settle()); + assertFalse(java.nio.file.Files.exists(folder.resolve("sales"))); + assertEquals(5,player.getInventory().getItem(0).getAmount()); + TestMoney.taxAt(20); + assertEquals(Result.APPLIED,sale.settle()); + assertEquals(200,Denars.balance(Denars.pouch(seller))); + } +} diff --git a/src/test/java/net/tfminecraft/marketblock/util/InventoryUtilsTest.java b/src/test/java/net/tfminecraft/marketblock/util/InventoryUtilsTest.java index 1d3b5e8..4ad8964 100644 --- a/src/test/java/net/tfminecraft/marketblock/util/InventoryUtilsTest.java +++ b/src/test/java/net/tfminecraft/marketblock/util/InventoryUtilsTest.java @@ -69,7 +69,7 @@ void emptyInventoryHasNoMatchingItems() { assertNotNull(new InventoryUtils()); assertEquals(0, InventoryUtils.getTotalAmount(player, PATH)); assertFalse(InventoryUtils.hasEnough(player, PATH, 1)); - assertEquals(0, InventoryUtils.removeItems(player, PATH, 1).total()); + assertEquals(0, remove(player, PATH, 1).total()); verifyNoInteractions(checker); freshness.verifyNoInteractions(); } @@ -82,7 +82,7 @@ void explicitAirStacksDoNotReachItemOrFreshnessLookups() { when(inventory.getContents()).thenReturn(new ItemStack[] {null, new ItemStack(Material.AIR)}); assertEquals(0, InventoryUtils.getTotalAmount(owner, PATH)); - assertEquals(0, InventoryUtils.removeItems(owner, PATH, 1).total()); + assertEquals(0, remove(owner, PATH, 1).total()); verifyNoInteractions(checker); freshness.verifyNoInteractions(); } @@ -122,7 +122,7 @@ void invalidRemovalAmountsLeaveEveryInventoryStackUntouched(double amount) { put(2, Material.POTATO, 5); ItemStack[] before = snapshot(); - SaleTake taken = InventoryUtils.removeItems(player, PATH, amount); + SaleTake taken = remove(player, PATH, amount); assertEquals(Map.of(), taken.getCounts()); assertArrayEquals(before, player.getInventory().getContents()); @@ -137,8 +137,9 @@ void largestSupportedWholeAmountKeepsAnExactRemovalTotal() { when(owner.getInventory()).thenReturn(inventory); ItemStack stack = new ItemStack(Material.CARROT, Integer.MAX_VALUE); when(inventory.getContents()).thenReturn(new ItemStack[] {stack}); + when(inventory.getItem(0)).thenReturn(stack); assertTrue(InventoryUtils.hasEnough(owner, PATH, Integer.MAX_VALUE)); - SaleTake taken = InventoryUtils.removeItems(owner, PATH, Integer.MAX_VALUE); + SaleTake taken = remove(owner, PATH, Integer.MAX_VALUE); assertEquals(Integer.MAX_VALUE, taken.total()); assertEquals(0, stack.getAmount()); } @@ -148,7 +149,7 @@ void removesPartOfAStackAndStopsBeforeLaterMatches() { put(0, Material.CARROT, 10); put(1, Material.POTATO, 4); - SaleTake taken = InventoryUtils.removeItems(player, PATH, 3); + SaleTake taken = remove(player, PATH, 3); assertEquals(Map.of("fresh", 3), taken.getCounts()); assertEquals(7, player.getInventory().getItem(0).getAmount()); @@ -161,7 +162,7 @@ void exactStackRemovalDoesNotTouchTheNextMatchingStack() { put(0, Material.CARROT, 3); put(1, Material.POTATO, 4); - SaleTake taken = InventoryUtils.removeItems(player, PATH, 3); + SaleTake taken = remove(player, PATH, 3); assertEquals(Map.of("fresh", 3), taken.getCounts()); assertEquals(0, player.getInventory().getItem(0).getAmount()); @@ -176,7 +177,7 @@ void mixedStackRemovalSkipsEmptyAndUnrelatedItemsAndTracksFreshness() { put(4, Material.POTATO, 5); put(5, Material.CARROT, 7); - SaleTake taken = InventoryUtils.removeItems(player, PATH, 4); + SaleTake taken = remove(player, PATH, 4); assertEquals(Map.of("fresh", 2, "stale", 2), taken.getCounts()); assertEquals(4, taken.total()); @@ -194,7 +195,7 @@ void requestingMoreThanAvailableReturnsOnlyTheItemsActuallyRemoved() { put(3, Material.POTATO, 3); assertFalse(InventoryUtils.hasEnough(player, PATH, 8)); - SaleTake taken = InventoryUtils.removeItems(player, PATH, 8); + SaleTake taken = remove(player, PATH, 8); assertEquals(Map.of("fresh", 2, "stale", 3), taken.getCounts()); assertEquals(5, taken.total()); @@ -208,11 +209,32 @@ void removalWithNoMatchingPathLeavesInventoryIntact() { put(1, Material.POTATO, 3); ItemStack[] before = snapshot(); - assertEquals(0, InventoryUtils.removeItems(player, "food.other", 1).total()); + assertEquals(0, remove(player, "food.other", 1).total()); assertArrayEquals(before, player.getInventory().getContents()); freshness.verifyNoInteractions(); } + @Test + void planningASaleLeavesTheInventoryUntilItIsApplied() { + put(0, Material.CARROT, 2); + put(1, Material.POTATO, 3); + ItemStack[] before = snapshot(); + + SaleTake plan = InventoryUtils.planTake(player, PATH, 4); + + assertEquals(Map.of("fresh", 2, "stale", 2), plan.getCounts()); + assertArrayEquals(before, player.getInventory().getContents()); + plan.apply(); + assertEquals(0, player.getInventory().getItem(0).getAmount()); + assertEquals(1, player.getInventory().getItem(1).getAmount()); + } + + private static SaleTake remove(Player owner, String path, double amount) { + SaleTake take = InventoryUtils.planTake(owner, path, amount); + take.apply(); + return take; + } + private void put(int slot, Material material, int amount) { player.getInventory().setItem(slot, new ItemStack(material, amount)); }