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));
}