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