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