diff --git a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java index c6f9cc5..d306a58 100644 --- a/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java +++ b/src/main/java/net/tfminecraft/dowsing/managers/NodeManager.java @@ -429,6 +429,12 @@ public void invenClick(InventoryClickEvent e) { Player p = (Player) e.getWhoClicked(); if(!currentNode.containsKey(p)) return; Node n = currentNode.get(p); + if(!nodes.contains(n)) { + // The node was already removed; stale menus must not act on it. + currentNode.remove(p); + p.closeInventory(); + return; + } InventoryManager inv = new InventoryManager(); if(e.getView().getTitle().equalsIgnoreCase("§7"+n.getBlock().getResource()+ " Node")) { e.setCancelled(true); diff --git a/src/main/java/net/tfminecraft/dowsing/objects/Node.java b/src/main/java/net/tfminecraft/dowsing/objects/Node.java index ed29310..a2cada5 100644 --- a/src/main/java/net/tfminecraft/dowsing/objects/Node.java +++ b/src/main/java/net/tfminecraft/dowsing/objects/Node.java @@ -54,6 +54,8 @@ public class Node { Integer multiplier; Double costIncrease; Map lastResult = new HashMap<>(); + Map pendingResult; + boolean broken; List addedDrops = new ArrayList(); List errors = new ArrayList(); Map completeDrop = new HashMap<>(); @@ -151,6 +153,13 @@ public Map getLastResult() { public void setLastResult(Map lastResult) { this.lastResult = lastResult; } + /** Rolled output that could not be delivered yet; null when no cycle output is waiting. */ + public Map getPendingResult() { + return pendingResult; + } + public void setPendingResult(Map pendingResult) { + this.pendingResult = pendingResult; + } public NodeBlock getBlock() { return block; } @@ -409,6 +418,7 @@ public void input() { ng.takeInputs(this); } public void refund() { + if(this.broken) return; NodeEngine ng = new NodeEngine(); Boolean failed = false; this.errors.clear(); @@ -431,6 +441,7 @@ public void refund() { ng.refund(this);; } public void activate() { + if(this.broken) return; if(hasPendingRefund()) { deActivate(); return; @@ -480,6 +491,13 @@ public void activate() { if(failed) { return; } + if(this.pendingResult != null && !this.pendingResult.isEmpty()) { + // Settle held output before new inputs are taken, so a later refund cannot cancel it. + if(!new ItemDropper().dropItems(this)) { + this.errors.add("§7Held output does not fit in the hopper"); + return; + } + } this.isActive = true; this.timeLeft = this.modifiedTime; this.cycleTime = 0; @@ -493,14 +511,19 @@ public boolean hasPendingRefund() { } public void deActivate() { this.isActive = false; + // A broken node is terminal: stale menus must not refund or restart it. + if(this.broken) return; if(isClaimable()) return; if(this.cycleTime >= 0 && this.cycleTime < Cache.cycleLength) { + boolean refunded = this.getInputCounter() > 0; 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; } + // Returning this cycle's inputs cancels its held output; incomplete refunds keep it. + if(refunded && this.getInputCounter() == 0) setPendingResult(null); } NodeManager.requestNodeBenefitSync(); } @@ -667,6 +690,9 @@ public Integer getNaturalYieldFromChunk(String resource, Location l) throws IOEx return 0; } public void breakNode() { + // Stale menus can repeat removal; the node item and held output must drop only once. + if(this.broken) return; + this.broken = true; String path = this.getBlock().getBlock(); String type = path.split("\\.")[0]; if(type.equalsIgnoreCase("v")) { @@ -680,6 +706,16 @@ public void breakNode() { Location loc = new Location(this.getLoc().getWorld(), this.getLoc().getX(), this.getLoc().getY(), this.getLoc().getZ()); loc.add(0.5,0,0.5); ItemDropper dropper = new ItemDropper(); + Map heldOutput = this.pendingResult; + setPendingResult(null); + if(heldOutput != null) { + // Removing the node returns its held output instead of discarding it. + for(Map.Entry entry : heldOutput.entrySet()) { + for(int count = 0; count < entry.getValue(); count++) { + dropper.dropItem(loc, entry.getKey(), false); + } + } + } dropper.dropItem(loc, path, false); loc.getWorld().playSound(loc, Sound.ENTITY_GLOW_ITEM_FRAME_REMOVE_ITEM, 0.5f, 1f); NodeManager.requestNodeBenefitSync(); diff --git a/src/main/java/net/tfminecraft/dowsing/utils/Database.java b/src/main/java/net/tfminecraft/dowsing/utils/Database.java index dbee916..4fa0bba 100644 --- a/src/main/java/net/tfminecraft/dowsing/utils/Database.java +++ b/src/main/java/net/tfminecraft/dowsing/utils/Database.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.TreeMap; import java.util.UUID; import java.util.logging.Level; @@ -38,6 +39,7 @@ @SuppressWarnings("deprecation") // Stored configuration text uses Bukkit legacy color codes. public class Database { + static final String PENDING_RESULT_KEY = "pending result"; private JSONObject json = new JSONObject(); // org.json.simple JSONParser parser = new JSONParser(); private File[] listFiles(File directory) { @@ -116,6 +118,10 @@ public void loadNodes() { if(json.containsKey(LastCycleResult.KEY)) { n.setLastResult(LastCycleResult.fromJson(json.get(LastCycleResult.KEY))); } + if(json.containsKey(PENDING_RESULT_KEY)) { + Map pendingResult = LastCycleResult.fromJson(json.get(PENDING_RESULT_KEY)); + if(!pendingResult.isEmpty()) n.setPendingResult(pendingResult); + } NodeManager.nodes.add(n); } catch (Exception ex) { ex.printStackTrace(); @@ -248,6 +254,7 @@ public boolean saveNode(Node n) { defaults.put("active pms", pmArray); defaults.put("efficiency", n.getEfficiency()); defaults.put(LastCycleResult.KEY, LastCycleResult.toJson(n.getLastResult())); + defaults.put(PENDING_RESULT_KEY, LastCycleResult.toJson(n.getPendingResult())); return save(file, defaults); } catch (Throwable ex) { ex.printStackTrace(); diff --git a/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java b/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java index ed6e2cb..84609bc 100644 --- a/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java +++ b/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java @@ -1,6 +1,8 @@ package net.tfminecraft.dowsing.utils; +import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.HashSet; @@ -8,8 +10,12 @@ import java.util.function.Consumer; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.Sound; +import org.bukkit.block.Block; +import org.bukkit.block.Hopper; import org.bukkit.entity.Item; +import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.Vector; @@ -29,10 +35,17 @@ public ItemDropper() { this.warning = warning; } - /** Returns false when a configured reward cannot be created, so the cycle can retry. */ + /** + * Delivers output into the hopper above the node when one is present, so blocks + * above the hopper cannot trap and despawn it. Returns false when a configured + * reward cannot be created or the hopper cannot hold the output, so the cycle + * can retry instead of losing items. + */ public boolean dropItems(Node n) { + Map pending = n.getPendingResult(); + boolean retained = pending != null && !pending.isEmpty(); Map items = new HashMap<>(); - for(String key : n.getCompleteDrop().keySet()) { + for(String key : retained ? pending.keySet() : n.getCompleteDrop().keySet()) { if(key.equalsIgnoreCase("nothing")) continue; ItemStack item = itemFactory.apply(key); if(item == null) { @@ -46,44 +59,77 @@ public boolean dropItems(Node n) { items.put(key, item); } Map result = new HashMap<>(); - Double maxWeight = 0.0; - for(String key : n.getCompleteDrop().keySet()) { - maxWeight = maxWeight+n.getCompleteDrop().get(key); - } - int safety = 0; - Integer dropped = 0; - while(dropped < n.getYield() && safety < n.getYield() * 10) { - safety++; - Double random = Math.random(); - Double previous = 0.0; + if(retained) { + // A held cycle keeps its rolled output; retries must not reroll it. + result = new LinkedHashMap<>(pending); + } else { + Double maxWeight = 0.0; for(String key : n.getCompleteDrop().keySet()) { - if(maxWeight <= 0) { - maxWeight = 1.0; - } - Double chance = n.getCompleteDrop().get(key) / maxWeight; - Double max = previous+chance; - if(random <= max && random > previous) { - dropped++; - if(!key.equalsIgnoreCase("nothing")) { - result.merge(key, 1, Integer::sum); + maxWeight = maxWeight+n.getCompleteDrop().get(key); + } + int safety = 0; + Integer dropped = 0; + while(dropped < n.getYield() && safety < n.getYield() * 10) { + safety++; + Double random = Math.random(); + Double previous = 0.0; + for(String key : n.getCompleteDrop().keySet()) { + if(maxWeight <= 0) { + maxWeight = 1.0; + } + Double chance = n.getCompleteDrop().get(key) / maxWeight; + Double max = previous+chance; + if(random <= max && random > previous) { + dropped++; + if(!key.equalsIgnoreCase("nothing")) { + result.merge(key, 1, Integer::sum); + } + break; } - break; + previous = max; } - previous = max; } } n.update(); Location loc = n.getLoc().clone().add(0.5, 2, 0.5); - for(Map.Entry entry : result.entrySet()) { - for(int count = 0; count < entry.getValue(); count++) { - Item e = loc.getWorld().dropItem(loc, items.get(entry.getKey()).clone()); - e.setVelocity(new Vector()); - loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + Block above = n.getLoc().clone().add(0, 1, 0).getBlock(); + if(above != null && above.getType() == Material.HOPPER && above.getState() instanceof Hopper hopper) { + if(!deliverToHopper(n, hopper.getInventory(), items, result, loc)) { + n.setPendingResult(result); + return false; + } + } else { + for(Map.Entry entry : result.entrySet()) { + for(int count = 0; count < entry.getValue(); count++) { + Item e = loc.getWorld().dropItem(loc, items.get(entry.getKey()).clone()); + e.setVelocity(new Vector()); + loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + } } } + n.setPendingResult(null); n.setLastResult(result); return true; } + /** One transaction: a full hopper keeps its contents and the pending output for a later retry. */ + private boolean deliverToHopper(Node n, Inventory inventory, Map items, Map result, Location loc) { + ItemStack[] before = Arrays.stream(inventory.getContents()) + .map(item -> item == null ? null : item.clone()).toArray(ItemStack[]::new); + for(Map.Entry entry : result.entrySet()) { + ItemStack stack = items.get(entry.getKey()).clone(); + stack.setAmount(entry.getValue()); + if(!inventory.addItem(stack).isEmpty()) { + inventory.setContents(before); + if(unavailableRewardsLogged.add("blocked:" + n.getId())) { + warning.accept("[Dowsing] Cannot complete node " + n.getId() + ": output hopper is full"); + } + return false; + } + } + unavailableRewardsLogged.remove("blocked:" + n.getId()); + if(!result.isEmpty()) loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + return true; + } public void dropItem(Location loc, String path, Boolean noV) { ItemCreator ic = new ItemCreator(); ItemStack item = ic.getItemFromPath(path); diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java index 27a9154..4dbfa0e 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeManagerEventsTest.java @@ -193,13 +193,24 @@ void unrelatedInventoryClicksAreIgnored() { verify(event, never()).setCancelled(true); } + @Test + void staleMenusCannotActOnRemovedNodes() { + InventoryClickEvent event = f.click("§7Confirm Action", 11); + f.manager.confirm.put(f.player, ConfirmType.DEACTIVATE); + NodeManager.nodes.remove(f.node); + f.manager.invenClick(event); + verify(f.player).closeInventory(); + assertFalse(f.manager.currentNode.containsKey(f.player)); + verify(f.node, never()).deActivate(); verify(f.node, never()).breakNode(); + } + @ParameterizedTest @ValueSource(strings = {"§7Iron Node", "§7Iron Node: Fuel", "§7Iron Node: Type", "§7Confirm Action"}) void everyManagedMenuCancelsBottomInventoryClicksAndRemovesOrphanNodes(String title) { f.manager.currentSlot.put(f.player, f.slot); InventoryClickEvent event = f.click(title, 0); when(event.getClickedInventory()).thenReturn(mock(Inventory.class)); f.manager.invenClick(event); verify(event).setCancelled(true); verify(f.node, never()).breakNode(); - when(f.node.hasGuild()).thenReturn(false); NodeManager.nodes.add(f.node); + when(f.node.hasGuild()).thenReturn(false); event = f.click(title, 0); f.manager.invenClick(event); verify(f.node).breakNode(); verify(f.player).closeInventory(); assertTrue(NodeManager.nodes.isEmpty()); } @@ -361,7 +372,7 @@ void confirmationMenusSupportCancelAcceptAndAbsentActions() { f.manager.confirm.put(f.player, ConfirmType.DELETE_NODE); f.manager.invenClick(f.click("§7Confirm Action", 15)); verify(f.lastInventory()).nodeView(f.player, f.node); f.manager.invenClick(f.click("§7Confirm Action", 0)); verify(f.node, never()).breakNode(); - NodeManager.nodes.add(f.node); f.manager.invenClick(f.click("§7Confirm Action", 11)); + f.manager.invenClick(f.click("§7Confirm Action", 11)); verify(f.node).breakNode(); assertTrue(NodeManager.nodes.isEmpty()); assertFalse(f.manager.confirm.containsKey(f.player)); } @@ -567,6 +578,7 @@ PlayerInteractEvent interact(Action action) { } InventoryClickEvent click(String title, int index) { manager.currentNode.put(player, node); + if (!NodeManager.nodes.contains(node)) NodeManager.nodes.add(node); InventoryClickEvent event = mock(InventoryClickEvent.class); InventoryView view = mock(InventoryView.class); when(event.getWhoClicked()).thenReturn(player); when(event.getView()).thenReturn(view); when(view.getTitle()).thenReturn(title); when(view.getTopInventory()).thenReturn(inventory); when(event.getClickedInventory()).thenReturn(inventory); when(event.getSlot()).thenReturn(index); return event; diff --git a/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java b/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java index 5a963b0..ae2bb81 100644 --- a/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java +++ b/src/test/java/net/tfminecraft/dowsing/managers/NodeRefundMenuTest.java @@ -15,6 +15,7 @@ import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryView; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -54,6 +55,12 @@ void setUp() { when(click.getClickedInventory()).thenReturn(top); when(view.getTopInventory()).thenReturn(top); manager.currentNode.put(player, node); + NodeManager.nodes.add(node); + } + + @AfterEach + void tearDown() { + NodeManager.nodes.remove(node); } @ParameterizedTest diff --git a/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java b/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java index 1d02e3e..746089e 100644 --- a/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java +++ b/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java @@ -57,6 +57,7 @@ void persistedStateAndAccessorsRetainTheRequestedValues() { assertEquals(0.0, node.getTimeModifier()); assertEquals(1, node.getMultiplier()); assertEquals(1.0, node.getCostIncrease()); assertTrue(node.getAddedDrops().isEmpty()); assertTrue(node.getErrors().isEmpty()); assertTrue(node.getLastResult().isEmpty()); + assertNull(node.getPendingResult()); UUID replacement = UUID.randomUUID(); Location moved = new Location(f.world, 8, 65, 9); NodeBlock replacementBlock = mock(NodeBlock.class); NodeType replacementType = mock(NodeType.class); @@ -67,6 +68,7 @@ void persistedStateAndAccessorsRetainTheRequestedValues() { node.setWealthModifier(2.5); node.setPrestigeGain(3.5); node.setTimeModifier(-20.0); node.setMultiplier(3); node.setCostIncrease(1.5); node.setAddedDrops(new ArrayList<>(List.of("v.STONE(1)"))); node.setErrors(new ArrayList<>(List.of("broken"))); node.setLastResult(new HashMap<>(Map.of("v.STONE", 2))); + node.setPendingResult(new HashMap<>(Map.of("v.DIRT", 5))); assertEquals(replacement, node.getId()); assertSame(replacementBlock, node.getBlock()); assertSame(moved, node.getLoc()); assertSame(replacementType, node.getCurrentType()); assertSame(f.guild, node.getGuild()); assertFalse(node.getIsActive()); assertEquals(3, node.getLevel()); @@ -78,6 +80,7 @@ void persistedStateAndAccessorsRetainTheRequestedValues() { assertEquals(3, node.getMultiplier()); assertEquals(1.5, node.getCostIncrease()); assertEquals(List.of("v.STONE(1)"), node.getAddedDrops()); assertEquals(List.of("broken"), node.getErrors()); assertEquals(Map.of("v.STONE", 2), node.getLastResult()); + assertEquals(Map.of("v.DIRT", 5), node.getPendingResult()); } @Test diff --git a/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java b/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java index d1ec256..e4f8d68 100644 --- a/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java +++ b/src/test/java/net/tfminecraft/dowsing/objects/NodeDeactivationTest.java @@ -2,8 +2,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.AfterEach; @@ -101,6 +103,33 @@ void successfulRefundsDrainPendingInputs(int cycleTime) { assertEquals(0, node.getInputCounter()); } + @Test + void completedRefundsCancelHeldOutputButIncompleteRefundsKeepIt() { + RefundNode node = new RefundNode(2, 1); + node.setPendingResult(Map.of("v.STONE", 3)); + + node.deActivate(); + + assertEquals(1, node.getInputCounter()); + assertEquals(Map.of("v.STONE", 3), node.getPendingResult()); + + node.successfulRefundsRemaining = 1; + node.deActivate(); + + assertEquals(0, node.getInputCounter()); + assertNull(node.getPendingResult()); + } + + @Test + void heldOutputSurvivesDeactivationWhenNoInputsAreRefunded() { + RefundNode node = new RefundNode(0, 0); + node.setPendingResult(Map.of("v.STONE", 3)); + + node.deActivate(); + + assertEquals(Map.of("v.STONE", 3), node.getPendingResult()); + } + @Test void noPendingInputsSkipsRefunds() { RefundNode node = new RefundNode(0, 0); diff --git a/src/test/java/net/tfminecraft/dowsing/objects/NodeProductionTest.java b/src/test/java/net/tfminecraft/dowsing/objects/NodeProductionTest.java index 67f8daf..9759761 100644 --- a/src/test/java/net/tfminecraft/dowsing/objects/NodeProductionTest.java +++ b/src/test/java/net/tfminecraft/dowsing/objects/NodeProductionTest.java @@ -5,6 +5,7 @@ import static org.mockito.Mockito.*; import java.util.List; +import java.util.Map; import org.bukkit.Location; import org.bukkit.Material; @@ -102,6 +103,27 @@ void successfulActivationResetsTheCycleConsumesInputsAndClearsOldErrors() { } } + @Test + void activationSettlesHeldOutputBeforeTakingNewInputs() { + Node node = f.owned(); node.setPendingResult(Map.of("v.STONE", 3)); + boolean[] blocked = {true}; + try (var engines = engines(true, true, true, true, true); + var droppers = mockConstruction(ItemDropper.class, (dropper, context) -> when(dropper.dropItems(node)) + .thenAnswer(inv -> { boolean wasBlocked = blocked[0]; blocked[0] = false; return !wasBlocked; }))) { + node.activate(); + assertFalse(node.getIsActive()); + assertEquals(List.of("§7Held output does not fit in the hopper"), node.getErrors()); + assertEquals(Map.of("v.STONE", 3), node.getPendingResult()); + for (NodeEngine engine : engines.constructed()) verify(engine, never()).takeInputs(any()); + node.activate(); + assertTrue(node.getIsActive(), node.getErrors().toString()); + assertEquals(1, node.getInputCounter()); + assertEquals(2, droppers.constructed().size()); + for (ItemDropper dropper : droppers.constructed()) verify(dropper).dropItems(node); + verify(engines.constructed().getLast()).takeInputs(node); + } + } + @Test void unclaimedNodesCannotStartAndPendingRefundsAreRetriedBeforeStarting() { Node node = f.node(); @@ -188,6 +210,36 @@ void breakingAVanillaNodeClearsTheBlockAndReturnsItsItemAtTheBlockCenter() { } } + @Test + void breakingANodeReturnsItsHeldOutputItemsOnlyOnce() { + Node node = f.node(); node.setPendingResult(Map.of("v.STONE", 2)); + try (var droppers = mockConstruction(ItemDropper.class)) { + node.breakNode(); + assertNull(node.getPendingResult()); + // A stale second menu repeating the removal must not drop anything again. + node.breakNode(); + verify(droppers.constructed().getFirst(), times(2)).dropItem(new Location(f.world, 2.5, 64, 3.5), "v.STONE", false); + verify(droppers.constructed().getFirst()).dropItem(new Location(f.world, 2.5, 64, 3.5), "v.IRON_BLOCK", false); + assertEquals(1, droppers.constructed().size()); + } + } + + @Test + void brokenNodesCannotActivateDeactivateOrRefund() { + Node node = f.owned(); node.setIsActive(true); node.setInputCounter(2); + node.setPendingResult(Map.of("v.STONE", 1)); + try (var droppers = mockConstruction(ItemDropper.class)) { + node.breakNode(); + } + try (var engines = engines(true, true, true, true, true)) { + node.activate(); + node.deActivate(); + node.refund(); + assertFalse(node.getIsActive()); assertEquals(2, node.getInputCounter()); + assertTrue(engines.constructed().isEmpty()); + } + } + @Test void furnitureRemovalSkipsUnrelatedEntitiesAndHandlesItemsAdderLookupFailures() { Node node = f.node(); when(f.block.getBlock()).thenReturn("ia.nodes:iron"); diff --git a/src/test/java/net/tfminecraft/dowsing/utils/DatabaseTest.java b/src/test/java/net/tfminecraft/dowsing/utils/DatabaseTest.java index c2c4d4d..ef7ceec 100644 --- a/src/test/java/net/tfminecraft/dowsing/utils/DatabaseTest.java +++ b/src/test/java/net/tfminecraft/dowsing/utils/DatabaseTest.java @@ -118,6 +118,7 @@ Node node(World world, boolean owned) { when(n.getIsActive()).thenReturn(true); when(n.getLevel()).thenReturn(2); when(n.getCycleTime()).thenReturn(3); when(n.getTimeLeft()).thenReturn(4); when(n.getInputCounter()).thenReturn(5); when(n.getEfficiency()).thenReturn(80.0); when(n.getLastResult()).thenReturn(Map.of("v.STONE",4)); when(n.hasGuild()).thenReturn(owned); + when(n.getPendingResult()).thenReturn(Map.of("v.DIRT",2)); Guild guild = mock(Guild.class); when(guild.getId()).thenReturn("guild"); when(n.getGuild()).thenReturn(guild); return n; } @@ -144,6 +145,7 @@ Node node(World world, boolean owned) { types.when(() -> TypeLoader.getByString("type")).thenReturn(loadedType); pms.when(() -> PMLoader.getByString("pm")).thenReturn(pm); db.loadNodes(); assertEquals(1,NodeManager.nodes.size()); verify(slot).setActivePm(pm); verify(NodeManager.nodes.getFirst()).setLastResult(Map.of("v.STONE",4)); + verify(NodeManager.nodes.getFirst()).setPendingResult(Map.of("v.DIRT",2)); NodeManager.nodes.clear(); json.remove("efficiency"); json.remove(LastCycleResult.KEY); json.put("active pms",new JSONArray()); Files.writeString(path,json.toJSONString()); db.loadNodes(); assertEquals(1,NodeManager.nodes.size()); types.when(() -> TypeLoader.getByString("type")).thenReturn(null); NodeManager.nodes.clear(); db.loadNodes(); assertTrue(NodeManager.nodes.isEmpty()); diff --git a/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java b/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java index c589e6b..316b0d3 100644 --- a/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java +++ b/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java @@ -18,9 +18,13 @@ import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.Hopper; import org.bukkit.entity.Item; +import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.util.Vector; import org.junit.jupiter.api.AfterEach; @@ -145,6 +149,115 @@ void directDropsSkipMissingItemsAndOptionallyClearVelocity() { } } + @Test + void outputGoesToTheHopperAboveTheNodeInsteadOfTheGround() { + World world = mock(World.class); + Location origin = new Location(world, 4, 60, 7); + Node node = nodeAt(origin, Map.of("v.STONE", 1.0), 3); + Inventory inventory = hopperAbove(world); + ItemStack existing = mock(ItemStack.class); + when(existing.clone()).thenReturn(mock(ItemStack.class)); + when(inventory.getContents()).thenReturn(new ItemStack[] {existing, null, null, null, null}); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + ItemStack template = mock(ItemStack.class); + ItemStack delivered = mock(ItemStack.class); + when(template.clone()).thenReturn(delivered); + assertTrue(new ItemDropper(path -> template, message -> fail(message)).dropItems(node)); + verify(delivered).setAmount(3); + verify(inventory).addItem(delivered); + verify(world, never()).dropItem(any(Location.class), any(ItemStack.class)); + verify(world).playSound(new Location(world, 4.5, 62, 7.5), Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + verify(node).setLastResult(Map.of("v.STONE", 3)); verify(node).update(); + assertEquals(new Location(world, 4, 60, 7), origin); + } + + @Test + void fullHopperHoldsTheCycleAndRestoresItsContents() { + World world = mock(World.class); + Node node = nodeAt(new Location(world, 4, 60, 7), Map.of("v.STONE", 1.0, "v.DIRT", 1.0), 3); + Map pending = new LinkedHashMap<>(Map.of("v.STONE", 1, "v.DIRT", 2)); + when(node.getPendingResult()).thenReturn(pending); + Inventory inventory = hopperAbove(world); + ItemStack[] before = new ItemStack[5]; + when(inventory.getContents()).thenReturn(before); + ItemStack template = mock(ItemStack.class); + ItemStack delivered = mock(ItemStack.class); + when(template.clone()).thenReturn(delivered); + // The first reward fits and the second does not, on both attempts. + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>(), new HashMap<>(Map.of(0, delivered)), + new HashMap<>(), new HashMap<>(Map.of(0, delivered))); + List warnings = new ArrayList<>(); + ItemDropper dropper = new ItemDropper(path -> template, warnings::add); + assertFalse(dropper.dropItems(node)); assertFalse(dropper.dropItems(node)); + verify(inventory, times(2)).setContents(before); + verify(node, times(2)).setPendingResult(pending); + verify(node, never()).setPendingResult(null); + verify(node, never()).setLastResult(any()); + verify(world, never()).dropItem(any(Location.class), any(ItemStack.class)); + verify(world, never()).playSound(any(Location.class), any(Sound.class), anyFloat(), anyFloat()); + assertEquals(1, warnings.size()); assertTrue(warnings.getFirst().contains("output hopper is full")); + } + + @Test + void blockedOutputIsDeliveredAsRolledOnceTheHopperHasRoom() { + World world = mock(World.class); + Node node = nodeAt(new Location(world, 4, 60, 7), Map.of("v.STONE", 1.0), 3); + when(node.getPendingResult()).thenReturn(null, Map.of("v.STONE", 3)); + Inventory inventory = hopperAbove(world); + when(inventory.getContents()).thenReturn(new ItemStack[5]); + ItemStack template = mock(ItemStack.class); + ItemStack delivered = mock(ItemStack.class); + when(template.clone()).thenReturn(delivered); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>(Map.of(0, delivered)), new HashMap<>()); + ItemDropper dropper = new ItemDropper(path -> template, message -> {}); + assertFalse(dropper.dropItems(node)); + verify(node).setPendingResult(Map.of("v.STONE", 3)); + // A reroll at the new yield would deliver 99 instead of the held 3. + when(node.getYield()).thenReturn(99); + assertTrue(dropper.dropItems(node)); + verify(delivered, times(2)).setAmount(3); verify(delivered, never()).setAmount(99); + verify(node).setPendingResult(null); verify(node).setLastResult(Map.of("v.STONE", 3)); + } + + @Test + void nothingOnlyOutputCompletesWithoutTouchingTheHopper() { + World world = mock(World.class); + Node node = nodeAt(new Location(world, 4, 60, 7), Map.of("nothing", 1.0), 3); + Inventory inventory = hopperAbove(world); + when(inventory.getContents()).thenReturn(new ItemStack[5]); + assertTrue(new ItemDropper(path -> { fail("Nothing must not be resolved"); return null; }, message -> fail(message)).dropItems(node)); + verify(inventory, never()).addItem(any(ItemStack.class)); + verify(world, never()).playSound(any(Location.class), any(Sound.class), anyFloat(), anyFloat()); + verify(node).setPendingResult(null); verify(node).setLastResult(Map.of()); + } + + @Test + void pendingAmountsAboveOneStackAreDeliveredAsOneTransaction() { + World world = mock(World.class); + Node node = nodeAt(new Location(world, 4, 60, 7), Map.of("v.STONE", 1.0), 3); + when(node.getPendingResult()).thenReturn(Map.of("v.STONE", 130)); + Inventory inventory = hopperAbove(world); + when(inventory.getContents()).thenReturn(new ItemStack[5]); + when(inventory.addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + ItemStack template = mock(ItemStack.class); + ItemStack delivered = mock(ItemStack.class); + when(template.clone()).thenReturn(delivered); + assertTrue(new ItemDropper(path -> template, message -> fail(message)).dropItems(node)); + verify(delivered).setAmount(130); verify(inventory).addItem(delivered); + verify(node).setPendingResult(null); verify(node).setLastResult(Map.of("v.STONE", 130)); + } + + private Inventory hopperAbove(World world) { + Block block = mock(Block.class); + Hopper hopper = mock(Hopper.class); + Inventory inventory = mock(Inventory.class); + when(world.getBlockAt(any(Location.class))).thenReturn(block); + when(block.getType()).thenReturn(Material.HOPPER); + when(block.getState()).thenReturn(hopper); + when(hopper.getInventory()).thenReturn(inventory); + return inventory; + } + private Node nodeAt(Location location, Map drops, int yield) { Node node = mock(Node.class); when(node.getId()).thenReturn(UUID.randomUUID()); when(node.getLoc()).thenReturn(location); when(node.getCompleteDrop()).thenReturn(drops); when(node.getYield()).thenReturn(yield);