diff --git a/pom.xml b/pom.xml index 1cae7bd..1dff392 100644 --- a/pom.xml +++ b/pom.xml @@ -12,8 +12,9 @@ 21 UTF-8 full - 0.2.0 - 3.0.1 + 0.3.0 + 4.0.0 + 2.4.0 @@ -83,6 +84,18 @@ + + me.plugins + tlibs + ${tlibs.version} + provided + + + * + * + + + diff --git a/src/main/java/net/tfminecraft/bartershops/Database.java b/src/main/java/net/tfminecraft/bartershops/Database.java index 6641bf8..abaca72 100644 --- a/src/main/java/net/tfminecraft/bartershops/Database.java +++ b/src/main/java/net/tfminecraft/bartershops/Database.java @@ -2,7 +2,6 @@ import java.io.File; import java.io.FileInputStream; -import java.io.FileWriter; import java.io.InputStreamReader; import java.math.BigDecimal; import java.util.HashMap; @@ -22,8 +21,11 @@ import com.google.gson.ToNumberPolicy; public class Database { - private static final Gson SHOP_READER = new GsonBuilder() + static final Gson SHOP_READER = new GsonBuilder() .setObjectToNumberStrategy(ToNumberPolicy.BIG_DECIMAL).create(); + // The owner's account; written before shops belonged to characters, so every shop has it. + static final String OWNER = "owner"; + static final String OWNER_CHARACTER = "owner character"; private final File dataFolder; public Database() { @@ -50,7 +52,9 @@ public ShopSign getShopFromLoc(Location loc) { shop.setBarterAmount(readInteger(shopJson.get("barter amount"))); shop.setPrice(readInteger(shopJson.get("price"))); if (!shop.hasValidTerms()) return null; - shop.setOwner((String) shopJson.get("owner")); + shop.setShopId(shopId(file)); + shop.setOwnerAccount((String) shopJson.get(OWNER)); + shop.setOwnerCharacter(readCharacter(shopJson.get(OWNER_CHARACTER))); shop.setPaymentItem((String) shopJson.get("payment item")); shop.setType((String) shopJson.get("type")); return shop; @@ -62,9 +66,24 @@ public ShopSign getShopFromLoc(Location loc) { return null; } + static String shopId(File file) { + String name = file.getName(); + return name.endsWith(".json") ? name.substring(0, name.length() - 5) : name; + } + + // Only a well-formed character ID owns a shop; anything else leaves it closed. + static String readCharacter(Object value) { + if (!(value instanceof String text)) return null; + try { + return UUID.fromString(text).toString(); + } catch (IllegalArgumentException ex) { + return null; + } + } + // The data folder only exists once a shop has been saved. private File[] shopFiles() { - File[] files = dataFolder.listFiles(); + File[] files = dataFolder.listFiles((folder, name) -> name.endsWith(".json")); return files == null ? new File[0] : files; } @@ -95,14 +114,15 @@ public Boolean shopExistsFromLoc(Location loc) { return false; } - public String getOwnerFromLoc(Location loc) { + /** The character owning the shop at the sign, or null when it has none yet. */ + public String getOwnerCharacterFromLoc(Location loc) { for (final File file : shopFiles()) { if (!file.isDirectory()) { try (InputStreamReader reader = new InputStreamReader(new FileInputStream(file), "UTF-8")) { JSONObject shopJson = (JSONObject) parser.parse(reader); Location fileLoc = new Location(Bukkit.getServer().getWorld((String) shopJson.get("sign world")), ((Number) shopJson.get("sign xPos")).doubleValue(),((Number) shopJson.get("sign yPos")).doubleValue(),((Number) shopJson.get("sign zPos")).doubleValue()); if(!loc.equals(fileLoc)) continue; - return (String) shopJson.get("owner"); + return readCharacter(shopJson.get(OWNER_CHARACTER)); } catch (Exception ex) { ex.printStackTrace(); } @@ -143,7 +163,8 @@ public void saveShop(ShopSign shop) { defaults.put("barter amount", shop.getBarterAmount()); defaults.put("price", shop.getPrice()); defaults.put("payment item", shop.getPaymentItemString()); - defaults.put("owner", shop.getOwner()); + defaults.put(OWNER, shop.getOwnerAccount()); + defaults.put(OWNER_CHARACTER, shop.getOwnerCharacter()); defaults.put("type", shop.getType()); dataFolder.mkdirs(); UUID uuid = UUID. randomUUID(); @@ -156,7 +177,8 @@ public void saveShop(ShopSign shop) { } // A new shop has no stored values, so save() writes the defaults above. json = new JSONObject(); - if(!save(file, defaults)) file.delete(); + if(save(file, defaults)) shop.setShopId(uuidAsString); + else file.delete(); } catch (Throwable ex) { ex.printStackTrace(); } @@ -188,9 +210,7 @@ public boolean save(File file, HashMap defaults) { Gson g = new GsonBuilder().setPrettyPrinting().create(); String prettyJsonString = g.toJson(treeMap); - try (FileWriter fw = new FileWriter(file)) { - fw.write(prettyJsonString); - } + DurableFiles.write(file.toPath(), prettyJsonString); return true; } catch (Exception ex) { diff --git a/src/main/java/net/tfminecraft/bartershops/DurableFiles.java b/src/main/java/net/tfminecraft/bartershops/DurableFiles.java new file mode 100644 index 0000000..70d027f --- /dev/null +++ b/src/main/java/net/tfminecraft/bartershops/DurableFiles.java @@ -0,0 +1,48 @@ +package net.tfminecraft.bartershops; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +/** Publish whole, flushed files before money or inventory changes. */ +final class DurableFiles { + private DurableFiles() {} + + static void write(Path target, String text) throws IOException { + write(target, text.getBytes(StandardCharsets.UTF_8)); + } + + static void write(Path target, byte[] bytes) throws IOException { + target = target.toAbsolutePath(); + Files.createDirectories(target.getParent()); + Path temp = Files.createTempFile(target.getParent(), ".pending-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + // Parent entries also need flushing when an intent creates its first directory. + for (Path directory = target.getParent(); directory != null; directory = directory.getParent()) sync(directory); + } finally { + Files.deleteIfExists(temp); + } + } + + static void delete(Path target) throws IOException { + Files.deleteIfExists(target); + sync(target.getParent()); + } + + private static void sync(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } +} diff --git a/src/main/java/net/tfminecraft/bartershops/ShopEvents.java b/src/main/java/net/tfminecraft/bartershops/ShopEvents.java index 5584154..361e5ac 100644 --- a/src/main/java/net/tfminecraft/bartershops/ShopEvents.java +++ b/src/main/java/net/tfminecraft/bartershops/ShopEvents.java @@ -8,10 +8,12 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.UUID; +import java.util.Arrays; +import java.util.Base64; +import java.util.Optional; +import java.util.function.BooleanSupplier; import java.util.function.LongSupplier; -import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.Location; import org.bukkit.Material; @@ -40,9 +42,8 @@ import com.destroystokyo.paper.event.block.BlockDestroyEvent; -import net.tfminecraft.denareconomy.DenarEconomy; -import net.tfminecraft.denareconomy.data.Account; -import net.tfminecraft.denareconomy.enums.Accounts; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; import net.tfminecraft.bartershops.sf.ShopEmbargo; public class ShopEvents implements Listener{ @@ -63,6 +64,28 @@ public class ShopEvents implements Listener{ LongSupplier clock = System::currentTimeMillis; Database db = new Database(); + ShopTrades trades; + BooleanSupplier ready = () -> Characters.available() && Characters.legacyHeirsReady(); + + private boolean stillCreating(Player player, ShopSign shop) { + if (shop != null && ready.getAsBoolean() && Characters.currentSession(player) + .map(session -> session.ref().equals(shop.getOwnerRef().orElse(null))).orElse(false)) return true; + shopCreating.remove(player); + currentShop.remove(player); + player.sendMessage("§cShop creation cancelled: your active character changed or is unavailable."); + return false; + } + + private boolean setOwner(Player player, ShopSign shop) { + Optional owner = Characters.currentSession(player).map(session -> session.ref()); + if (!ready.getAsBoolean() || owner.isEmpty()) { + player.sendMessage("§cSelect a character before creating a shop; character shops must be ready."); + return false; + } + shop.setOwnerAccount(owner.get().ownerId().toString()); + shop.setOwnerCharacter(owner.get().characterId().toString()); + return true; + } // Retain Bukkit chat-event ordering and String message semantics for existing integrations. @SuppressWarnings("deprecation") @@ -93,6 +116,7 @@ public void chatEvent(AsyncPlayerChatEvent event) { public void run() { ShopSign shop = currentShop.get(player); + if (!stillCreating(player, shop)) return; shop.setBarterAmount(amount); player.sendTitle("Shop Creation §e3/4", "§aType the price in chat", 5, 80, 6); shopCreating.put(player, 4); @@ -121,6 +145,7 @@ public void run() public void run() { ShopSign shop = currentShop.get(player); + if (!stillCreating(player, shop)) return; shop.setPrice(amount); player.sendTitle("Shop Creation §e4/4", "§aShop Created!", 5, 80, 6); Sign sign = (Sign) shop.getSignLoc().getBlock().getState(); @@ -159,7 +184,7 @@ public void createShopEvent(PlayerInteractEvent e) { p.sendMessage("§cShop Sign has invalid type"); return; } - shop.setOwner(p.getUniqueId().toString()); + if (!setOwner(p, shop)) return; shop.setSignLoc(b.getLocation()); e.setCancelled(true); p.sendTitle("Shop Creation §e1/4", "§aRight Click on the storage chest with the redstone", 5, 80, 6); @@ -175,6 +200,7 @@ public void createShopEvent(PlayerInteractEvent e) { } e.setCancelled(true); ShopSign shop = currentShop.get(p); + if (!stillCreating(p, shop)) return; if(b.getLocation().distanceSquared(shop.getSignLoc()) > 1024) { p.sendMessage("§cChest is too far away"); return; @@ -201,7 +227,7 @@ public void createShopEvent(PlayerInteractEvent e) { p.sendMessage("§cShop Sign has invalid type"); return; } - shop.setOwner(p.getUniqueId().toString()); + if (!setOwner(p, shop)) return; shop.setSignLoc(b.getLocation()); e.setCancelled(true); p.sendTitle("Shop Creation §e1/4", "§aRight Click on the storage chest with the redstone", 5, 80, 6); @@ -227,76 +253,81 @@ public void useShop(PlayerInteractEvent e) { p.sendMessage("§cThe terms on this shop sign make no sense. Speak to the shop owner."); return; } - if(ShopEmbargo.blocked(p, shop)) { - p.sendMessage("§cYou cannot use this shop: your nation is under embargo."); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - if(!shop.getStorageLoc().getBlock().getType().equals(Material.CHEST)) { - p.sendMessage("§cShop missing a storage chest"); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - Chest storage = (Chest) shop.getStorageLoc().getBlock().getState(); - - if(shop.getType().equalsIgnoreCase("buy")) { - // Only stacks of the item being sold count as stock; other items in the chest are never handed over. - ItemStack item = firstItem(storage.getInventory()); - if(item == null || hasEnoughItems(storage.getInventory(), item, shop.getBarterAmount()) == false) { - p.sendMessage("§cShop out of stock"); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - // Items that do not fit would be lost after payment, so the whole order must fit. - if(hasRoomFor(p.getInventory(), item, shop.getBarterAmount()) == false) { - p.sendMessage(ChatColor.RED + "Your inventory is full!"); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - Account pouch = DenarEconomy.getPlayerManager().get(p).getPouch(); - if(pouch.getBal() < shop.getPrice()){ - p.sendMessage("§cCannot afford this!"); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - pouch.change(shop.getPrice()*-1); - DenarEconomy.getMoneyManager().addMoneyToAccount(shop.getOwner(), shop.getPrice(), true, true, Accounts.BANK); - p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f); - exchangeItems(storage.getInventory(), p.getInventory(), item, shop.getBarterAmount()); - } - if(shop.getType().equalsIgnoreCase("sell")) { - ItemStack item = firstItem(storage.getInventory()); - if(item != null && hasRoomFor(storage.getInventory(), item, shop.getBarterAmount()) == false) { - p.sendMessage(ChatColor.RED + "Shop Storage is full!"); - return; - } - if(p.getInventory().firstEmpty() == -1) { - p.sendMessage(ChatColor.RED + "Your inventory is full!"); - return; - } - if(DenarEconomy.getPlayerManager().get(UUID.fromString(shop.getOwner())).getBank().getBal() < shop.getPrice()){ - p.sendMessage("§cThe shop owner lacks money!"); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - if(item == null) { - p.sendMessage("§cShop has no item set for selling"); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - if(hasEnoughItems(p.getInventory(), item, shop.getBarterAmount()) == false) { - p.sendMessage("§cYou dont have enough to sell"); - p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); - return; - } - Account pouch = DenarEconomy.getPlayerManager().get(p).getPouch(); - DenarEconomy.getMoneyManager().changeBal(shop.getOwner(), shop.getPrice()*-1, Accounts.BANK); - DenarEconomy.getMoneyManager().addMoney(p, shop.getPrice(), false, true); - p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f); - exchangeItems(p.getInventory(), storage.getInventory(), item, shop.getBarterAmount()); - } - } - + Optional customer = Characters.currentSession(p).map(session -> session.ref()); + Optional owner = shop.getOwnerRef(); + if (!ready.getAsBoolean() || customer.isEmpty() || owner.isEmpty()) { + p.sendMessage("§cThis shop is closed until its owner and your active character are available."); + return; + } + if (!ShopEmbargo.ready()) { + refused(p, "§cShops are waiting for faction and tax data. Try again shortly."); + return; + } + if (ShopEmbargo.blocked(p, shop)) { + p.sendMessage("§cYou cannot use this shop: your nation is under embargo."); + p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); + return; + } + if (!shop.getStorageLoc().getBlock().getType().equals(Material.CHEST)) { + refused(p, "§cShop missing a storage chest"); + return; + } + Inventory storage = ((Chest) shop.getStorageLoc().getBlock().getState()).getInventory(); + boolean buy = "buy".equalsIgnoreCase(shop.getType()); + if (!buy && !"sell".equalsIgnoreCase(shop.getType())) return; + if (!buy && p.getInventory().firstEmpty() == -1) { + p.sendMessage(ChatColor.RED + "Your inventory is full!"); + return; + } + ItemStack item = firstItem(storage); + if (item == null) { + refused(p, buy ? "§cShop out of stock" : "§cShop has no item set for selling"); + return; + } + Inventory sender = buy ? storage : p.getInventory(); + Inventory receiver = buy ? p.getInventory() : storage; + if (!hasEnoughItems(sender, item, shop.getBarterAmount())) { + refused(p, buy ? "§cShop out of stock" : "§cYou dont have enough to sell"); + return; + } + if (!hasRoomFor(receiver, item, shop.getBarterAmount())) { + refused(p, buy ? ChatColor.RED + "Your inventory is full!" : ChatColor.RED + "Shop Storage is full!"); + return; + } + item.setAmount(1); + ItemStack[] beforeSender = snapshot(sender); + ItemStack[] beforeReceiver = snapshot(receiver); + ShopTrades.Request request = new ShopTrades.Request(shop.getShopId(), customer.get(), owner.get(), + buy ? "buy" : "sell", shop.getBarterAmount(), shop.getPrice().longValue() * 100L, Base64.getEncoder().encodeToString(item.serializeAsBytes())); + ShopTrades.Outcome result = trades.trade(request, () -> { + // A tax listener can run inside the posting. Recheck before any item mutation. + if (!Arrays.equals(beforeSender, sender.getStorageContents()) + || !Arrays.equals(beforeReceiver, receiver.getStorageContents()) + || !Characters.currentSession(p).map(session -> session.ref().equals(customer.get())).orElse(false)) + throw new IllegalStateException("Character or inventory changed during payment"); + exchangeItems(sender, receiver, item, shop.getBarterAmount()); + }); + if (result == ShopTrades.Outcome.APPLIED) { + p.playSound(p.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f); + } else if (result == ShopTrades.Outcome.REFUSED) { + refused(p, buy ? "§cCannot afford this!" : "§cThe shop owner lacks money!"); + } else if (result == ShopTrades.Outcome.WAITING) { + refused(p, "§cThe economy is unavailable. Retry this trade when it returns."); + } else { + refused(p, "§cThis shop has an interrupted trade. Ask staff to review it before trading again."); + } + } + + private static ItemStack[] snapshot(Inventory inventory) { + return Arrays.stream(inventory.getStorageContents()).map(item -> item == null ? null : item.clone()) + .toArray(ItemStack[]::new); + } + + private static void refused(Player player, String message) { + player.sendMessage(message); + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f); + } + @EventHandler public void breakDoor(BlockBreakEvent e) { Player p = e.getPlayer(); @@ -366,7 +397,9 @@ private static boolean isSign(Block b) { private boolean mayBreak(Player p, Location signLoc) { if(p.hasPermission(ADMIN_PERMISSION)) return true; - return p.getUniqueId().toString().equals(db.getOwnerFromLoc(signLoc)); + return ready.getAsBoolean() && Characters.currentSession(p) + .map(session -> session.ref().characterId().toString().equals(db.getOwnerCharacterFromLoc(signLoc))) + .orElse(false); } // Every sign that comes down with the broken block: the block itself, signs held up by anything @@ -406,7 +439,7 @@ private ItemStack firstItem(Inventory i) { for(ItemStack item : i.getStorageContents()) { if(item == null) continue; if(item.getType().equals(Material.AIR)) continue; - return new ItemStack(item); + return item.clone(); } return null; } @@ -426,22 +459,14 @@ public boolean hasRoomFor(Inventory i, ItemStack match, Integer amount) { return room >= amount; } - public Boolean hasEnoughItems(Inventory i, ItemStack match, Integer amount) { - Integer counter = 0; - for(ItemStack item : i.getStorageContents()) { - if(item == null) continue; - if(item.getType().equals(Material.AIR)) continue; - match.setAmount(item.getAmount()); - if(match.equals(item)) { - counter = counter + item.getAmount(); - } - } - if(counter >= amount) { - return true; - } - return false; - } - + public Boolean hasEnoughItems(Inventory inventory, ItemStack match, Integer amount) { + long count = 0; + for (ItemStack item : inventory.getStorageContents()) { + if (item != null && item.getType() != Material.AIR && match.isSimilar(item)) count += item.getAmount(); + } + return count >= amount; + } + public void exchangeItems(Inventory sender, Inventory reciever, ItemStack item, Integer amount) { Integer i = -1; for(ItemStack invItem : sender.getStorageContents()) { @@ -454,7 +479,8 @@ public void exchangeItems(Inventory sender, Inventory reciever, ItemStack item, if(amount > invItem.getAmount()) { ItemStack recieverItem = new ItemStack(invItem); recieverItem.setAmount(invItem.getAmount()); - reciever.addItem(recieverItem); + if (!reciever.addItem(recieverItem).isEmpty()) + throw new IllegalStateException("Inventory rejected part of the delivery"); amount = amount - invItem.getAmount(); invItem.setAmount(0); sender.setItem(i, null); @@ -463,7 +489,8 @@ public void exchangeItems(Inventory sender, Inventory reciever, ItemStack item, if(amount == invItem.getAmount()) { ItemStack recieverItem = new ItemStack(invItem); recieverItem.setAmount(invItem.getAmount()); - reciever.addItem(recieverItem); + if (!reciever.addItem(recieverItem).isEmpty()) + throw new IllegalStateException("Inventory rejected part of the delivery"); invItem.setAmount(0); sender.setItem(i, null); break; @@ -471,7 +498,8 @@ public void exchangeItems(Inventory sender, Inventory reciever, ItemStack item, invItem.setAmount(invItem.getAmount() - amount); ItemStack recieverItem = new ItemStack(invItem); recieverItem.setAmount(amount); - reciever.addItem(recieverItem); + if (!reciever.addItem(recieverItem).isEmpty()) + throw new IllegalStateException("Inventory rejected part of the delivery"); break; } continue; diff --git a/src/main/java/net/tfminecraft/bartershops/ShopMain.java b/src/main/java/net/tfminecraft/bartershops/ShopMain.java index 40e7d4b..47e775e 100644 --- a/src/main/java/net/tfminecraft/bartershops/ShopMain.java +++ b/src/main/java/net/tfminecraft/bartershops/ShopMain.java @@ -1,22 +1,25 @@ package net.tfminecraft.bartershops; -import org.bukkit.configuration.file.FileConfiguration; +import java.io.File; import org.bukkit.plugin.java.JavaPlugin; +public class ShopMain extends JavaPlugin { + public static ShopMain plugin; + ShopEvents events = new ShopEvents(); + private boolean ready; -public class ShopMain extends JavaPlugin{ - FileConfiguration config = getConfig(); - - public static ShopMain plugin; - - ShopEvents events = new ShopEvents(); - - /* This is a very old plugin, so the code is pretty bad, but i maintain it since its unneccesary work to reinvent the wheel :) */ - @Override - public void onEnable(){ - plugin = this; - - getServer().getPluginManager().registerEvents(events, this); - - } + @Override + public void onEnable() { + plugin = this; + events.db = new Database(new File(getDataFolder(), "Data")); + events.trades = new ShopTrades(getDataFolder().toPath().resolve("trades"), getLogger()); + events.ready = () -> ready; + ShopMigration migration = new ShopMigration(new File(getDataFolder(), "Data"), + new File(getDataFolder(), "migrations"), getLogger()); + ready = migration.runWhenReady(); + getServer().getScheduler().runTaskTimer(this, () -> { + if (!ready) ready = migration.runWhenReady(); + }, 100L, 100L); + getServer().getPluginManager().registerEvents(events, this); + } } diff --git a/src/main/java/net/tfminecraft/bartershops/ShopMigration.java b/src/main/java/net/tfminecraft/bartershops/ShopMigration.java new file mode 100644 index 0000000..07b7301 --- /dev/null +++ b/src/main/java/net/tfminecraft/bartershops/ShopMigration.java @@ -0,0 +1,171 @@ +package net.tfminecraft.bartershops; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; + +/** Back up, journal and atomically migrate each shop; unresolved shops retain their original data. */ +final class ShopMigration { + static final String REPORT = "quarantine.txt"; + private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create(); + private final Path data; + private final Path migrations; + private final Logger logger; + private boolean waitingLogged; + private boolean failed; + private List reportedQuarantine; + private boolean reportFailureLogged; + + ShopMigration(File dataFolder, File migrationFolder, Logger logger) { + data = dataFolder.toPath(); + migrations = migrationFolder.toPath(); + this.logger = logger; + } + + boolean runWhenReady() { + if (!Characters.available() || !Characters.legacyHeirsReady()) { + if (!waitingLogged) { + waitingLogged = true; + logger.severe("Waiting for character heirs; shop creation, trades and owner removal are closed."); + } + return false; + } + migrate(); + return !failed; + } + + List migrate() { + failed = false; + List quarantined = new ArrayList<>(); + File[] files = data.toFile().listFiles((folder, name) -> name.endsWith(".json")); + for (File file : files == null ? new File[0] : files) { + try { + String problem = migrate(file.toPath()); + if (problem != null) quarantined.add(file.getName() + ": " + problem); + } catch (IOException | RuntimeException ex) { + failed = true; + quarantined.add(file.getName() + ": migration failed: " + ex); + } + } + try { + Path report = migrations.resolve(REPORT); + if (!quarantined.isEmpty()) { + // Retries while not ready rerun migration; only a changed list is news. + boolean changed = !quarantined.equals(reportedQuarantine); + if (changed || !Files.exists(report)) + DurableFiles.write(report, String.join("\n", quarantined) + "\n"); + if (changed) { + reportedQuarantine = List.copyOf(quarantined); + quarantined.forEach(message -> logger.warning("Shop remains closed: " + message)); + } + } else { + if (Files.exists(report)) DurableFiles.delete(report); + reportedQuarantine = List.of(); + } + reportFailureLogged = false; + } catch (IOException ex) { + failed = true; + if (!reportFailureLogged) { + reportFailureLogged = true; + logger.severe("Cannot write shop quarantine report: " + ex); + } + } + return quarantined; + } + + @SuppressWarnings("unchecked") + private String migrate(Path file) throws IOException { + Path manifest = migrations.resolve("character-v1").resolve(file.getFileName()); + Path backup = migrations.resolve("backup").resolve(file.getFileName()); + if (Files.exists(manifest)) { + Map previous; + try { + previous = read(manifest); + } catch (IOException | RuntimeException ex) { + // Only this shop is affected: without its record it stays unmigrated and closed. + return "unreadable migration record; retained for staff: " + ex; + } + if ("in-progress".equals(previous.get("state"))) { + byte[] original = Files.readAllBytes(backup); + if (!hash(original).equals(previous.get("sha256"))) throw new IOException("Backup hash mismatch"); + DurableFiles.write(file, original); + } + } + Map shop; + try { + shop = read(file); + } catch (IOException | RuntimeException ex) { + // Only this shop is affected: Database cannot load it, so it stays closed on its own. + return "unreadable shop; retained for staff: " + ex; + } + if (shop.containsKey(Database.OWNER_CHARACTER)) { + if (Database.readCharacter(shop.get(Database.OWNER_CHARACTER)) != null + && Database.readCharacter(shop.get(Database.OWNER)) != null) return null; + return "invalid owner character/account; retained for staff"; + } + byte[] original = Files.readAllBytes(file); + if (!Files.exists(backup)) DurableFiles.write(backup, original); + // The first backup is immutable. Staff resolve quarantines through the heir mapping. + if (!hash(original).equals(hash(Files.readAllBytes(backup)))) + throw new IOException("Shop changed since its migration backup; staff review required"); + Map entry = new LinkedHashMap<>(); + entry.put("shopId", Database.shopId(file.toFile())); + entry.put("backup", backup.toString()); + entry.put("sha256", hash(original)); + entry.put("original", shop); + Optional heir = Optional.empty(); + String accountText = Database.readCharacter(shop.get(Database.OWNER)); + if (accountText != null) { + UUID account = UUID.fromString(accountText); + heir = Characters.legacyHeir(account).filter(ref -> account.equals(ref.ownerId())); + } + if (heir.isEmpty()) { + String reason = "owner " + shop.get(Database.OWNER) + " has no reviewed heir"; + entry.put("state", "quarantined"); + entry.put("reason", reason); + DurableFiles.write(manifest, JSON.toJson(entry)); + return reason; + } + entry.put("state", "in-progress"); + DurableFiles.write(manifest, JSON.toJson(entry)); + Map migrated = new LinkedHashMap<>(shop); + migrated.put(Database.OWNER_CHARACTER, heir.get().characterId().toString()); + DurableFiles.write(file, JSON.toJson(migrated)); + entry.put("state", "done"); + DurableFiles.write(manifest, JSON.toJson(entry)); + return null; + } + + @SuppressWarnings("unchecked") + private static Map read(Path path) throws IOException { + try (var reader = Files.newBufferedReader(path)) { + Map value = Database.SHOP_READER.fromJson(reader, Map.class); + if (value == null) throw new IOException("Unreadable shop: " + path); + return value; + } + } + + private static String hash(byte[] bytes) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException(ex); + } + } +} diff --git a/src/main/java/net/tfminecraft/bartershops/ShopSign.java b/src/main/java/net/tfminecraft/bartershops/ShopSign.java index f8797df..4fe5279 100644 --- a/src/main/java/net/tfminecraft/bartershops/ShopSign.java +++ b/src/main/java/net/tfminecraft/bartershops/ShopSign.java @@ -1,17 +1,25 @@ package net.tfminecraft.bartershops; +import java.util.Optional; +import java.util.UUID; + import org.bukkit.Location; import net.Indyuce.mmoitems.MMOItems; import net.Indyuce.mmoitems.api.item.mmoitem.MMOItem; import net.Indyuce.mmoitems.manager.ItemManager; +import net.tfminecraft.tlibs.character.CharacterRef; public class ShopSign { public Location signLoc; public Location storageLoc; public Integer barterAmount; public Integer price; - public String owner; + // The shop file's UUID, stable for the shop's lifetime. + public String shopId; + public String ownerAccount; + // Absent until the shop is migrated to a character; such a shop is closed. + public String ownerCharacter; public String paymentitem; public String type; @@ -30,8 +38,14 @@ public void setBarterAmount(Integer i) { public void setPrice(Integer i) { this.price = i; } - public void setOwner(String p) { - this.owner = p; + public void setShopId(String id) { + this.shopId = id; + } + public void setOwnerAccount(String account) { + this.ownerAccount = account; + } + public void setOwnerCharacter(String character) { + this.ownerCharacter = character; } public void setPaymentItem(String i) { this.paymentitem = i; @@ -56,8 +70,22 @@ public Integer getPrice() { public boolean hasValidTerms() { return barterAmount != null && barterAmount > 0 && price != null && price >= 0; } - public String getOwner() { - return this.owner; + public String getShopId() { + return this.shopId; + } + public String getOwnerAccount() { + return this.ownerAccount; + } + public String getOwnerCharacter() { + return this.ownerCharacter; + } + /** The owning character, or empty while the shop has none (closed). */ + public Optional getOwnerRef() { + try { + return Optional.of(new CharacterRef(UUID.fromString(ownerAccount), UUID.fromString(ownerCharacter))); + } catch (RuntimeException ex) { + return Optional.empty(); + } } public String getPaymentItemString() { return this.paymentitem; diff --git a/src/main/java/net/tfminecraft/bartershops/ShopTrades.java b/src/main/java/net/tfminecraft/bartershops/ShopTrades.java new file mode 100644 index 0000000..c5e152d --- /dev/null +++ b/src/main/java/net/tfminecraft/bartershops/ShopTrades.java @@ -0,0 +1,89 @@ +package net.tfminecraft.bartershops; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.tlibs.character.CharacterRef; + +/** Retain each click's ID until delivery completes. Interrupted delivery requires staff review. */ +final class ShopTrades { + enum Outcome { APPLIED, REFUSED, WAITING, REVIEW } + record Request(String shopId, CharacterRef customer, CharacterRef owner, String direction, + int quantity, long cents, String item) {} + private record Intent(String operationId, Request request, String state) {} + private static final Gson JSON = new GsonBuilder().setPrettyPrinting().create(); + private final Path directory; + private final Logger logger; + private final Map active = new HashMap<>(); + + ShopTrades(Path directory, Logger logger) { + this.directory = directory; + this.logger = logger; + } + + Outcome trade(Request request, Runnable deliver) { + Path file; + try { + file = directory.resolve(UUID.fromString(request.shopId()) + ".json"); + } catch (RuntimeException ex) { + return Outcome.REVIEW; + } + Intent intent = active.get(request.shopId()); + // Files from an earlier process may describe an already delivered item. Never replay them. + if (intent == null && Files.exists(file)) return Outcome.REVIEW; + if (intent != null && (!intent.request().equals(request) || !"prepared".equals(intent.state()))) + return Outcome.REVIEW; + try { + if (intent == null) { + intent = new Intent("shop:" + request.shopId() + ":" + UUID.randomUUID(), request, "prepared"); + DurableFiles.write(file, JSON.toJson(intent)); + active.put(request.shopId(), intent); + } + Result result = Denars.status(intent.operationId()).orElse(null); + if (result == null) { + boolean buy = "buy".equals(request.direction()); + CharacterRef seller = buy ? request.owner() : request.customer(); + String from = buy ? Denars.pouch(request.customer()) : Denars.bank(request.owner()); + String to = buy ? Denars.bank(request.owner()) : Denars.pouch(request.customer()); + String reason = "shop:" + request.direction(); + result = request.cents() == 0 + ? Denars.post(intent.operationId(), reason, List.of(new Leg(from, 0), new Leg(to, 0))) + : Denars.trade(intent.operationId(), reason, from, seller, to, request.cents(), true); + } + if (result == Result.REFUSED) { + DurableFiles.delete(file); + active.remove(request.shopId()); + return Outcome.REFUSED; + } + if (result == Result.UNAVAILABLE) return Outcome.WAITING; + if (result != Result.APPLIED) { + Intent conflict = new Intent(intent.operationId(), request, "conflict"); + active.put(request.shopId(), conflict); + DurableFiles.write(file, JSON.toJson(conflict)); + return Outcome.REVIEW; + } + Intent delivering = new Intent(intent.operationId(), request, "delivering"); + // Set memory first: an ambiguous disk write must also block delivery in this process. + active.put(request.shopId(), delivering); + DurableFiles.write(file, JSON.toJson(delivering)); + deliver.run(); + DurableFiles.delete(file); + active.remove(request.shopId()); + return Outcome.APPLIED; + } catch (IOException | RuntimeException ex) { + logger.severe("Shop trade requires review; retained intent " + file + ": " + ex); + return Outcome.REVIEW; + } + } +} diff --git a/src/main/java/net/tfminecraft/bartershops/sf/ShopEmbargo.java b/src/main/java/net/tfminecraft/bartershops/sf/ShopEmbargo.java index d7c992c..92aa24b 100644 --- a/src/main/java/net/tfminecraft/bartershops/sf/ShopEmbargo.java +++ b/src/main/java/net/tfminecraft/bartershops/sf/ShopEmbargo.java @@ -3,10 +3,11 @@ import java.util.UUID; import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; +import net.tfminecraft.tlibs.character.Characters; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.RelationManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -27,21 +28,23 @@ public static boolean blocked(Player customer, ShopSign shop) { return false; } try { - if (!sfReady()) { - return false; - } - Faction customerFaction = FactionManager.getByMember(customer.getName()); + Plugin plugin = Bukkit.getPluginManager().getPlugin("SimpleFactions"); + if (plugin == null) return false; + if (!plugin.isEnabled() || !SimpleFactionsApi.ready()) return true; + String character = Characters.currentSession(customer).map(session -> session.ref().characterId().toString()).orElse(null); + if (character == null) return false; + Faction customerFaction = FactionManager.getByMember(character); if (customerFaction == null) { return false; } - Faction ownerFaction = ownerFaction(shop.getOwner()); + Faction ownerFaction = ownerFaction(shop.getOwnerCharacter()); if (ownerFaction == null) { return false; } return RelationManager.hasTradeEmbargo(ownerFaction, customerFaction); } catch (RuntimeException | LinkageError ex) { warn(ex); - return false; + return true; } } @@ -55,23 +58,24 @@ private static Faction ownerFaction(String ownerUuid) { } catch (IllegalArgumentException ex) { return null; } - OfflinePlayer owner = Bukkit.getOfflinePlayer(uuid); - String name = owner.getName(); - if (name == null || name.isBlank()) { - return null; - } - return FactionManager.getByMember(name); + return FactionManager.getByMember(uuid.toString()); } - private static boolean sfReady() { - Plugin plugin = Bukkit.getPluginManager().getPlugin("SimpleFactions"); - return plugin != null && plugin.isEnabled(); - } + /** Absent optional integration is allowed; installed but unavailable factions close all trades. */ + public static boolean ready() { + try { + Plugin plugin = Bukkit.getPluginManager().getPlugin("SimpleFactions"); + return plugin == null || (plugin.isEnabled() && SimpleFactionsApi.ready()); + } catch (RuntimeException | LinkageError ex) { + warn(ex); + return false; + } + } private static void warn(Throwable ex) { if (!loggedFail && ShopMain.plugin != null) { loggedFail = true; - ShopMain.plugin.getLogger().warning("[BarterShops] SimpleFactions embargo skipped: " + ex.getMessage()); + ShopMain.plugin.getLogger().warning("[BarterShops] SimpleFactions trade blocked: " + ex.getMessage()); } } } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 13e07b8..da2cc45 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ name: BarterShops version: ${project.version} author: Drefvelin -depend: [MMOItems, MythicLib] +depend: [MMOItems, MythicLib, DenarEconomy, TLibs] softdepend: [SimpleFactions] permissions: diff --git a/src/test/java/net/tfminecraft/bartershops/DatabaseTest.java b/src/test/java/net/tfminecraft/bartershops/DatabaseTest.java index b85c172..d4315ac 100644 --- a/src/test/java/net/tfminecraft/bartershops/DatabaseTest.java +++ b/src/test/java/net/tfminecraft/bartershops/DatabaseTest.java @@ -97,14 +97,16 @@ void loadsEverySavedFieldForTheShopAtTheSign() throws Exception { assertEquals(16, shop.getBarterAmount()); assertEquals(40, shop.getPrice()); assertEquals("sword.katana", shop.getPaymentItemString()); - assertEquals(OWNER, shop.getOwner()); + assertEquals(OWNER, shop.getOwnerAccount()); assertEquals("sell", shop.getType()); } @Test void readsTheOwnerEvenWhenTheTermsAreInvalid() throws Exception { writeShop("0.0", "10.0"); - assertEquals(OWNER, new Database(directory.toFile()).getOwnerFromLoc(new Location(world, 1, 2, 3))); + Path file = directory.resolve("shop.json"); + Files.writeString(file, Files.readString(file).replace("\"owner\":", "\"owner character\": \"" + OWNER + "\", \"owner\":")); + assertEquals(OWNER, new Database(directory.toFile()).getOwnerCharacterFromLoc(new Location(world, 1, 2, 3))); } @Test @@ -117,7 +119,7 @@ void lookupsSkipFoldersUnreadableFilesAndOtherSigns() throws Exception { assertNull(database.getShopFromLoc(elsewhere)); assertFalse(database.shopExistsFromLoc(elsewhere)); - assertNull(database.getOwnerFromLoc(elsewhere)); + assertNull(database.getOwnerCharacterFromLoc(elsewhere)); database.deleteFile(elsewhere); assertTrue(Files.exists(directory.resolve("shop.json"))); @@ -176,12 +178,14 @@ void savedShopsLoadBackUnchanged() throws Exception { assertTrue(files[0].getName().matches("[0-9a-f-]{36}\\.json")); assertTrue(database.shopExistsFromLoc(saved.getSignLoc())); ShopSign loaded = database.getShopFromLoc(saved.getSignLoc()); + assertEquals(saved.getShopId(), loaded.getShopId()); + assertEquals(saved.getOwnerCharacter(), loaded.getOwnerCharacter()); assertEquals(saved.getSignLoc(), loaded.getSignLoc()); assertEquals(saved.getStorageLoc(), loaded.getStorageLoc()); assertEquals(16, loaded.getBarterAmount()); assertEquals(40, loaded.getPrice()); assertEquals("sword.katana", loaded.getPaymentItemString()); - assertEquals(OWNER, loaded.getOwner()); + assertEquals(OWNER, loaded.getOwnerAccount()); assertEquals("buy", loaded.getType()); } @@ -331,7 +335,8 @@ private ShopSign shop() { shop.setBarterAmount(16); shop.setPrice(40); shop.setPaymentItem("sword.katana"); - shop.setOwner(OWNER); + shop.setOwnerAccount(OWNER); + shop.setOwnerCharacter("00000000-0000-0000-0000-000000000101"); shop.setType("buy"); return shop; } diff --git a/src/test/java/net/tfminecraft/bartershops/ShopEventsTest.java b/src/test/java/net/tfminecraft/bartershops/ShopEventsTest.java index 7474bda..4d7f363 100644 --- a/src/test/java/net/tfminecraft/bartershops/ShopEventsTest.java +++ b/src/test/java/net/tfminecraft/bartershops/ShopEventsTest.java @@ -67,12 +67,17 @@ import io.papermc.paper.registry.RegistryAccess; import net.tfminecraft.bartershops.sf.ShopEmbargo; -import net.tfminecraft.denareconomy.DenarEconomy; -import net.tfminecraft.denareconomy.data.Account; -import net.tfminecraft.denareconomy.data.PlayerData; -import net.tfminecraft.denareconomy.enums.Accounts; -import net.tfminecraft.denareconomy.managers.MoneyManager; -import net.tfminecraft.denareconomy.managers.PlayerManager; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import net.tfminecraft.tlibs.character.Characters; +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.Optional; +import java.util.logging.Logger; +import org.junit.jupiter.api.io.TempDir; + class ShopEventsTest { @SuppressWarnings("deprecation") @@ -99,6 +104,8 @@ void validCreationStillCompletesIncludingFreeShops(int quantity, int price) { when(location.getBlock()).thenReturn(block); when(block.getState()).thenReturn(sign); ShopSign shop = new ShopSign(); + shop.setOwnerAccount(CUSTOMER.toString()); + shop.setOwnerCharacter(CUSTOMER_CHARACTER.toString()); shop.setSignLoc(location); shop.setType("buy"); events.currentShop.put(player, shop); @@ -225,10 +232,13 @@ private void assertShopRejected(ShopSign shop) { private Location signLocation; private Sign sign; private MockedStatic embargo; - private MockedStatic denar; - private Account pouch; - private Account bank; - private MoneyManager money; + private MockedStatic denar; + private MockedStatic characters; + @TempDir Path tradeDirectory; + private static final UUID OWNER_CHARACTER = UUID.fromString("00000000-0000-0000-0000-000000000101"); + private static final UUID CUSTOMER_CHARACTER = UUID.fromString("00000000-0000-0000-0000-000000000102"); + private static final CharacterRef OWNER_REF = new CharacterRef(OWNER, OWNER_CHARACTER); + private static final CharacterRef CUSTOMER_REF = new CharacterRef(CUSTOMER, CUSTOMER_CHARACTER); /** * Sound constants resolve through Paper's server registry when the interface is first loaded, @@ -307,23 +317,22 @@ void setUpListener() { when(block.getRelative(any(BlockFace.class))).thenReturn(air); signLines("[Buy]"); embargo = mockStatic(ShopEmbargo.class); - denar = mockStatic(DenarEconomy.class); - PlayerManager players = mock(PlayerManager.class); - PlayerData customerData = mock(PlayerData.class); - PlayerData ownerData = mock(PlayerData.class); - pouch = mock(Account.class); - bank = mock(Account.class); - money = mock(MoneyManager.class); - denar.when(DenarEconomy::getPlayerManager).thenReturn(players); - denar.when(DenarEconomy::getMoneyManager).thenReturn(money); - when(players.get(player)).thenReturn(customerData); - when(players.get(OWNER)).thenReturn(ownerData); - when(customerData.getPouch()).thenReturn(pouch); - when(ownerData.getBank()).thenReturn(bank); + embargo.when(ShopEmbargo::ready).thenReturn(true); + characters = mockStatic(Characters.class); + characters.when(Characters::available).thenReturn(true); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + characters.when(() -> Characters.currentSession(any(Player.class))).thenReturn(Optional.of(new CharacterSession(CUSTOMER_REF, 1))); + denar = mockStatic(Denars.class); + denar.when(() -> Denars.pouch(any())).thenAnswer(call -> "char:" + call.getArgument(0).characterId() + ":pouch"); + denar.when(() -> Denars.bank(any())).thenAnswer(call -> "char:" + call.getArgument(0).characterId() + ":bank"); + tradeResult(Result.APPLIED); + denar.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.APPLIED); + events.trades = new ShopTrades(tradeDirectory, Logger.getAnonymousLogger()); } @AfterEach void closeStatics() { + characters.close(); denar.close(); embargo.close(); } @@ -351,7 +360,7 @@ void clickingASignWithRedstoneStartsCreation(Integer stage, String firstLine, St assertEquals(1, events.shopCreating.get(player)); ShopSign shop = events.currentShop.get(player); assertEquals(type, shop.getType()); - assertEquals(CUSTOMER.toString(), shop.getOwner()); + assertEquals(CUSTOMER.toString(), shop.getOwnerAccount()); assertEquals(signLocation, shop.getSignLoc()); verify(player).sendTitle("Shop Creation §e1/4", "§aRight Click on the storage chest with the redstone", 5, 80, 6); } @@ -621,12 +630,10 @@ void buyingTakesStockAndPaysTheOwner() { Inventory storage = storage(tradingShop("buy")); ItemStack[] stock = stock(storage, null, new Stack(Material.AIR, 0), new Stack(Material.DIRT, 8)); List bought = received(inventory); - when(pouch.getBal()).thenReturn(10.0); events.useShop(click(block)); - verify(pouch).change(-10.0); - verify(money).addMoneyToAccount(OWNER.toString(), 10.0, true, true, Accounts.BANK); + verifyTrade("buy"); assertEquals(new Stack(Material.DIRT, 3), stock[2]); assertEquals(List.of(new Stack(Material.DIRT, 5)), bought); verify(player).playSound(playerLocation, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1f, 1f); @@ -639,7 +646,6 @@ void buyingDrawsFromSeveralMatchingStacks() { ItemStack[] stock = stock(storage, new Stack(Material.DIRT, 3), new Stack(Material.STONE, 2), new Stack(Material.DIRT, 4)); List bought = received(inventory); - when(pouch.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -654,7 +660,6 @@ void buyingAWholeStackEmptiesItsSlot() { Inventory storage = storage(tradingShop("buy")); ItemStack[] stock = stock(storage, new Stack(Material.DIRT, 5), new Stack(Material.DIRT, 5)); List bought = received(inventory); - when(pouch.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -666,7 +671,6 @@ void buyingAWholeStackEmptiesItsSlot() { @Test void buyingNeedsEnoughStock() { stock(storage(tradingShop("buy")), new Stack(Material.DIRT, 4)); - when(pouch.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -678,7 +682,6 @@ void otherItemsInTheChestDoNotCountAsStock() { // Previously the dirt and stone were counted together: the buyer paid for five and got three dirt. stock(storage(tradingShop("buy")), new Stack(Material.DIRT, 3), new Stack(Material.STONE, 2)); List bought = received(inventory); - when(pouch.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -689,7 +692,6 @@ void otherItemsInTheChestDoNotCountAsStock() { @Test void emptyChestsAreOutOfStock() { stock(storage(tradingShop("buy")), null, new Stack(Material.AIR, 0)); - when(pouch.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -701,7 +703,6 @@ void buyingNeedsRoomForTheWholeOrder() { // Previously one free slot was enough, and whatever did not fit was lost after payment. stock(storage(tradingShop("buy")), new Stack(Material.DIRT, 5)); stock(inventory, new Stack(Material.DIRT, 62), new Stack(Material.STONE, 64)); - when(pouch.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -713,18 +714,17 @@ void buyingCanTopUpPartStacks() { stock(storage(tradingShop("buy")), new Stack(Material.DIRT, 5)); stock(inventory, new Stack(Material.DIRT, 59), new Stack(Material.STONE, 64)); List bought = received(inventory); - when(pouch.getBal()).thenReturn(10.0); events.useShop(click(block)); - verify(pouch).change(-10.0); + verifyTrade("buy"); assertEquals(List.of(new Stack(Material.DIRT, 5)), bought); } @Test void buyingNeedsEnoughMoney() { stock(storage(tradingShop("buy")), new Stack(Material.DIRT, 5)); - when(pouch.getBal()).thenReturn(9.99); + tradeResult(Result.REFUSED); events.useShop(click(block)); @@ -741,12 +741,10 @@ void sellingTakesTheCustomersItemsAndPaysThem() { ItemStack[] held = stock(inventory, null, new Stack(Material.AIR, 0), new Stack(Material.DIRT, 3), new Stack(Material.STONE, 1), new Stack(Material.DIRT, 4)); - when(bank.getBal()).thenReturn(10.0); events.useShop(click(block)); - verify(money).changeBal(OWNER.toString(), -10.0, Accounts.BANK); - verify(money).addMoney(player, 10.0, false, true); + verifyTrade("sell"); assertNull(held[2]); assertEquals(new Stack(Material.STONE, 1), held[3]); assertEquals(new Stack(Material.DIRT, 2), held[4]); @@ -759,7 +757,6 @@ null, new Stack(Material.AIR, 0), new Stack(Material.DIRT, 3), new Stack(Materia void sellingNeedsRoomInTheChestForTheWholeOrder() { stock(storage(tradingShop("sell")), new Stack(Material.DIRT, 62), new Stack(Material.STONE, 64)); stock(inventory, new Stack(Material.DIRT, 5)); - when(bank.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -782,7 +779,7 @@ void sellingNeedsInventorySpace() { void sellingNeedsTheOwnerToAffordIt() { stock(storage(tradingShop("sell")), new Stack(Material.DIRT, 1)); stock(inventory, new Stack(Material.DIRT, 5)); - when(bank.getBal()).thenReturn(9.99); + tradeResult(Result.REFUSED); events.useShop(click(block)); @@ -792,7 +789,6 @@ void sellingNeedsTheOwnerToAffordIt() { @Test void sellingNeedsAnItemInTheStorage() { stock(storage(tradingShop("sell")), null, new Stack(Material.AIR, 0)); - when(bank.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -803,7 +799,6 @@ void sellingNeedsAnItemInTheStorage() { void sellingNeedsEnoughMatchingItems() { stock(storage(tradingShop("sell")), new Stack(Material.DIRT, 1)); stock(inventory, new Stack(Material.DIRT, 4), new Stack(Material.STONE, 5)); - when(bank.getBal()).thenReturn(10.0); events.useShop(click(block)); @@ -1128,7 +1123,7 @@ void otherPlayersCannotBreakAWallHangingShopSign() { private void shopOwnedBy(Location location, UUID owner) { when(events.db.shopExistsFromLoc(location)).thenReturn(true); - when(events.db.getOwnerFromLoc(location)).thenReturn(owner.toString()); + when(events.db.getOwnerCharacterFromLoc(location)).thenReturn((owner.equals(CUSTOMER) ? CUSTOMER_CHARACTER : OWNER_CHARACTER).toString()); } private static BlockDestroyEvent destroyed(Block b) { @@ -1272,6 +1267,114 @@ void exchangingStopsWhenTheSenderRunsOut() { assertEquals(List.of(new Stack(Material.DIRT, 3)), moved); } + @Test + void anotherCharacterOnTheSameAccountCannotBreakTheShop() { + shopOwnedBy(signLocation, CUSTOMER); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of( + new CharacterSession(new CharacterRef(CUSTOMER, UUID.randomUUID()), 2))); + BlockBreakEvent event = new BlockBreakEvent(block, player); + fire(event); + assertTrue(event.isCancelled()); + verify(events.db, never()).deleteFile(any()); + } + + @Test + void unmigratedShopCannotTradeOrBeBrokenExceptByStaff() { + ShopSign shop = tradingShop("buy"); + shop.setOwnerCharacter(null); + events.useShop(click(block)); + denar.verifyNoInteractions(); + BlockBreakEvent event = new BlockBreakEvent(block, player); + fire(event); + assertTrue(event.isCancelled()); + when(player.hasPermission(ShopEvents.ADMIN_PERMISSION)).thenReturn(true); + BlockBreakEvent staff = new BlockBreakEvent(block, player); + fire(staff); + assertFalse(staff.isCancelled()); + } + + @Test + void creationRequiresAnActiveCharacter() { + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.empty()); + events.createShopEvent(click(block)); + assertTrue(events.currentShop.isEmpty()); + assertTrue(events.shopCreating.isEmpty()); + } + + @Test + void switchingCharactersCancelsTheStorageCreationStep() { + creatingShop(); + characters.when(() -> Characters.currentSession(player)).thenReturn(Optional.of( + new CharacterSession(new CharacterRef(CUSTOMER, UUID.randomUUID()), 2))); + events.createShopEvent(click(chestAt(new Location(world, 1, 64, 0)))); + assertTrue(events.currentShop.isEmpty()); + assertTrue(events.shopCreating.isEmpty()); + verify(events.db, never()).saveShop(any()); + } + + @Test + void unavailablePaymentLeavesItemsUntilTheSameIntentApplies() throws Exception { + ItemStack[] stock = stock(storage(tradingShop("buy")), new Stack(Material.DIRT, 10)); + List bought = received(inventory); + tradeResult(Result.UNAVAILABLE); + events.useShop(click(block)); + assertTrue(bought.isEmpty()); + assertEquals(10, stock[0].getAmount()); + Path file = tradeDirectory.resolve("00000000-0000-0000-0000-000000000003.json"); + String before = Files.readString(file); + tradeResult(Result.APPLIED); + events.useShop(click(block)); + assertEquals(5, stock[0].getAmount()); + assertEquals(List.of(new Stack(Material.DIRT, 5)), bought); + assertFalse(Files.exists(file)); + assertTrue(before.contains("prepared")); + } + + @Test + void changesMadeByPaymentEventsFreezeDeliveryAndCannotReplayIt() throws Exception { + ItemStack[] stock = stock(storage(tradingShop("buy")), new Stack(Material.DIRT, 10)); + List bought = received(inventory); + denar.when(() -> Denars.trade(anyString(), anyString(), anyString(), any(), anyString(), anyLong(), anyBoolean())) + .thenAnswer(call -> { stock[0].setAmount(9); return Result.APPLIED; }); + events.useShop(click(block)); + events.useShop(click(block)); + assertTrue(bought.isEmpty()); + assertEquals(9, stock[0].getAmount()); + denar.verify(() -> Denars.trade(anyString(), anyString(), anyString(), any(), anyString(), anyLong(), anyBoolean()), times(1)); + } + + @Test + void factionMigrationWaitBlocksPostingUntilTheProviderIsReady() { + ItemStack[] stock = stock(storage(tradingShop("buy")), new Stack(Material.DIRT, 10)); + List bought = received(inventory); + embargo.when(ShopEmbargo::ready).thenReturn(false); + events.useShop(click(block)); + denar.verifyNoInteractions(); + assertTrue(bought.isEmpty()); + assertEquals(10, stock[0].getAmount()); + embargo.when(ShopEmbargo::ready).thenReturn(true); + events.useShop(click(block)); + assertEquals(5, stock[0].getAmount()); + assertEquals(List.of(new Stack(Material.DIRT, 5)), bought); + verifyTrade("buy"); + embargo.when(ShopEmbargo::ready).thenReturn(false); + events.useShop(click(block)); + assertEquals(5, stock[0].getAmount()); + verifyTrade("buy"); + } + + @Test + void freeShopMovesItemsOnlyAfterItsLedgerPostingApplies() { + ShopSign shop = tradingShop("buy"); + shop.setPrice(0); + ItemStack[] stock = stock(storage(shop), new Stack(Material.DIRT, 10)); + List bought = received(inventory); + events.useShop(click(block)); + assertEquals(5, stock[0].getAmount()); + assertEquals(List.of(new Stack(Material.DIRT, 5)), bought); + denar.verify(() -> Denars.post(startsWith("shop:"), eq("shop:buy"), anyList())); + } + // --- Fixture helpers --- private void startAt(Integer stage) { @@ -1305,6 +1408,8 @@ private PlayerInteractEvent click(Block clicked) { private ShopSign creatingShop() { ShopSign shop = new ShopSign(); + shop.setOwnerAccount(CUSTOMER.toString()); + shop.setOwnerCharacter(CUSTOMER_CHARACTER.toString()); shop.setSignLoc(signLocation); events.shopCreating.put(player, 1); events.currentShop.put(player, shop); @@ -1330,12 +1435,16 @@ private ShopSign tradingShop(String type) { when(chest.getInventory()).thenReturn(storage); stock(storage); ShopSign shop = new ShopSign(); + shop.setOwnerAccount(CUSTOMER.toString()); + shop.setOwnerCharacter(CUSTOMER_CHARACTER.toString()); shop.setSignLoc(signLocation); shop.setStorageLoc(storageLocation); shop.setType(type); shop.setBarterAmount(5); shop.setPrice(10); - shop.setOwner(OWNER.toString()); + shop.setOwnerAccount(OWNER.toString()); + shop.setOwnerCharacter(OWNER_CHARACTER.toString()); + shop.setShopId("00000000-0000-0000-0000-000000000003"); when(events.db.shopExistsFromLoc(signLocation)).thenReturn(true); when(events.db.getShopFromLoc(signLocation)).thenReturn(shop); holding(Material.AIR); @@ -1366,11 +1475,24 @@ private static List received(Inventory inventory) { return items; } + private void tradeResult(Result result) { + denar.when(() -> Denars.trade(anyString(), anyString(), anyString(), any(), anyString(), anyLong(), anyBoolean())) + .thenReturn(result); + } + + private void verifyTrade(String direction) { + boolean buy = direction.equals("buy"); + denar.verify(() -> Denars.trade(startsWith("shop:"), eq("shop:" + direction), + eq(buy ? "char:" + CUSTOMER_CHARACTER + ":pouch" : "char:" + OWNER_CHARACTER + ":bank"), + eq(buy ? OWNER_REF : CUSTOMER_REF), + eq(buy ? "char:" + OWNER_CHARACTER + ":bank" : "char:" + CUSTOMER_CHARACTER + ":pouch"), eq(1000L), eq(true))); + } + private void assertRefused(String message) { verify(player).sendMessage(message); verify(player).playSound(playerLocation, Sound.ENTITY_VILLAGER_NO, 1f, 1f); - verify(pouch, never()).change(anyDouble()); - verifyNoInteractions(money); + verify(inventory, never()).addItem(any(ItemStack[].class)); + verify(inventory, never()).setItem(anyInt(), any()); } /** @@ -1426,6 +1548,11 @@ public int hashCode() { return Objects.hash(type, amount); } + @Override + public byte[] serializeAsBytes() { + return (type.name() + ":" + amount).getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + @Override public String toString() { return type + " x" + amount; diff --git a/src/test/java/net/tfminecraft/bartershops/ShopMainTest.java b/src/test/java/net/tfminecraft/bartershops/ShopMainTest.java index 2a46b65..ce60a56 100644 --- a/src/test/java/net/tfminecraft/bartershops/ShopMainTest.java +++ b/src/test/java/net/tfminecraft/bartershops/ShopMainTest.java @@ -1,127 +1,46 @@ package net.tfminecraft.bartershops; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; - -import java.io.File; -import java.io.InputStream; -import java.lang.reflect.Field; import java.nio.file.Path; - -import org.bukkit.Bukkit; +import java.util.logging.Logger; import org.bukkit.Server; -import org.bukkit.UnsafeValues; import org.bukkit.plugin.PluginManager; -import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.mockito.MockedStatic; - -import io.papermc.paper.plugin.configuration.PluginMeta; -import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader; -import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup; +import net.tfminecraft.tlibs.character.Characters; class ShopMainTest { - @TempDir Path dataFolder; + @TempDir Path directory; @Test - void enablingRegistersTheShopListenerAndPublishesThePlugin() throws Exception { + void enablesProtectionButWaitsForHeirsBeforeOpeningShops() { + ShopMain plugin = mock(ShopMain.class); Server server = mock(Server.class); PluginManager plugins = mock(PluginManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getServer()).thenReturn(server); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); when(server.getPluginManager()).thenReturn(plugins); - try (MockedStatic bukkit = mockStatic(Bukkit.class); - PluginLoader loader = new PluginLoader(server, dataFolder.toFile())) { - bukkit.when(Bukkit::getUnsafe).thenReturn(mock(UnsafeValues.class)); - Class main = loader.loadClass(ShopMain.class.getName()); - JavaPlugin plugin = (JavaPlugin) main.getDeclaredConstructor().newInstance(); - + when(server.getScheduler()).thenReturn(scheduler); + plugin.events = new ShopEvents(); + doCallRealMethod().when(plugin).onEnable(); + ShopMain previous = ShopMain.plugin; + try (var characters = mockStatic(Characters.class)) { plugin.onEnable(); - - assertSame(plugin, main.getField("plugin").get(null)); - verify(plugins).registerEvents(any(ShopEvents.class), same(plugin)); - } - } - - /** - * JavaPlugin only accepts instances defined by a Paper plugin class loader. This one defines - * ShopMain itself and delegates everything else, so the listener and API types stay shared. - */ - private static final class PluginLoader extends ClassLoader implements ConfiguredPluginClassLoader { - private final Server server; - private final File dataFolder; - private JavaPlugin plugin; - - PluginLoader(Server server, File dataFolder) { - super(ShopMainTest.class.getClassLoader()); - this.server = server; - this.dataFolder = dataFolder; - } - - @Override - protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { - if (!name.equals(ShopMain.class.getName())) { - return super.loadClass(name, resolve); - } - synchronized (getClassLoadingLock(name)) { - Class loaded = findLoadedClass(name); - if (loaded != null) { - return loaded; - } - try (InputStream in = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) { - byte[] bytes = in.readAllBytes(); - // Keep ShopMain's code source so the coverage agent still instruments this copy. - return defineClass(name, bytes, 0, bytes.length, ShopMain.class.getProtectionDomain()); - } catch (Exception ex) { - throw new ClassNotFoundException(name, ex); - } - } - } - - @Override - public Class loadClass(String name, boolean resolve, boolean checkGlobal, boolean checkLibraries) - throws ClassNotFoundException { - return loadClass(name, resolve); - } - - @Override - public void init(JavaPlugin plugin) { - this.plugin = plugin; - // JavaPlugin.init needs Paper's server-side PluginLoader service, so set only the - // fields ShopMain reaches: getServer(), and getConfig()'s file and resource lookup. - set(plugin, "server", server); - set(plugin, "dataFolder", dataFolder); - set(plugin, "configFile", new File(dataFolder, "config.yml")); - set(plugin, "classLoader", this); - } - - private static void set(JavaPlugin plugin, String name, Object value) { - try { - Field field = JavaPlugin.class.getDeclaredField(name); - field.setAccessible(true); - field.set(plugin, value); - } catch (ReflectiveOperationException ex) { - throw new IllegalStateException(ex); - } - } - - @Override - public PluginMeta getConfiguration() { - return null; - } - - @Override - public JavaPlugin getPlugin() { - return plugin; - } - - @Override - public PluginClassLoaderGroup getGroup() { - return null; - } - - @Override - public void close() { + assertSame(plugin, ShopMain.plugin); + assertFalse(plugin.events.ready.getAsBoolean()); + verify(plugins).registerEvents(plugin.events, plugin); + var task = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskTimer(eq(plugin), task.capture(), eq(100L), eq(100L)); + characters.when(Characters::available).thenReturn(true); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + task.getValue().run(); + assertTrue(plugin.events.ready.getAsBoolean()); + } finally { + ShopMain.plugin = previous; } } } diff --git a/src/test/java/net/tfminecraft/bartershops/ShopMigrationTest.java b/src/test/java/net/tfminecraft/bartershops/ShopMigrationTest.java new file mode 100644 index 0000000..0fb0342 --- /dev/null +++ b/src/test/java/net/tfminecraft/bartershops/ShopMigrationTest.java @@ -0,0 +1,186 @@ +package net.tfminecraft.bartershops; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.Characters; + +class ShopMigrationTest { + @TempDir Path directory; + private final UUID account = UUID.randomUUID(); + private final CharacterRef heir = new CharacterRef(account, UUID.randomUUID()); + private final Logger logger = mock(Logger.class); + + private Path file() throws Exception { + Path file = directory.resolve("Data/" + UUID.randomUUID() + ".json"); + Files.createDirectories(file.getParent()); + Files.writeString(file, "{\"owner\":\"" + account + "\",\"untouched\":{\"x\":12.3},\"price\":10}"); + return file; + } + private ShopMigration migration() { + return new ShopMigration(directory.resolve("Data").toFile(), directory.resolve("migrations").toFile(), logger); + } + + @Test + void waitingDoesNotWriteAnythingOrAskForAnHeir() throws Exception { + Path file = file(); + String original = Files.readString(file); + try (var characters = mockStatic(Characters.class)) { + ShopMigration migration = migration(); + assertFalse(migration.runWhenReady()); + assertFalse(migration.runWhenReady()); + assertEquals(original, Files.readString(file)); + assertFalse(Files.exists(directory.resolve("migrations"))); + characters.verify(() -> Characters.legacyHeir(any()), never()); + verify(logger, times(1)).severe(anyString()); + } + } + + @Test + void backsUpExactOriginalAndKeepsShopIdAndUnknownData() throws Exception { + Path file = file(); + byte[] original = Files.readAllBytes(file); + try (var characters = mockStatic(Characters.class)) { + characters.when(Characters::available).thenReturn(true); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + characters.when(() -> Characters.legacyHeir(account)).thenReturn(Optional.of(heir)); + assertTrue(migration().runWhenReady()); + assertArrayEquals(original, Files.readAllBytes(directory.resolve("migrations/backup").resolve(file.getFileName()))); + String migrated = Files.readString(file); + assertTrue(migrated.contains(heir.characterId().toString())); + assertTrue(migrated.contains("untouched")); + assertTrue(migrated.contains("12.3")); + assertTrue(Files.readString(directory.resolve("migrations/character-v1").resolve(file.getFileName())).contains("done")); + assertTrue(migration().runWhenReady()); + assertEquals(migrated, Files.readString(file)); + characters.verify(() -> Characters.legacyHeir(account), times(1)); + } + } + + @Test + void unassignedOwnerIsKeptAndQuarantinedWithItsFullOriginal() throws Exception { + Path file = file(); + String original = Files.readString(file); + try (var characters = mockStatic(Characters.class)) { + characters.when(Characters::available).thenReturn(true); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + assertTrue(migration().runWhenReady()); + assertEquals(original, Files.readString(file)); + String entry = Files.readString(directory.resolve("migrations/character-v1").resolve(file.getFileName())); + assertTrue(entry.contains("quarantined")); + assertTrue(entry.contains("untouched")); + assertTrue(Files.readString(directory.resolve("migrations/quarantine.txt")).contains(account.toString())); + characters.when(() -> Characters.legacyHeir(account)).thenReturn(Optional.of(heir)); + assertTrue(migration().runWhenReady()); + assertFalse(Files.exists(directory.resolve("migrations/quarantine.txt"))); + assertTrue(Files.readString(file).contains(heir.characterId().toString())); + assertEquals(original, Files.readString(directory.resolve("migrations/backup").resolve(file.getFileName()))); + } + } + + @Test + void interruptedStoreRestoresTheVerifiedBackupBeforeRedoing() throws Exception { + Path file = file(); + byte[] original = Files.readAllBytes(file); + Path backup = directory.resolve("migrations/backup").resolve(file.getFileName()); + DurableFiles.write(backup, original); + String sha = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(original)); + Path manifest = directory.resolve("migrations/character-v1").resolve(file.getFileName()); + DurableFiles.write(manifest, "{\"state\":\"in-progress\",\"sha256\":\"" + sha + "\"}"); + Files.writeString(file, "{\"partial\":true}"); + try (var characters = mockStatic(Characters.class)) { + characters.when(Characters::available).thenReturn(true); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + characters.when(() -> Characters.legacyHeir(account)).thenReturn(Optional.of(heir)); + assertTrue(migration().runWhenReady()); + assertTrue(Files.readString(file).contains(heir.characterId().toString())); + assertTrue(Files.readString(file).contains("untouched")); + assertArrayEquals(original, Files.readAllBytes(backup)); + } + } + + @Test + void mismatchedBackupFailsClosedAndDoesNotRewriteSource() throws Exception { + Path file = file(); + String original = Files.readString(file); + Path backup = directory.resolve("migrations/backup").resolve(file.getFileName()); + DurableFiles.write(backup, "tampered"); + try (var characters = mockStatic(Characters.class)) { + characters.when(Characters::available).thenReturn(true); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + assertFalse(migration().runWhenReady()); + assertEquals(original, Files.readString(file)); + assertEquals("tampered", Files.readString(backup)); + characters.verify(() -> Characters.legacyHeir(any()), never()); + } + } + + @Test + void backupWriteFailureCannotRewriteTheLegacyShop() throws Exception { + Path file = file(); + String original = Files.readString(file); + Path migrations = directory.resolve("migrations"); + Files.createDirectories(migrations); + Files.writeString(migrations.resolve("backup"), "occupied"); + try (var characters = mockStatic(Characters.class)) { + characters.when(Characters::available).thenReturn(true); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + assertFalse(migration().runWhenReady()); + assertEquals(original, Files.readString(file)); + } + } + + @Test + void oneUnreadableShopIsQuarantinedWithoutClosingEveryShop() throws Exception { + Path good = file(); + Path broken = directory.resolve("Data/broken.json"); + Files.writeString(broken, "{"); + try (var characters = mockStatic(Characters.class)) { + characters.when(Characters::available).thenReturn(true); + characters.when(Characters::legacyHeirsReady).thenReturn(true); + characters.when(() -> Characters.legacyHeir(account)).thenReturn(Optional.of(heir)); + ShopMigration migration = migration(); + assertTrue(migration.runWhenReady()); + assertTrue(Files.readString(good).contains(heir.characterId().toString())); + assertEquals("{", Files.readString(broken)); + assertTrue(Files.readString(directory.resolve("migrations/quarantine.txt")).contains("broken.json")); + assertTrue(migration.runWhenReady()); + verify(logger, times(1)).warning(contains("broken.json")); + } + } + + @Test + void unreadableMigrationRecordLeavesItsShopUntouched() throws Exception { + Path file = file(); + String original = Files.readString(file); + Path manifest = directory.resolve("migrations/character-v1").resolve(file.getFileName()); + Files.createDirectories(manifest.getParent()); + Files.writeString(manifest, "{"); + assertEquals(1, migration().migrate().size()); + assertEquals(original, Files.readString(file)); + assertTrue(Files.readString(directory.resolve("migrations/quarantine.txt")) + .contains("unreadable migration record")); + } + + @Test + void invalidCharacterIsFrozenRatherThanAssignedToAnotherCharacter() throws Exception { + Path file = file(); + String invalid = "{\"owner\":\"" + account + "\",\"owner character\":\"broken\"}"; + Files.writeString(file, invalid); + try (var characters = mockStatic(Characters.class)) { + assertEquals(1, migration().migrate().size()); + assertEquals(invalid, Files.readString(file)); + characters.verifyNoInteractions(); + } + } +} diff --git a/src/test/java/net/tfminecraft/bartershops/ShopSignTest.java b/src/test/java/net/tfminecraft/bartershops/ShopSignTest.java index 47cb3f1..f696fe1 100644 --- a/src/test/java/net/tfminecraft/bartershops/ShopSignTest.java +++ b/src/test/java/net/tfminecraft/bartershops/ShopSignTest.java @@ -24,7 +24,7 @@ void storesEveryField() { shop.setStorageLoc(storage); shop.setBarterAmount(16); shop.setPrice(40); - shop.setOwner("00000000-0000-0000-0000-000000000001"); + shop.setOwnerAccount("00000000-0000-0000-0000-000000000001"); shop.setPaymentItem("sword.katana"); shop.setType("sell"); @@ -32,7 +32,7 @@ void storesEveryField() { assertSame(storage, shop.getStorageLoc()); assertEquals(16, shop.getBarterAmount()); assertEquals(40, shop.getPrice()); - assertEquals("00000000-0000-0000-0000-000000000001", shop.getOwner()); + assertEquals("00000000-0000-0000-0000-000000000001", shop.getOwnerAccount()); assertEquals("sword.katana", shop.getPaymentItemString()); assertEquals("sell", shop.getType()); } diff --git a/src/test/java/net/tfminecraft/bartershops/ShopTradesTest.java b/src/test/java/net/tfminecraft/bartershops/ShopTradesTest.java new file mode 100644 index 0000000..e4f9085 --- /dev/null +++ b/src/test/java/net/tfminecraft/bartershops/ShopTradesTest.java @@ -0,0 +1,175 @@ +package net.tfminecraft.bartershops; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.tlibs.character.CharacterRef; + +class ShopTradesTest { + @TempDir Path directory; + private final String shopId = UUID.randomUUID().toString(); + private final CharacterRef customer = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + private final CharacterRef owner = new CharacterRef(UUID.randomUUID(), UUID.randomUUID()); + private final Logger logger = mock(Logger.class); + + private ShopTrades.Request request() { + return new ShopTrades.Request(shopId, customer, owner, "buy", 5, 1000, "DIRT"); + } + private Path intent() { return directory.resolve(shopId + ".json"); } + + @Test + void recordsIntentBeforePostingAndDeletesItOnlyAfterDelivery() throws Exception { + ShopTrades trades = new ShopTrades(directory, logger); + AtomicInteger delivered = new AtomicInteger(); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.status(anyString())).thenReturn(Optional.empty()); + denars.when(() -> Denars.trade(anyString(), anyString(), anyString(), any(), anyString(), anyLong(), anyBoolean())) + .thenAnswer(call -> { + String json = Files.readString(intent()); + assertTrue(json.contains(call.getArgument(0))); + assertTrue(json.contains("prepared")); + assertEquals(0, delivered.get()); + assertEquals("char:" + owner.characterId() + ":bank", call.getArgument(4)); + assertEquals(owner, call.getArgument(3)); + assertEquals(true, call.getArgument(6)); + return Result.APPLIED; + }); + assertEquals(ShopTrades.Outcome.APPLIED, trades.trade(request(), () -> { + try { assertTrue(Files.readString(intent()).contains("delivering")); } + catch (IOException ex) { fail(ex); } + delivered.incrementAndGet(); + })); + assertEquals(1, delivered.get()); + assertFalse(Files.exists(intent())); + } + } + + @Test + void unavailableRetriesReuseTheDurableOperationId() throws Exception { + ShopTrades trades = new ShopTrades(directory, logger); + AtomicInteger delivered = new AtomicInteger(); + List ids = new ArrayList<>(); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.status(anyString())).thenReturn(Optional.empty()); + denars.when(() -> Denars.trade(anyString(), anyString(), anyString(), any(), anyString(), anyLong(), anyBoolean())) + .thenAnswer(call -> { ids.add(call.getArgument(0)); return ids.size() == 1 ? Result.UNAVAILABLE : Result.APPLIED; }); + assertEquals(ShopTrades.Outcome.WAITING, trades.trade(request(), delivered::incrementAndGet)); + assertTrue(Files.exists(intent())); + assertEquals(0, delivered.get()); + assertEquals(ShopTrades.Outcome.APPLIED, trades.trade(request(), delivered::incrementAndGet)); + assertEquals(2, ids.size()); + assertEquals(ids.get(0), ids.get(1)); + assertEquals(1, delivered.get()); + } + } + + @Test + void appliedPostingIsNotRepricedWhenTaxOrAnEventFailsAfterCommit() { + ShopTrades trades = new ShopTrades(directory, logger); + AtomicInteger delivered = new AtomicInteger(); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.status(anyString())).thenReturn(Optional.empty(), Optional.of(Result.APPLIED)); + denars.when(() -> Denars.trade(anyString(), anyString(), anyString(), any(), anyString(), anyLong(), anyBoolean())) + .thenThrow(new IllegalStateException("event after commit")); + assertEquals(ShopTrades.Outcome.REVIEW, trades.trade(request(), delivered::incrementAndGet)); + assertEquals(ShopTrades.Outcome.APPLIED, trades.trade(request(), delivered::incrementAndGet)); + assertEquals(1, delivered.get()); + denars.verify(() -> Denars.trade(anyString(), anyString(), anyString(), any(), anyString(), anyLong(), anyBoolean()), times(1)); + } + } + + @Test + void failedDeliveryCannotReplayOrRechargeInThisProcessOrAfterRestart() throws Exception { + ShopTrades trades = new ShopTrades(directory, logger); + AtomicInteger delivered = new AtomicInteger(); + try (var denars = mockStatic(Denars.class)) { + denars.when(() -> Denars.status(anyString())).thenReturn(Optional.of(Result.APPLIED)); + assertEquals(ShopTrades.Outcome.REVIEW, trades.trade(request(), () -> { + delivered.incrementAndGet(); throw new IllegalStateException("partial item delivery"); + })); + assertTrue(Files.readString(intent()).contains("delivering")); + assertEquals(ShopTrades.Outcome.REVIEW, trades.trade(request(), delivered::incrementAndGet)); + assertEquals(ShopTrades.Outcome.REVIEW, + new ShopTrades(directory, logger).trade(request(), delivered::incrementAndGet)); + assertEquals(1, delivered.get()); + denars.verify(() -> Denars.status(anyString()), times(1)); + denars.verifyNoMoreInteractions(); + } + } + + @ParameterizedTest + @EnumSource(value = Result.class, names = {"REFUSED", "CONFLICT"}) + void rejectionCannotMoveItems(Result result) throws Exception { + ShopTrades trades = new ShopTrades(directory, logger); + AtomicInteger delivered = new AtomicInteger(); + try (var denars = mockStatic(Denars.class)) { + denars.when(() -> Denars.status(anyString())).thenReturn(Optional.of(result)); + assertEquals(result == Result.REFUSED ? ShopTrades.Outcome.REFUSED : ShopTrades.Outcome.REVIEW, + trades.trade(request(), delivered::incrementAndGet)); + assertEquals(0, delivered.get()); + assertEquals(result != Result.REFUSED, Files.exists(intent())); + } + } + + @Test + void failingToSaveIntentCannotReachMoneyOrItems() throws Exception { + Path notDirectory = directory.resolve("file"); + Files.writeString(notDirectory, "occupied"); + try (var denars = mockStatic(Denars.class)) { + assertEquals(ShopTrades.Outcome.REVIEW, new ShopTrades(notDirectory, logger).trade(request(), () -> fail("delivery"))); + denars.verifyNoInteractions(); + } + } + + @Test + void changingCharacterOrTermsDoesNotAdoptAnUnfinishedTrade() { + ShopTrades trades = new ShopTrades(directory, logger); + try (var denars = mockStatic(Denars.class)) { + denars.when(() -> Denars.trade(anyString(), anyString(), any(), any(), any(), anyLong(), anyBoolean())) + .thenReturn(Result.UNAVAILABLE); + assertEquals(ShopTrades.Outcome.WAITING, trades.trade(request(), () -> fail("delivery"))); + var changed = new ShopTrades.Request(shopId, new CharacterRef(customer.ownerId(), UUID.randomUUID()), + owner, "buy", 5, 1000, "DIRT"); + assertEquals(ShopTrades.Outcome.REVIEW, trades.trade(changed, () -> fail("delivery"))); + denars.verify(() -> Denars.status(anyString()), times(1)); + } + } + + @Test + void freeShopsRecordAZeroPosting() { + ShopTrades trades = new ShopTrades(directory, logger); + var free = new ShopTrades.Request(shopId, customer, owner, "sell", 5, 0, "DIRT"); + try (var denars = mockStatic(Denars.class, CALLS_REAL_METHODS)) { + denars.when(() -> Denars.status(anyString())).thenReturn(Optional.empty()); + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenReturn(Result.APPLIED); + assertEquals(ShopTrades.Outcome.APPLIED, trades.trade(free, () -> {})); + denars.verify(() -> Denars.post(startsWith("shop:"), eq("shop:sell"), argThat(legs -> + legs.size() == 2 && legs.stream().allMatch(leg -> leg.cents() == 0)))); + } + } + + @Test + void conflictingPostingCannotBecomeDeliveryOnARetry() { + ShopTrades trades = new ShopTrades(directory, logger); + try (var denars = mockStatic(Denars.class)) { + denars.when(() -> Denars.status(anyString())).thenReturn(Optional.of(Result.CONFLICT), Optional.of(Result.APPLIED)); + assertEquals(ShopTrades.Outcome.REVIEW, trades.trade(request(), () -> fail("delivery"))); + assertEquals(ShopTrades.Outcome.REVIEW, trades.trade(request(), () -> fail("delivery"))); + denars.verify(() -> Denars.status(anyString()), times(1)); + } + } +} diff --git a/src/test/java/net/tfminecraft/bartershops/sf/ShopEmbargoTest.java b/src/test/java/net/tfminecraft/bartershops/sf/ShopEmbargoTest.java index f3e6f4f..7571368 100644 --- a/src/test/java/net/tfminecraft/bartershops/sf/ShopEmbargoTest.java +++ b/src/test/java/net/tfminecraft/bartershops/sf/ShopEmbargoTest.java @@ -8,7 +8,10 @@ import java.util.logging.Logger; import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; +import net.tfminecraft.tlibs.character.Characters; +import net.tfminecraft.tlibs.character.CharacterRef; +import net.tfminecraft.tlibs.character.CharacterSession; +import java.util.Optional; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginManager; @@ -22,6 +25,7 @@ import net.tfminecraft.bartershops.ShopMain; import net.tfminecraft.bartershops.ShopSign; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; import net.tfminecraft.simplefactions.managers.FactionManager; import net.tfminecraft.simplefactions.managers.RelationManager; import net.tfminecraft.simplefactions.objects.Faction; @@ -31,11 +35,14 @@ class ShopEmbargoTest { private MockedStatic bukkit; private MockedStatic factions; + private MockedStatic api; private MockedStatic relations; private PluginManager plugins; private Plugin simpleFactions; private Player customer; - private OfflinePlayer owner; + private MockedStatic characters; + private static final String CUSTOMER_CHARACTER = "00000000-0000-0000-0000-000000000102"; + private static final String OWNER_CHARACTER = "00000000-0000-0000-0000-000000000101"; private Faction customerFaction; private Faction ownerFaction; private ShopSign shop; @@ -47,28 +54,33 @@ void setUp() throws Exception { previousPlugin = ShopMain.plugin; bukkit = mockStatic(Bukkit.class); factions = mockStatic(FactionManager.class); + api = mockStatic(SimpleFactionsApi.class); + api.when(SimpleFactionsApi::ready).thenReturn(true); relations = mockStatic(RelationManager.class); plugins = mock(PluginManager.class); simpleFactions = mock(Plugin.class); customer = mock(Player.class); - owner = mock(OfflinePlayer.class); + characters = mockStatic(Characters.class); + characters.when(() -> Characters.currentSession(customer)).thenReturn(Optional.of( + new CharacterSession(new CharacterRef(UUID.randomUUID(), UUID.fromString(CUSTOMER_CHARACTER)), 1))); customerFaction = mock(Faction.class); ownerFaction = mock(Faction.class); bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); - bukkit.when(() -> Bukkit.getOfflinePlayer(OWNER)).thenReturn(owner); when(plugins.getPlugin("SimpleFactions")).thenReturn(simpleFactions); when(simpleFactions.isEnabled()).thenReturn(true); when(customer.getName()).thenReturn("Customer"); - when(owner.getName()).thenReturn("Owner"); - factions.when(() -> FactionManager.getByMember("Customer")).thenReturn(customerFaction); - factions.when(() -> FactionManager.getByMember("Owner")).thenReturn(ownerFaction); + factions.when(() -> FactionManager.getByMember(CUSTOMER_CHARACTER)).thenReturn(customerFaction); + factions.when(() -> FactionManager.getByMember(OWNER_CHARACTER)).thenReturn(ownerFaction); shop = new ShopSign(); - shop.setOwner(OWNER.toString()); + shop.setOwnerAccount(OWNER.toString()); + shop.setOwnerCharacter(OWNER_CHARACTER); } @AfterEach void tearDown() throws Exception { + characters.close(); relations.close(); + api.close(); factions.close(); bukkit.close(); ShopMain.plugin = previousPlugin; @@ -102,15 +114,16 @@ void missingSimpleFactionsIsNeverBlocked() { } @Test - void disabledSimpleFactionsIsNeverBlocked() { + void disabledSimpleFactionsBlocksTrades() { when(simpleFactions.isEnabled()).thenReturn(false); - assertFalse(ShopEmbargo.blocked(customer, shop)); + assertFalse(ShopEmbargo.ready()); + assertTrue(ShopEmbargo.blocked(customer, shop)); factions.verifyNoInteractions(); } @Test void customerWithoutANationIsNeverBlocked() { - factions.when(() -> FactionManager.getByMember("Customer")).thenReturn(null); + factions.when(() -> FactionManager.getByMember(CUSTOMER_CHARACTER)).thenReturn(null); assertFalse(ShopEmbargo.blocked(customer, shop)); relations.verifyNoInteractions(); } @@ -119,7 +132,7 @@ void customerWithoutANationIsNeverBlocked() { @NullSource @ValueSource(strings = {"", " ", "not-a-uuid"}) void unusableOwnerIdIsNeverBlocked(String ownerId) { - shop.setOwner(ownerId); + shop.setOwnerCharacter(ownerId); assertFalse(ShopEmbargo.blocked(customer, shop)); relations.verifyNoInteractions(); } @@ -127,21 +140,21 @@ void unusableOwnerIdIsNeverBlocked(String ownerId) { @ParameterizedTest @NullSource @ValueSource(strings = {"", " "}) - void ownerWithoutAKnownNameIsNeverBlocked(String name) { - when(owner.getName()).thenReturn(name); + void customerWithoutAnActiveCharacterIsNeverBlocked(String name) { + characters.when(() -> Characters.currentSession(customer)).thenReturn(Optional.empty()); assertFalse(ShopEmbargo.blocked(customer, shop)); relations.verifyNoInteractions(); } @Test void ownerWithoutANationIsNeverBlocked() { - factions.when(() -> FactionManager.getByMember("Owner")).thenReturn(null); + factions.when(() -> FactionManager.getByMember(OWNER_CHARACTER)).thenReturn(null); assertFalse(ShopEmbargo.blocked(customer, shop)); relations.verifyNoInteractions(); } @Test - void simpleFactionsFailuresAllowTheTradeAndWarnOnce() { + void simpleFactionsFailuresBlockTheTradeAndWarnOnce() { ShopMain plugin = mock(ShopMain.class); Logger logger = mock(Logger.class); when(plugin.getLogger()).thenReturn(logger); @@ -150,10 +163,10 @@ void simpleFactionsFailuresAllowTheTradeAndWarnOnce() { .thenThrow(new IllegalStateException("relations not loaded")) .thenThrow(new NoClassDefFoundError("RelationManager")); - assertFalse(ShopEmbargo.blocked(customer, shop)); - assertFalse(ShopEmbargo.blocked(customer, shop)); + assertTrue(ShopEmbargo.blocked(customer, shop)); + assertTrue(ShopEmbargo.blocked(customer, shop)); - verify(logger, times(1)).warning("[BarterShops] SimpleFactions embargo skipped: relations not loaded"); + verify(logger, times(1)).warning("[BarterShops] SimpleFactions trade blocked: relations not loaded"); } @Test @@ -162,11 +175,36 @@ void failuresBeforeThePluginIsEnabledAreNotLogged() throws Exception { relations.when(() -> RelationManager.hasTradeEmbargo(ownerFaction, customerFaction)) .thenThrow(new IllegalStateException("relations not loaded")); - assertFalse(ShopEmbargo.blocked(customer, shop)); + assertTrue(ShopEmbargo.blocked(customer, shop)); assertFalse(loggedFail().getBoolean(null)); } + @Test + void waitingForMigrationBlocksBeforeAnyFactionLookup() { + api.when(SimpleFactionsApi::ready).thenReturn(false); + assertFalse(ShopEmbargo.ready()); + assertTrue(ShopEmbargo.blocked(customer, shop)); + factions.verifyNoInteractions(); + api.when(SimpleFactionsApi::ready).thenReturn(true); + assertTrue(ShopEmbargo.ready()); + } + + @Test + void missingOptionalPluginIsReady() { + when(plugins.getPlugin("SimpleFactions")).thenReturn(null); + assertTrue(ShopEmbargo.ready()); + api.verifyNoInteractions(); + } + + @Test + void readinessFailureClosesTrades() { + api.when(SimpleFactionsApi::ready).thenThrow(new IllegalStateException("not ready")); + assertFalse(ShopEmbargo.ready()); + assertTrue(ShopEmbargo.blocked(customer, shop)); + factions.verifyNoInteractions(); + } + private static Field loggedFail() throws Exception { Field field = ShopEmbargo.class.getDeclaredField("loggedFail"); field.setAccessible(true);