diff --git a/pom.xml b/pom.xml
index e8a3e6d..a8cd1c4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -91,6 +91,12 @@
5.14.0
test
+
+ org.mockito
+ mockito-core
+ 5.20.0
+ test
+
net.tfminecraft
magic
diff --git a/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java b/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java
index 21a5027..4b45d2b 100644
--- a/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java
+++ b/src/main/java/net/tfminecraft/dowsing/managers/InventoryManager.java
@@ -334,7 +334,13 @@ ItemStack createStatus(Node n) {
i.setType(Material.RED_STAINED_GLASS_PANE);
ItemMeta m = i.getItemMeta();
m.setDisplayName("§cINACTIVE");
- if(n.getErrors().size() > 0) {
+ if(n.hasPendingRefund()) {
+ m.setDisplayName("§eRetry Refund");
+ List lore = new ArrayList<>(n.getErrors());
+ lore.add("§7Restore the barrel and hopper, then click to retry.");
+ lore.add("§7Refunds must finish before changing or activating this node.");
+ m.setLore(lore);
+ } else if(n.getErrors().size() > 0) {
m.setLore(n.getErrors());
}
i.setItemMeta(m);
diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java
index e916ca5..0b9e595 100644
--- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java
+++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java
@@ -231,6 +231,7 @@ public void validate() {
}
}
public void confirmClick(Player p, Node n, ConfirmType t) {
+ if(t != ConfirmType.DEACTIVATE && blockPendingRefund(p, n)) return;
if(t.equals(ConfirmType.DEACTIVATE)) {
n.deActivate();
InventoryManager inv = new InventoryManager();
@@ -254,6 +255,11 @@ public void confirmClick(Player p, Node n, ConfirmType t) {
currentNode.put(p, n);
}
}
+ private boolean blockPendingRefund(Player p, Node n) {
+ if(!n.hasPendingRefund()) return false;
+ p.sendMessage("§cRestore the barrel and hopper, then click Retry Refund before changing this node.");
+ return true;
+ }
@EventHandler(ignoreCancelled = true)
public void placeVanillaNode(BlockPlaceEvent e) {
NodeBlock b = BlockLoader.getByBlock(e.getBlock().getType());
@@ -387,6 +393,7 @@ public void invenClick(InventoryClickEvent e) {
InventoryManager inv = new InventoryManager();
if(e.getView().getTitle().equalsIgnoreCase("§7"+n.getBlock().getResource()+ " Node")) {
e.setCancelled(true);
+ if(e.getClickedInventory() != e.getView().getTopInventory()) return;
if(!n.hasGuild()) {
n.breakNode();
nodes.remove(n);
@@ -399,6 +406,7 @@ public void invenClick(InventoryClickEvent e) {
p.playSound(p.getLocation(), Sound.ENTITY_VILLAGER_NO, 1f, 1f);
return;
}
+ if(e.getSlot() != 17 && blockPendingRefund(p, n)) return;
if(e.getSlot() == 8) {
if(n.getIsActive()) {
p.sendMessage("§cCannot upgrade while node is active");
@@ -436,6 +444,11 @@ public void invenClick(InventoryClickEvent e) {
if(n.getIsActive()) {
confirm.put(p, ConfirmType.DEACTIVATE);
inv.confirmView(p);
+ } else if(n.hasPendingRefund()) {
+ n.deActivate();
+ p.sendMessage(n.hasPendingRefund()
+ ? "§cRefund still pending. Restore the barrel and hopper, then retry."
+ : "§aRefund complete. The node remains inactive.");
} else {
n.activate();
if(n.getIsActive()) {
@@ -482,6 +495,8 @@ public void invenClick(InventoryClickEvent e) {
}
} else if(currentSlot.get(p) != null && e.getView().getTitle().equalsIgnoreCase("§7"+n.getBlock().getResource()+" Node: "+WordUtils.capitalize(currentSlot.get(p).getId().replace("_", " ")))) {
e.setCancelled(true);
+ if(e.getClickedInventory() != e.getView().getTopInventory()) return;
+ if(e.getSlot() != 26 && blockPendingRefund(p, n)) return;
if(!n.hasGuild()) {
n.breakNode();
nodes.remove(n);
@@ -517,6 +532,8 @@ public void invenClick(InventoryClickEvent e) {
currentNode.put(p, n);
} else if(e.getView().getTitle().equalsIgnoreCase("§7"+n.getBlock().getResource()+" Node: Type")) {
e.setCancelled(true);
+ if(e.getClickedInventory() != e.getView().getTopInventory()) return;
+ if(e.getSlot() != 26 && blockPendingRefund(p, n)) return;
if(!n.hasGuild()) {
n.breakNode();
nodes.remove(n);
@@ -550,6 +567,7 @@ public void invenClick(InventoryClickEvent e) {
inv.confirmView(p);
} else if(e.getView().getTitle().equalsIgnoreCase("§7Confirm Action")) {
e.setCancelled(true);
+ if(e.getClickedInventory() != e.getView().getTopInventory()) return;
if(!n.hasGuild()) {
n.breakNode();
nodes.remove(n);
diff --git a/src/main/java/net/tfminecraft/dowsing/objects/Node.java b/src/main/java/net/tfminecraft/dowsing/objects/Node.java
index 9da7d0a..d1a86ee 100644
--- a/src/main/java/net/tfminecraft/dowsing/objects/Node.java
+++ b/src/main/java/net/tfminecraft/dowsing/objects/Node.java
@@ -404,6 +404,10 @@ public void refund() {
ng.refund(this);;
}
public void activate() {
+ if(hasPendingRefund()) {
+ deActivate();
+ return;
+ }
if(isClaimable()) return;
NodeEngine ng = new NodeEngine();
Boolean failed = false;
@@ -456,12 +460,20 @@ public void activate() {
ng.takeInputs(this);
NodeManager.requestNodeBenefitSync();
}
+ /** Whether an inactive node still owes refunds from an interrupted cycle. */
+ public boolean hasPendingRefund() {
+ return !this.isActive && this.inputCounter > 0
+ && this.cycleTime >= 0 && this.cycleTime < Cache.cycleLength;
+ }
public void deActivate() {
this.isActive = false;
if(isClaimable()) return;
- if(this.cycleTime > 0 && this.cycleTime < Cache.cycleLength) {
+ if(this.cycleTime >= 0 && this.cycleTime < Cache.cycleLength) {
while(this.getInputCounter() > 0) {
+ int pendingInputs = this.getInputCounter();
refund();
+ // A missing barrel or hopper prevents refunds. Keep the pending inputs for retry.
+ if(this.getInputCounter() >= pendingInputs) break;
}
}
NodeManager.requestNodeBenefitSync();
diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java
new file mode 100644
index 0000000..5a963b0
--- /dev/null
+++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java
@@ -0,0 +1,155 @@
+package net.tfminecraft.dowsing.managers;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.contains;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.bukkit.entity.Player;
+import org.bukkit.event.inventory.InventoryClickEvent;
+import org.bukkit.inventory.Inventory;
+import org.bukkit.inventory.InventoryView;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import net.tfminecraft.dowsing.enums.ConfirmType;
+import net.tfminecraft.dowsing.objects.Node;
+import net.tfminecraft.dowsing.objects.NodeBlock;
+import net.tfminecraft.dowsing.objects.NodeSlot;
+
+class NodeRefundMenuTest {
+ private NodeManager manager;
+ private Node node;
+ private Player player;
+ private InventoryClickEvent click;
+ private InventoryView view;
+
+ @BeforeEach
+ void setUp() {
+ manager = new NodeManager();
+ node = mock(Node.class);
+ player = mock(Player.class);
+ click = mock(InventoryClickEvent.class);
+ view = mock(InventoryView.class);
+ NodeBlock block = mock(NodeBlock.class);
+ when(node.getBlock()).thenReturn(block);
+ when(block.getResource()).thenReturn("Iron");
+ when(node.hasGuild()).thenReturn(true);
+ when(node.hasPendingRefund()).thenReturn(true);
+ when(node.getIsActive()).thenReturn(false);
+ when(player.hasPermission("dowsing.admin")).thenReturn(true);
+ when(click.getWhoClicked()).thenReturn(player);
+ when(click.getView()).thenReturn(view);
+ when(view.getTitle()).thenReturn("§7Iron Node");
+ Inventory top = mock(Inventory.class);
+ when(click.getClickedInventory()).thenReturn(top);
+ when(view.getTopInventory()).thenReturn(top);
+ manager.currentNode.put(player, node);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"§7Iron Node", "§7Iron Node: Type", "§7Iron Node: Fuel", "§7Confirm Action"})
+ void lowerInventoryClicksCannotTriggerNodeActions(String title) {
+ NodeSlot slot = mock(NodeSlot.class);
+ when(slot.getId()).thenReturn("fuel");
+ manager.currentSlot.put(player, slot);
+ when(view.getTitle()).thenReturn(title);
+ when(click.getSlot()).thenReturn(17);
+ when(click.getClickedInventory()).thenReturn(mock(Inventory.class));
+
+ manager.invenClick(click);
+
+ verify(click).setCancelled(true);
+ verify(node, never()).deActivate();
+ verify(node, never()).activate();
+ verify(node, never()).update();
+ verify(node, never()).breakNode();
+ verify(player, never()).sendMessage(anyString());
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {6, 8, 9, 18, 24, 26, 10})
+ void pendingRefundBlocksMainMenuChanges(int slot) {
+ when(click.getSlot()).thenReturn(slot);
+
+ manager.invenClick(click);
+
+ verify(click).setCancelled(true);
+ verify(player).sendMessage(contains("Retry Refund"));
+ verify(node, never()).setGuild(any());
+ verify(node, never()).breakNode();
+ verify(node, never()).activate();
+ }
+
+ @ParameterizedTest
+ @EnumSource(value = ConfirmType.class, names = {"DELETE_NODE", "CHANGE_TYPE"})
+ void pendingRefundBlocksStaleConfirmation(ConfirmType action) {
+ manager.confirmClick(player, node, action);
+
+ verify(player).sendMessage(contains("Retry Refund"));
+ verify(node, never()).breakNode();
+ verify(node, never()).setCurrentType(any());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"§7Iron Node: Type", "§7Iron Node: Fuel"})
+ void pendingRefundBlocksAlreadyOpenSubmenus(String title) {
+ NodeSlot slot = mock(NodeSlot.class);
+ when(slot.getId()).thenReturn("fuel");
+ manager.currentSlot.put(player, slot);
+ when(view.getTitle()).thenReturn(title);
+
+ manager.invenClick(click);
+
+ verify(click).setCancelled(true);
+ verify(player).sendMessage(contains("Retry Refund"));
+ verify(slot, never()).setActivePm(any());
+ }
+
+ @Test
+ void statusButtonRetriesRefundWithoutActivating() {
+ when(click.getSlot()).thenReturn(17);
+
+ manager.invenClick(click);
+
+ verify(node).deActivate();
+ verify(node, never()).activate();
+ verify(player).sendMessage(contains("Refund still pending"));
+ }
+
+ @Test
+ void successfulRetryLeavesNodeInactive() {
+ when(click.getSlot()).thenReturn(17);
+ when(node.hasPendingRefund()).thenReturn(true, false);
+
+ manager.invenClick(click);
+
+ verify(node).deActivate();
+ verify(node, never()).activate();
+ verify(player).sendMessage(contains("Refund complete"));
+ }
+
+ @Test
+ void statusButtonStillActivatesWhenNoRefundIsPending() {
+ when(click.getSlot()).thenReturn(17);
+ when(node.hasPendingRefund()).thenReturn(false);
+ // Stop at the production boundary before Bukkit's server-backed sound registry is needed.
+ RuntimeException activationReached = new RuntimeException("Activation reached");
+ doThrow(activationReached).when(node).activate();
+
+ assertSame(activationReached, assertThrows(RuntimeException.class, () -> manager.invenClick(click)));
+
+ verify(node).activate();
+ verify(node, never()).deActivate();
+ verify(player, never()).sendMessage(anyString());
+ }
+}
diff --git a/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java b/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java
new file mode 100644
index 0000000..d1ec256
--- /dev/null
+++ b/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java
@@ -0,0 +1,146 @@
+package net.tfminecraft.dowsing.objects;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import net.tfminecraft.dowsing.Cache;
+
+class NodeDeactivationTest {
+
+ private Integer previousCycleLength;
+
+ @BeforeEach
+ void setCycleLength() {
+ previousCycleLength = Cache.cycleLength;
+ Cache.cycleLength = 60;
+ }
+
+ @AfterEach
+ void restoreCycleLength() {
+ Cache.cycleLength = previousCycleLength;
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, 1})
+ void failedRefundStopsAndKeepsPendingInputsForRetry(int cycleTime) {
+ RefundNode node = new RefundNode(3, 0);
+ node.setCycleTime(cycleTime);
+
+ node.deActivate();
+
+ assertFalse(node.getIsActive());
+ assertEquals(1, node.attempts);
+ assertEquals(3, node.getInputCounter());
+ assertTrue(node.hasPendingRefund());
+
+ node.successfulRefundsRemaining = 3;
+ node.deActivate();
+
+ assertEquals(4, node.attempts);
+ assertEquals(0, node.getInputCounter());
+ assertFalse(node.hasPendingRefund());
+ }
+
+ @Test
+ void activationRetriesPendingRefundWithoutStartingANewCycle() {
+ RefundNode node = new RefundNode(3, 0);
+ node.deActivate();
+ node.successfulRefundsRemaining = 3;
+
+ node.activate();
+
+ assertEquals(0, node.getInputCounter());
+ assertFalse(node.getIsActive());
+ assertFalse(node.hasPendingRefund());
+ }
+
+ @Test
+ void onlyInterruptedInactiveCyclesHavePendingRefunds() {
+ RefundNode node = new RefundNode(3, 0);
+ assertFalse(node.hasPendingRefund());
+ node.isActive = false;
+ assertTrue(node.hasPendingRefund());
+ node.setCycleTime(0);
+ assertTrue(node.hasPendingRefund());
+ node.setCycleTime(-1);
+ assertFalse(node.hasPendingRefund());
+ node.setCycleTime(Cache.cycleLength);
+ assertFalse(node.hasPendingRefund());
+ }
+
+ @Test
+ void partialRefundStopsAtFirstFailure() {
+ RefundNode node = new RefundNode(3, 1);
+
+ node.deActivate();
+
+ assertFalse(node.getIsActive());
+ assertEquals(2, node.attempts);
+ assertEquals(2, node.getInputCounter());
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0, 1})
+ void successfulRefundsDrainPendingInputs(int cycleTime) {
+ RefundNode node = new RefundNode(3, 3);
+ node.setCycleTime(cycleTime);
+
+ node.deActivate();
+
+ assertFalse(node.getIsActive());
+ assertEquals(3, node.attempts);
+ assertEquals(0, node.getInputCounter());
+ }
+
+ @Test
+ void noPendingInputsSkipsRefunds() {
+ RefundNode node = new RefundNode(0, 0);
+
+ node.deActivate();
+
+ assertFalse(node.getIsActive());
+ assertEquals(0, node.attempts);
+ }
+
+ private static final class RefundNode extends Node {
+ private int attempts;
+ private int successfulRefundsRemaining;
+ private final int maximumAttempts;
+
+ RefundNode(int pendingInputs, int successfulRefunds) {
+ super(UUID.randomUUID(), null, null, null, true, 1, 1, null, 10, pendingInputs, 50);
+ successfulRefundsRemaining = successfulRefunds;
+ maximumAttempts = pendingInputs + 1;
+ }
+
+ @Override
+ public boolean isClaimable() {
+ return false;
+ }
+
+ @Override
+ public void update() {
+ // No world or production configuration is needed for the refund loop.
+ }
+
+ @Override
+ public void refund() {
+ // Fail deterministically on the old loop instead of hanging the test JVM.
+ assertTrue(++attempts <= maximumAttempts, "Deactivation kept retrying a failed refund");
+ if (successfulRefundsRemaining > 0) {
+ successfulRefundsRemaining--;
+ setInputCounter(getInputCounter() - 1);
+ }
+ // Missing barrel/hopper returns without consuming a pending input.
+ }
+ }
+}