diff --git a/pom.xml b/pom.xml index d044b3b..0851776 100644 --- a/pom.xml +++ b/pom.xml @@ -16,10 +16,10 @@ UTF-8 full - 0.2.4 + 0.3.0 0.2.0 - 3.0.1 - 2.0.0 + 4.0.0 + 2.4.0 diff --git a/src/main/java/net/tfminecraft/dowsing/DowsingMain.java b/src/main/java/net/tfminecraft/dowsing/DowsingMain.java index cf8d62d..3379dfe 100644 --- a/src/main/java/net/tfminecraft/dowsing/DowsingMain.java +++ b/src/main/java/net/tfminecraft/dowsing/DowsingMain.java @@ -6,6 +6,8 @@ import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; import net.tfminecraft.dowsing.loaders.BlockLoader; import net.tfminecraft.dowsing.loaders.ConfigLoader; @@ -19,10 +21,13 @@ import net.tfminecraft.dowsing.utils.Database; import net.tfminecraft.dowsing.utils.ItemCreator; import net.tfminecraft.dowsing.utils.TabCompletion; +import net.tfminecraft.dowsing.utils.NodePayments; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; +import net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent; import net.tfminecraft.simplefactions.guild.income.Ledger; @SuppressWarnings("deprecation") // Preserve legacy section-color messages used by the plugin configuration. -public class DowsingMain extends JavaPlugin{ +public class DowsingMain extends JavaPlugin implements Listener { FileConfiguration config = getConfig(); public static DowsingMain plugin; @@ -40,6 +45,14 @@ public class DowsingMain extends JavaPlugin{ private static final NodeManager nodeManager = new NodeManager(); Database db = new Database(); + private NodePayments payments; + private boolean nodesLoaded; + private boolean managersStarted; + + public NodePayments payments() { return payments; } + public boolean nodesReady() { + return managersStarted && SimpleFactionsApi.ready() && !payments.blocked(); + } public static NodeManager getNodeManager() { return nodeManager; @@ -52,24 +65,45 @@ public void onEnable(){ createFolders(); createConfigs(); loadConfigs(); - startManagers(); - db.loadGuildCapacity(); - db.loadNodes(); - Ledger.setNodeUpkeepLookup(NodeManager::getTotalUpkeep); - NodeManager.requestNodeBenefitSync(); + nodesLoaded = false; + managersStarted = false; + payments = new NodePayments(getDataFolder().toPath(), db, getLogger()); + getServer().getPluginManager().registerEvents(this, plugin); getServer().getPluginManager().registerEvents(resourceManager, plugin); getServer().getPluginManager().registerEvents(nodeManager, plugin); getCommand(commands.cmd1).setExecutor(commands); getCommand(commands.cmd1).setTabCompleter(new TabCompletion()); + tryStartNodes(); + getServer().getScheduler().runTaskTimer(this, this::tryStartNodes, 100L, 100L); + } + @EventHandler + public void onFactionsReady(SimpleFactionsReadyEvent event) { tryStartNodes(); } + + void tryStartNodes() { + if (!SimpleFactionsApi.ready()) return; + if (!nodesLoaded) { + NodeManager.nodes.clear(); + NodeManager.extraCapacityByGuild.clear(); + db.loadGuildCapacity(); + db.loadNodes(); + nodesLoaded = true; + } + if (managersStarted && !payments.blocked()) return; + if (!payments.recover()) return; + Ledger.setNodeUpkeepLookup(NodeManager::getTotalUpkeep); + NodeManager.requestNodeBenefitSync(); + if (managersStarted) return; + managersStarted = true; + startManagers(); } @Override public void onDisable() { Ledger.setNodeUpkeepLookup(null); - db.deleteDatabase(); - for(Node n : NodeManager.nodes) { - db.saveNode(n); + if (nodesReady()) { + db.saveNodes(NodeManager.nodes); + db.saveGuildCapacity(); } - db.saveGuildCapacity(); + managersStarted = false; } public void startManagers() { nodeManager.start(); @@ -105,6 +139,7 @@ public void createConfigs() { } } public void reloadConfigCommand() { + if (!nodesReady()) return; nodeManager.cacheNodes(); ItemCreator.clearInvalidPathWarnings(); BlockLoader.clear(); diff --git a/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java b/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java index ad54441..bfc8d9e 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java @@ -248,7 +248,7 @@ ItemStack createGlobe(Node n) { ItemMeta m = i.getItemMeta(); m.setDisplayName("§eBelongs to: "+g.getName()); List lore = new ArrayList(); - lore.add("§7Leader: §f"+g.getLeader()); + lore.add("§7Leader: §f"+net.tfminecraft.simplefactions.identity.Members.display(g.getLeader())); if(NodeManager.getNodeAmount(g) < n.getCapacity()) { lore.add("§eNodes: §a"+NodeManager.getNodeAmount(g)+"/"+n.getCapacity()); } else if(NodeManager.getNodeAmount(g) == n.getCapacity()){ diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index 43fce1b..c6f9cc5 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -42,6 +42,8 @@ import net.tfminecraft.dowsing.objects.NodeType; import net.tfminecraft.dowsing.objects.ProductionMethod; import net.tfminecraft.dowsing.utils.ItemDropper; +import net.tfminecraft.dowsing.utils.NodePayments; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; import net.tfminecraft.dowsing.utils.NodeEngine; import net.tfminecraft.dowsing.utils.NodeReloader; import net.tfminecraft.dowsing.utils.NodeToggleLog; @@ -67,6 +69,7 @@ public static void requestNodeBenefitSync() { } public static void syncFactionNodeBenefits() { + if (!SimpleFactionsApi.ready()) return; Map prestigeByFaction = new HashMap<>(); Map wealthByFaction = new HashMap<>(); for(Node n : nodes) { @@ -158,6 +161,7 @@ public void start() { { public void run() { + if (!DowsingMain.plugin.nodesReady()) return; validate(); for(Node n : nodes) { try { @@ -204,6 +208,7 @@ public void particleCycle(){ { public void run() { + if (!DowsingMain.plugin.nodesReady()) return; for(Node n : nodes){ if(n.isClaimable()){ Location loc = n.getLoc().clone().add(0.5, 1, 0.5); @@ -220,6 +225,7 @@ public void hourCycle(){ { public void run() { + if (!DowsingMain.plugin.nodesReady()) return; for(Node n : nodes){ n.growEfficiency(); } @@ -227,6 +233,7 @@ public void run() }.runTaskTimer(DowsingMain.plugin, 0L, 72000L); } public void validate() { + if (!SimpleFactionsApi.ready()) return; for(int i = 0; i 0) return false; @@ -357,11 +360,12 @@ Integer getNaturalYieldFromChunk(NodeBlock b, Location l) throws NumberFormatExc } public void check() { + if (!SimpleFactionsApi.ready()) return; if(isClaimable()) return; Guild g = resolveGuild(); if(g == null) return; if(canHold(g)) return; - Player p = Bukkit.getPlayer(g.getLeader()); + Player p = Members.player(g.getLeader()); if(p != null && p.isOnline()) { p.sendMessage("§cYou lost control of the "+block.getResource()+" Node §c!"); p.closeInventory(); diff --git a/src/main/java/net/tfminecraft/dowsing/utils/Database.java b/src/main/java/net/tfminecraft/dowsing/utils/Database.java index 7061bc6..dbee916 100644 --- a/src/main/java/net/tfminecraft/dowsing/utils/Database.java +++ b/src/main/java/net/tfminecraft/dowsing/utils/Database.java @@ -4,7 +4,6 @@ import java.io.BufferedWriter; import java.io.File; import java.io.FileNotFoundException; -import java.io.FileWriter; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; @@ -178,19 +177,20 @@ public void loadGuildCapacity() { } } - public void saveGuildCapacity() { + public boolean saveGuildCapacity() { try { File file = new File(DowsingMain.plugin.getDataFolder(), "guild_capacity.json"); java.nio.file.Files.createDirectories(file.getParentFile().toPath()); - file.createNewFile(); + HashMap defaults = new HashMap(); for(String key : NodeManager.extraCapacityByGuild.keySet()) { defaults.put(key, NodeManager.extraCapacityByGuild.get(key)); } json = new JSONObject(); - save(file, defaults); + return save(file, defaults); } catch (Throwable ex) { ex.printStackTrace(); + return false; } } @@ -202,8 +202,21 @@ public void deleteDatabase() { } } } + /** Save first, then remove nodes absent from the successfully persisted snapshot. */ + public boolean saveNodes(List nodes) { + for (Node node : nodes) if (!saveNode(node)) return false; + java.util.Set names = new java.util.HashSet<>(); + for (Node node : nodes) names.add(node.getId() + ".json"); + File folder = new File(DowsingMain.plugin.getDataFolder(), "Nodes"); + for (File file : listFiles(folder)) { + if (file.isFile() && file.getName().matches("[0-9a-fA-F-]{36}\\.json") && !names.contains(file.getName())) { + if (!file.delete()) return false; + } + } + return true; + } @SuppressWarnings("unchecked") - public void saveNode(Node n) { + public boolean saveNode(Node n) { try { File file = new File(new File(DowsingMain.plugin.getDataFolder(), "Nodes"),n.getId()+".json"); java.nio.file.Files.createDirectories(file.getParentFile().toPath()); @@ -235,9 +248,10 @@ public void saveNode(Node n) { defaults.put("active pms", pmArray); defaults.put("efficiency", n.getEfficiency()); defaults.put(LastCycleResult.KEY, LastCycleResult.toJson(n.getLastResult())); - save(file, defaults); + return save(file, defaults); } catch (Throwable ex) { ex.printStackTrace(); + return false; } } @SuppressWarnings("unchecked") @@ -266,10 +280,7 @@ public boolean save(File file, HashMap defaults) { Gson g = new GsonBuilder().setPrettyPrinting().create(); String prettyJsonString = g.toJson(treeMap); - FileWriter fw = new FileWriter(file); - fw.write(prettyJsonString); - fw.flush(); - fw.close(); + DurableFiles.write(file.toPath(), prettyJsonString); return true; } catch (Exception ex) { diff --git a/src/main/java/net/tfminecraft/dowsing/utils/DurableFiles.java b/src/main/java/net/tfminecraft/dowsing/utils/DurableFiles.java new file mode 100644 index 0000000..0a866e0 --- /dev/null +++ b/src/main/java/net/tfminecraft/dowsing/utils/DurableFiles.java @@ -0,0 +1,38 @@ +package net.tfminecraft.dowsing.utils; + +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; + +/** Durable replacement for node state and the payment intent that precedes it. */ +final class DurableFiles { + private DurableFiles() { } + + static void write(Path destination, String text) throws IOException { + Path directory = destination.toAbsolutePath().getParent(); + Files.createDirectories(directory); + Path temporary = Files.createTempFile(directory, ".node-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + ByteBuffer data = StandardCharsets.UTF_8.encode(text); + while (data.hasRemaining()) channel.write(data); + channel.force(true); + } + Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + // Include new ancestor entries, even after an earlier failed publication attempt. + for (Path parent = directory; parent != null; parent = parent.getParent()) { + try (FileChannel channel = FileChannel.open(parent, StandardOpenOption.READ)) { + channel.force(true); + } + } + } finally { + Files.deleteIfExists(temporary); + } + } +} diff --git a/src/main/java/net/tfminecraft/dowsing/utils/NodePayments.java b/src/main/java/net/tfminecraft/dowsing/utils/NodePayments.java new file mode 100644 index 0000000..68dfe18 --- /dev/null +++ b/src/main/java/net/tfminecraft/dowsing/utils/NodePayments.java @@ -0,0 +1,115 @@ +package net.tfminecraft.dowsing.utils; + +import com.google.gson.Gson; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import net.tfminecraft.denareconomy.api.Denars; +import net.tfminecraft.denareconomy.api.Leg; +import net.tfminecraft.denareconomy.api.Result; +import net.tfminecraft.dowsing.managers.NodeManager; +import net.tfminecraft.dowsing.objects.Node; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; + +/** One durable intent spans a ledger posting and its exact node/capacity state change. */ +public final class NodePayments { + private static final Gson JSON = new Gson(); + private final Path file; + private final Database database; + private final Logger logger; + private boolean blocked; + + public NodePayments(Path directory, Database database, Logger logger) { + this.file = directory.resolve("node-payment.json"); + this.database = database; + this.logger = logger; + } + + public boolean blocked() { return blocked; } + + public boolean change(Node node, Guild guild, String kind, int before, int after, double cost) { + if (blocked) return false; + try { + long cents = BigDecimal.valueOf(cost).setScale(2, RoundingMode.HALF_UP) + .movePointRight(2).longValueExact(); + Intent intent = new Intent("dowsing:" + UUID.randomUUID(), node.getId(), guild.getId(), + guild.getIncarnationId(), kind, before, after, cents, false); + // A new node must exist on disk before recovery can reference it. + if (!database.saveNode(node)) throw new IOException("Could not save the node before payment"); + if (!database.saveGuildCapacity()) throw new IOException("Could not save capacity before payment"); + blocked = true; + write(intent); + return apply(intent, node, guild); + } catch (Exception failure) { + blocked = true; + logger.log(Level.SEVERE, "Node payment paused; its journal must be recovered before more changes", failure); + return false; + } + } + + /** Called after faction and node loading, before any timer or command can mutate nodes. */ + public boolean recover() { + try { + if (!Files.exists(file)) { blocked = false; return true; } + Intent intent = JSON.fromJson(Files.readString(file), Intent.class); + if (intent == null) throw new IOException("Empty node payment journal"); + if (intent.done()) { blocked = false; return true; } + blocked = true; + Guild guild = FactionManager.getGuildByString(intent.guild()); + Node node = NodeManager.nodes.stream().filter(n -> n.getId().equals(intent.node())).findFirst().orElse(null); + if (guild == null || !guild.getIncarnationId().equals(intent.incarnation()) || node == null + || node.getGuild() == null || !node.getGuild().getIncarnationId().equals(intent.incarnation())) { + throw new IOException("Node payment owner or node no longer matches; retain journal for staff"); + } + return apply(intent, node, guild) || !blocked; + } catch (Exception failure) { + blocked = true; + logger.log(Level.SEVERE, "Could not recover node payment; node changes remain paused", failure); + return false; + } + } + + private boolean apply(Intent intent, Node node, Guild guild) throws IOException { + if (!intent.kind().equals("level") && !intent.kind().equals("capacity")) + throw new IOException("Unknown node payment kind"); + int current = intent.kind().equals("level") ? node.getLevel() : NodeManager.getExtraCapacity(guild); + if (current != intent.before() && current != intent.after()) + throw new IOException("Node state differs from saved payment intent"); + Result result = Denars.post(intent.operation(), "dowsing:" + intent.kind(), List.of( + new Leg(Denars.guild(intent.incarnation()), Math.negateExact(intent.cents())), + new Leg(Denars.MINT, intent.cents()))); + if (result == Result.REFUSED) { + finish(intent); + return false; + } + if (result != Result.APPLIED) return false; + if (intent.kind().equals("level")) { + node.setLevel(intent.after()); + node.update(); + if (!database.saveNode(node)) throw new IOException("Could not persist paid node level"); + } else { + NodeManager.extraCapacityByGuild.put(guild.getId(), intent.after()); + if (!database.saveGuildCapacity()) throw new IOException("Could not persist paid capacity"); + } + finish(intent); + return true; + } + + private void finish(Intent intent) throws IOException { + write(new Intent(intent.operation(), intent.node(), intent.guild(), intent.incarnation(), + intent.kind(), intent.before(), intent.after(), intent.cents(), true)); + blocked = false; + } + + private void write(Intent intent) throws IOException { DurableFiles.write(file, JSON.toJson(intent)); } + + private record Intent(String operation, UUID node, String guild, UUID incarnation, String kind, + int before, int after, long cents, boolean done) { } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 33a63a0..16f00a4 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ name: Dowsing version: '${project.version}' author: Drefvelin -depend: [TLibs, SimpleFactions, MMOItems, MythicLib, ItemsAdder] +depend: [TLibs, DenarEconomy, SimpleFactions, MMOItems, MythicLib, ItemsAdder] softdepend: [Magic] commands: diff --git a/src/test/java/net/tfminecraft/dowsing/DowsingLifecycleTest.java b/src/test/java/net/tfminecraft/dowsing/DowsingLifecycleTest.java index 07ef25b..9425aa5 100644 --- a/src/test/java/net/tfminecraft/dowsing/DowsingLifecycleTest.java +++ b/src/test/java/net/tfminecraft/dowsing/DowsingLifecycleTest.java @@ -42,11 +42,34 @@ class DowsingLifecycleTest { Player player=mock(Player.class);plugin.reloadConfigPCommand(player);verify(player,times(2)).sendMessage(org.mockito.ArgumentMatchers.anyString()); verify(plugin.configLoader,times(3)).loadConfig(new java.io.File(plugin.getDataFolder(),"config.yml")); Node node=mock(Node.class);NodeManager.nodes.add(node); - plugin.onDisable();verify(plugin.db).deleteDatabase();verify(plugin.db).saveNode(node);verify(plugin.db).saveGuildCapacity(); + plugin.onDisable();verify(plugin.db).saveNodes(NodeManager.nodes);verify(plugin.db).saveGuildCapacity(); NodeManager.nodes.clear(); try(var paths=Files.walk(plugin.getDataFolder().toPath())){for(Path path:paths.sorted(Comparator.reverseOrder()).toList())Files.delete(path);} plugin.onEnable();assertTrue(Files.isDirectory(plugin.getDataFolder().toPath())); MockBukkit.getMock().getPluginManager().disablePlugin(plugin); } } + + @Test void waitingForIdentityNeverLoadsOrSavesNodesThenStartsOnlyOnce() { + var api=net.tfminecraft.dowsing.testsupport.FactionFixture.api; + api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(false); + try(var configs=mockConstruction(ConfigLoader.class);var pms=mockConstruction(PMLoader.class); + var slots=mockConstruction(SlotLoader.class);var types=mockConstruction(TypeLoader.class); + var blocks=mockConstruction(BlockLoader.class);var databases=mockConstruction(Database.class); + var ledger=mockStatic(Ledger.class);var managers=mockStatic(NodeManager.class); + var payments=mockConstruction(net.tfminecraft.dowsing.utils.NodePayments.class)) { + DowsingMain plugin=MockBukkit.load(DowsingMain.class); + assertFalse(plugin.nodesReady()); assertNotNull(plugin.payments()); + plugin.tryStartNodes(); plugin.reloadConfigCommand(); plugin.onDisable(); + verifyNoInteractions(plugin.db); + api.when(net.tfminecraft.simplefactions.api.SimpleFactionsApi::ready).thenReturn(true); + plugin.tryStartNodes(); verify(plugin.db).loadNodes(); assertFalse(plugin.nodesReady()); + when(plugin.payments().recover()).thenReturn(true); + plugin.onFactionsReady(new net.tfminecraft.simplefactions.events.SimpleFactionsReadyEvent()); + assertTrue(plugin.nodesReady()); plugin.tryStartNodes(); verify(plugin.db).loadNodes(); + when(plugin.payments().blocked()).thenReturn(true); assertFalse(plugin.nodesReady()); + plugin.tryStartNodes(); managers.verify(NodeManager::requestNodeBenefitSync, times(2)); + plugin.onDisable(); verify(plugin.db,never()).saveNodes(any()); + } + } } diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java index eea10fd..27a9154 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java @@ -92,10 +92,10 @@ void placementRejectsMissingPlayerDuplicateLocationAndSameChunk() { @Test void placementRequiresGuildMembershipMinimumMembersAndAvailableCapacity() { BlockPlaceEvent event = f.place(); - f.factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(null); + f.factions.when(() -> FactionManager.getGuildByMember(f.player)).thenReturn(null); f.manager.placeVanillaNode(event); verify(event).setCancelled(true); verify(f.player).sendMessage(contains("need to have a guild")); - f.factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(f.guild); + f.factions.when(() -> FactionManager.getGuildByMember(f.player)).thenReturn(f.guild); Cache.minMembersForNode = 3; event = f.place(); f.manager.placeVanillaNode(event); verify(event).setCancelled(true); verify(f.player).sendMessage(contains("at least 3 members")); Cache.minMembersForNode = 1; NodeManager.nodes.add(f.nodeAt(f.remoteLocation())); @@ -168,7 +168,7 @@ void unclaimedNodesRequireALeaderAndSuccessfulClaimChecks() { NodeManager.nodes.add(f.node); when(f.node.isClaimable()).thenReturn(true); PlayerInteractEvent event = f.interact(Action.RIGHT_CLICK_BLOCK); f.manager.openNode(event); verify(f.player).sendMessage(contains("Must be a guild leader")); - f.factions.when(() -> FactionManager.getGuildByLeader("Leader")).thenReturn(f.guild); + f.factions.when(() -> FactionManager.getGuildByLeader(f.player)).thenReturn(f.guild); f.manager.openNode(event); verify(f.node, never()).setGuild(any()); when(f.node.canClaim(f.player, f.guild)).thenReturn(true); f.manager.openNode(event); verify(f.node).setGuild(f.guild); verify(f.node).update(); @@ -206,7 +206,7 @@ void everyManagedMenuCancelsBottomInventoryClicksAndRemovesOrphanNodes(String ti @Test void mainMenuRejectsOtherGuildMembersAndLetsAdminsProceed() { - f.factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(null); + f.factions.when(() -> FactionManager.getGuildByMember(f.player)).thenReturn(null); f.manager.invenClick(f.click("§7Iron Node", 9)); verify(f.player).sendMessage("§cCannot change another guild's node"); verify(f.lastInventory(), never()).typeView(any(), any()); @@ -269,12 +269,12 @@ void capacityPurchasesValidateTheLimitBankAndFundsBeforeCharging() { when(f.guild.getBank()).thenReturn(f.bank); when(f.bank.getWealth()).thenReturn(10.0); f.manager.invenClick(f.click("§7Iron Node", 24)); verify(f.player).sendMessage("§cNot enough funds"); when(f.bank.getWealth()).thenReturn(100.0); - f.manager.invenClick(f.click("§7Iron Node", 24)); verify(f.bank).withdraw(25.0); + f.manager.invenClick(f.click("§7Iron Node", 24)); verify(f.payments).change(eq(f.node), eq(f.guild), eq("capacity"), anyInt(), anyInt(), eq(25.0)); assertEquals(1, NodeManager.getExtraCapacity(f.guild)); net.tfminecraft.simplefactions.Cache.maxExtraNodeCapacity = 1; InventoryClickEvent event = f.click("§7Iron Node", 24); when(event.getCurrentItem()).thenReturn(new ItemStack(Material.NETHER_STAR)); f.manager.invenClick(event); verify(f.player).sendMessage(contains("maximum extra capacity")); - f.manager.invenClick(f.click("§7Iron Node", 24)); verify(f.bank, times(1)).withdraw(25.0); + f.manager.invenClick(f.click("§7Iron Node", 24)); verify(f.payments, times(1)).change(eq(f.node), eq(f.guild), eq("capacity"), anyInt(), anyInt(), eq(25.0)); } @Test @@ -290,22 +290,22 @@ void purchasedCapacityAppliesWithMemberCapacityDisabledAndStopsAtThePurchaseLimi assertFalse(NodeManager.canPurchaseCapacity(f.guild)); f.manager.invenClick(f.click("§7Iron Node", 24)); assertEquals(3, NodeManager.getNodeCapacity(f.guild)); - verify(f.bank, times(2)).withdraw(25.0); + verify(f.payments, times(2)).change(eq(f.node), eq(f.guild), eq("capacity"), anyInt(), anyInt(), eq(25.0)); } @Test void upgradeAndDowngradeChargeAndRefundTheCorrectLevelAndRefreshTheMenu() { - f.manager.invenClick(f.click("§7Iron Node", 8)); verify(f.bank).withdraw(20.0); + f.manager.invenClick(f.click("§7Iron Node", 8)); verify(f.payments).change(f.node, f.guild, "level", 1, 2, 20.0); verify(f.node).setLevel(2); verify(f.node).update(); verify(f.lastInventory()).updateNodeView(f.player, f.node, f.inventory); when(f.node.getLevel()).thenReturn(2); - f.manager.invenClick(f.click("§7Iron Node", 26)); verify(f.bank).deposit(16.0); verify(f.node).setLevel(1); + f.manager.invenClick(f.click("§7Iron Node", 26)); verify(f.payments).change(f.node, f.guild, "level", 2, 1, -16.0); verify(f.node).setLevel(1); f.manager.upgradeNode(f.player, f.node, f.inventory); verify(f.player).sendMessage(contains("already at max level")); when(f.node.getLevel()).thenReturn(1); f.manager.downgradeNode(f.player, f.node, f.inventory); verify(f.player).sendMessage(contains("cannot go below level 1")); when(f.bank.getWealth()).thenReturn(1.0); f.manager.upgradeNode(f.player, f.node, f.inventory); verify(f.player).sendMessage(contains("does not have enough funds")); when(f.node.getLevel()).thenReturn(2); when(f.node.getGuild()).thenReturn(null); - f.manager.downgradeNode(f.player, f.node, f.inventory); verify(f.bank, times(1)).deposit(16.0); + f.manager.downgradeNode(f.player, f.node, f.inventory); verify(f.payments, times(1)).change(f.node, f.guild, "level", 2, 1, -16.0); } @Test @@ -400,7 +400,7 @@ void confirmedDeactivationAndTypeChangeRefreshTheNodeAndAuditRealStops() { @ValueSource(strings = {"§7Iron Node: Fuel", "§7Iron Node: Type", "§7Confirm Action"}) void staleSubmenusCannotChangeNodesAfterThePlayersGuildMembershipEnds(String title) { f.manager.currentSlot.put(f.player, f.slot); f.manager.confirm.put(f.player, ConfirmType.DELETE_NODE); - f.factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(null); + f.factions.when(() -> FactionManager.getGuildByMember(f.player)).thenReturn(null); InventoryClickEvent event = f.click(title, title.equals("§7Confirm Action") ? 11 : 0); doReturn(f.item("Next")).when(event).getCurrentItem(); ProductionMethod nextMethod = mock(ProductionMethod.class); when(nextMethod.getId()).thenReturn("next"); @@ -428,7 +428,7 @@ void pendingRefundsPreventChangesFromEveryMenu(String title) { @ValueSource(strings = {"§7Iron Node: Fuel", "§7Iron Node: Type", "§7Confirm Action"}) void foreignGuildViewersCanStillReturnFromOrCancelTheMenu(String title) { f.manager.currentSlot.put(f.player, f.slot); f.manager.confirm.put(f.player, ConfirmType.DELETE_NODE); - f.factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(null); + f.factions.when(() -> FactionManager.getGuildByMember(f.player)).thenReturn(null); f.manager.invenClick(f.click(title, title.equals("§7Confirm Action") ? 15 : 26)); verify(f.lastInventory()).nodeView(f.player, f.node); verify(f.node, never()).breakNode(); verify(f.player, never()).sendMessage(anyString()); @@ -455,6 +455,22 @@ void staleMenusCannotMutateANodeActivatedByAnotherPlayer(String action) { verify(f.player).sendMessage(contains("active")); } + + @Test void unavailableRuntimeAndPaymentsNeverMutateNodeState() { + when(DowsingMain.plugin.nodesReady()).thenReturn(false); + InventoryClickEvent click=f.click("§7Iron Node",8); + f.manager.invenClick(click); verify(click).setCancelled(true); + verify(f.payments,never()).change(any(),any(),anyString(),anyInt(),anyInt(),anyDouble()); + when(DowsingMain.plugin.nodesReady()).thenReturn(true); + when(f.payments.change(any(),any(),anyString(),anyInt(),anyInt(),anyDouble())).thenReturn(false); + f.manager.upgradeNode(f.player,f.node,f.inventory); + when(f.node.getNodeCapacityCost()).thenReturn(25.0); + f.manager.invenClick(f.click("§7Iron Node",24)); + verify(f.node,never()).setLevel(anyInt()); + assertEquals(0,NodeManager.getExtraCapacity(f.guild)); + verify(f.player,atLeastOnce()).sendMessage(contains("pending or unavailable")); + } + static final class Fixture implements AutoCloseable { final Map previousCache = new HashMap<>(); final List previousNodes = NodeManager.nodes; @@ -463,6 +479,7 @@ static final class Fixture implements AutoCloseable { final int previousMaxCapacity = net.tfminecraft.simplefactions.Cache.maxExtraNodeCapacity; final ServerMock server; final NodeManager manager = new NodeManager(); + final net.tfminecraft.dowsing.utils.NodePayments payments = mock(net.tfminecraft.dowsing.utils.NodePayments.class); final Guild guild = mock(Guild.class); final net.tfminecraft.simplefactions.objects.Bank bank = mock(net.tfminecraft.simplefactions.objects.Bank.class); final World world = mock(World.class); @@ -489,6 +506,13 @@ static final class Fixture implements AutoCloseable { server = MockBukkit.mock(); DowsingMain.plugin = mock(DowsingMain.class); when(DowsingMain.plugin.getLogger()).thenReturn(logger); when(DowsingMain.plugin.getServer()).thenReturn(server); + when(DowsingMain.plugin.payments()).thenReturn(payments); + when(payments.change(any(), any(), anyString(), anyInt(), anyInt(), anyDouble())).thenAnswer(invocation -> { + Node changed = invocation.getArgument(0); + if (invocation.getArgument(2).equals("level")) changed.setLevel(invocation.getArgument(4)); + else NodeManager.extraCapacityByGuild.put(guild.getId(), invocation.getArgument(4)); + return true; + }); when(DowsingMain.plugin.getName()).thenReturn("Dowsing"); when(DowsingMain.plugin.isEnabled()).thenReturn(true); NodeManager.nodes = new ArrayList<>(); NodeManager.extraCapacityByGuild = new HashMap<>(); Cache.extraCapacity = false; Cache.naturalYieldEnabled = false; Cache.cycleLength = 60; @@ -510,7 +534,7 @@ static final class Fixture implements AutoCloseable { when(type.getLevels()).thenReturn(List.of(first, second)); node = nodeAt(location); factions = mockStatic(FactionManager.class); factions.when(FactionManager::getCopy).thenReturn(new ArrayList<>()); - factions.when(() -> FactionManager.getGuildByMember("Leader")).thenReturn(guild); + factions.when(() -> FactionManager.getGuildByMember(player)).thenReturn(guild); factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); blocks = mockStatic(BlockLoader.class); blocks.when(() -> BlockLoader.getByBlock(Material.BARRIER)).thenReturn(block); blocks.when(() -> BlockLoader.getByPath("nodes:iron")).thenReturn(block); diff --git a/src/test/java/net/tfminecraft/dowsing/testsupport/FactionFixture.java b/src/test/java/net/tfminecraft/dowsing/testsupport/FactionFixture.java new file mode 100644 index 0000000..d6b1da1 --- /dev/null +++ b/src/test/java/net/tfminecraft/dowsing/testsupport/FactionFixture.java @@ -0,0 +1,37 @@ +package net.tfminecraft.dowsing.testsupport; + +import static org.mockito.Mockito.*; +import net.tfminecraft.dowsing.DowsingMain; +import net.tfminecraft.simplefactions.api.SimpleFactionsApi; +import net.tfminecraft.simplefactions.identity.Members; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.extension.*; +import org.mockito.MockedStatic; + +/** Existing node tests use a ready faction provider and human-readable member fixture keys. */ +public final class FactionFixture implements BeforeEachCallback, BeforeTestExecutionCallback, AfterEachCallback { + public static MockedStatic api; + private MockedStatic members; + private DowsingMain previous; + @Override public void beforeEach(ExtensionContext context) { + previous = DowsingMain.plugin; + DowsingMain.plugin = mock(DowsingMain.class); + when(DowsingMain.plugin.nodesReady()).thenReturn(true); + api = mockStatic(SimpleFactionsApi.class); + api.when(SimpleFactionsApi::ready).thenReturn(true); + members = mockStatic(Members.class); + members.when(() -> Members.display(anyString())).thenAnswer(i -> i.getArgument(0)); + members.when(() -> Members.player(anyString())).thenAnswer(i -> { + Player player = Bukkit.getPlayerExact(i.getArgument(0, String.class)); + return player == null ? Bukkit.getPlayer(i.getArgument(0, String.class)) : player; + }); + } + @Override public void beforeTestExecution(ExtensionContext context) { + if (DowsingMain.plugin != null && mockingDetails(DowsingMain.plugin).isMock()) + when(DowsingMain.plugin.nodesReady()).thenReturn(true); + } + @Override public void afterEach(ExtensionContext context) { + members.close(); api.close(); DowsingMain.plugin = previous; + } +} diff --git a/src/test/java/net/tfminecraft/dowsing/utils/DatabaseTest.java b/src/test/java/net/tfminecraft/dowsing/utils/DatabaseTest.java index 42981db..c2c4d4d 100644 --- a/src/test/java/net/tfminecraft/dowsing/utils/DatabaseTest.java +++ b/src/test/java/net/tfminecraft/dowsing/utils/DatabaseTest.java @@ -170,4 +170,17 @@ Node node(World world, boolean owned) { JSONObject result=(JSONObject)new JSONParser().parse(Files.readString(path)); assertEquals(4.0,result.get("integer")); assertEquals("§aGreen",result.get("existing")); assertFalse(result.containsKey("unsupported")); assertFalse(db.save(root.toFile(),defaults)); } + + @Test void snapshotSavesBeforePruningAndPreservesUnknownFiles() throws Exception { + World world=mock(World.class); when(world.getName()).thenReturn("world"); + Node kept=node(world,false), removed=node(world,false); + assertTrue(db.saveNode(removed)); + Path unknown=Files.writeString(root.resolve("Nodes/staff-note.txt"),"keep"); + assertTrue(db.saveNodes(List.of(kept))); + assertTrue(Files.exists(root.resolve("Nodes/"+kept.getId()+".json"))); + assertFalse(Files.exists(root.resolve("Nodes/"+removed.getId()+".json"))); + assertTrue(Files.exists(unknown)); + Database failing=spy(db); doReturn(false).when(failing).saveNode(kept); + assertFalse(failing.saveNodes(List.of(kept))); + } } diff --git a/src/test/java/net/tfminecraft/dowsing/utils/NodePaymentsTest.java b/src/test/java/net/tfminecraft/dowsing/utils/NodePaymentsTest.java new file mode 100644 index 0000000..95d68b6 --- /dev/null +++ b/src/test/java/net/tfminecraft/dowsing/utils/NodePaymentsTest.java @@ -0,0 +1,120 @@ +package net.tfminecraft.dowsing.utils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import net.tfminecraft.denareconomy.api.*; +import net.tfminecraft.dowsing.managers.NodeManager; +import net.tfminecraft.dowsing.objects.Node; +import net.tfminecraft.simplefactions.guild.Guild; +import net.tfminecraft.simplefactions.managers.FactionManager; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +class NodePaymentsTest { + @TempDir Path directory; + Database database; + Guild guild; + Node node; + UUID incarnation; + NodePayments payments; + AtomicInteger level; + MockedStatic denars; + MockedStatic factions; + List previousNodes; + Map previousCapacity; + final List operations = new ArrayList<>(); + Result result; + @BeforeEach void setup() { + database = mock(Database.class); + when(database.saveNode(any())).thenReturn(true); + when(database.saveGuildCapacity()).thenReturn(true); + guild = mock(Guild.class); incarnation = UUID.randomUUID(); + when(guild.getId()).thenReturn("guild"); when(guild.getIncarnationId()).thenReturn(incarnation); + node = mock(Node.class); when(node.getId()).thenReturn(UUID.randomUUID()); when(node.getGuild()).thenReturn(guild); + level = new AtomicInteger(1); when(node.getLevel()).thenAnswer(i -> level.get()); + doAnswer(i -> { level.set(i.getArgument(0)); return null; }).when(node).setLevel(anyInt()); + previousNodes = new ArrayList<>(NodeManager.nodes); NodeManager.nodes.clear(); NodeManager.nodes.add(node); + previousCapacity = new HashMap<>(NodeManager.extraCapacityByGuild); NodeManager.extraCapacityByGuild.clear(); + denars = mockStatic(Denars.class, CALLS_REAL_METHODS); + result = Result.APPLIED; + denars.when(() -> Denars.post(anyString(), anyString(), anyList())).thenAnswer(i -> { + assertTrue(Files.exists(directory.resolve("node-payment.json")), "intent precedes the posting"); + operations.add(i.getArgument(0)); return result; + }); + factions = mockStatic(FactionManager.class); + factions.when(() -> FactionManager.getGuildByString("guild")).thenReturn(guild); + payments = reopen(); + } + NodePayments reopen() { return new NodePayments(directory, database, Logger.getAnonymousLogger()); } + @AfterEach void cleanup() { + factions.close(); denars.close(); NodeManager.nodes.clear(); NodeManager.nodes.addAll(previousNodes); + NodeManager.extraCapacityByGuild.clear(); NodeManager.extraCapacityByGuild.putAll(previousCapacity); + } + @Test void successfulChangesPersistExactLevelCapacityAndRefund() { + assertTrue(payments.recover()); + assertTrue(payments.change(node,guild,"level",1,2,12.345)); + List expected=List.of(new Leg(Denars.guild(incarnation),-1235), new Leg(Denars.MINT,1235)); + denars.verify(() -> Denars.post(anyString(), eq("dowsing:level"), eq(expected))); + assertEquals(2,level.get()); assertFalse(payments.blocked()); + assertTrue(reopen().recover()); assertEquals(1,operations.size()); + assertTrue(payments.change(node,guild,"level",2,1,-5.0)); + assertEquals(1,level.get()); + assertTrue(payments.change(node,guild,"capacity",0,1,10)); + assertEquals(1,NodeManager.getExtraCapacity(guild)); verify(database, atLeastOnce()).saveGuildCapacity(); + } + @Test void refusalLeavesStateWhileUnavailableBlocksNewIntentAndRecoversSameId() { + result = Result.REFUSED; + assertFalse(payments.change(node,guild,"level",1,2,10)); assertEquals(1,level.get()); assertFalse(payments.blocked()); + result = Result.UNAVAILABLE; + assertFalse(payments.change(node,guild,"level",1,2,10)); String operation=operations.getLast(); + assertTrue(payments.blocked()); assertFalse(payments.change(node,guild,"capacity",0,1,20)); + assertEquals(2,operations.size()); + result = Result.APPLIED; payments=reopen(); assertTrue(payments.recover()); + assertEquals(operation,operations.getLast()); assertEquals(2,level.get()); + } + @Test void crashAfterPostingReappliesTheSavedTargetAndNeverIncrementsAgain() { + when(database.saveNode(node)).thenReturn(true, false, true); + assertFalse(payments.change(node,guild,"level",1,2,10)); assertEquals(2,level.get()); + payments=reopen(); assertTrue(payments.recover()); assertEquals(2,level.get()); + assertEquals(operations.getFirst(),operations.getLast()); + assertTrue(reopen().recover()); assertEquals(2,operations.size()); + } + @Test void failedCapacitySaveIsReplayedWithSamePayment() { + when(database.saveGuildCapacity()).thenReturn(true, false, true); + assertFalse(payments.change(node,guild,"capacity",0,1,10)); + assertTrue(reopen().recover()); assertEquals(1,NodeManager.getExtraCapacity(guild)); + assertEquals(operations.getFirst(),operations.getLast()); + } + @Test void conflictsAndChangedOwnersRemainBlockedForStaff() { + result=Result.CONFLICT; assertFalse(payments.change(node,guild,"level",1,2,10)); + when(guild.getIncarnationId()).thenReturn(UUID.randomUUID()); + assertFalse(reopen().recover()); assertEquals(1,operations.size()); + } + @Test void conflictingTargetStateAndInvalidJournalNeverPost() throws Exception { + result=Result.UNAVAILABLE; assertFalse(payments.change(node,guild,"level",1,2,10)); + level.set(7); assertFalse(reopen().recover()); assertEquals(1,operations.size()); + Files.writeString(directory.resolve("node-payment.json"), "null"); + assertFalse(reopen().recover()); + Files.writeString(directory.resolve("node-payment.json"), "broken json"); + assertFalse(reopen().recover()); + } + @Test void initialPersistenceAndInvalidAmountsFailBeforePosting() throws Exception { + when(database.saveNode(node)).thenReturn(false); + assertFalse(payments.change(node,guild,"level",1,2,10)); assertTrue(operations.isEmpty()); + assertTrue(payments.recover()); when(database.saveNode(node)).thenReturn(true); + when(database.saveGuildCapacity()).thenReturn(false); + assertFalse(payments.change(node,guild,"level",1,2,10)); assertTrue(operations.isEmpty()); + assertTrue(payments.recover()); when(database.saveGuildCapacity()).thenReturn(true); + assertFalse(payments.change(node,guild,"level",1,2,Double.NaN)); assertTrue(operations.isEmpty()); + assertTrue(payments.recover()); Files.createDirectory(directory.resolve("node-payment.json")); + assertFalse(payments.change(node,guild,"level",1,2,10)); assertTrue(operations.isEmpty()); + } + @Test void invalidOperationAndRefusedRecoveryAreRetainedOrCompletedSafely() { + assertFalse(payments.change(node,guild,"wrong",1,2,10)); assertTrue(operations.isEmpty()); + } +} diff --git a/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension new file mode 100644 index 0000000..5343b1a --- /dev/null +++ b/src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension @@ -0,0 +1 @@ +net.tfminecraft.dowsing.testsupport.FactionFixture diff --git a/src/test/resources/junit-platform.properties b/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..6efc0d5 --- /dev/null +++ b/src/test/resources/junit-platform.properties @@ -0,0 +1 @@ +junit.jupiter.extensions.autodetection.enabled=true