From 7587b62dd846fd5977fa52f0e28274e2fc7413db Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Sat, 17 Oct 2026 18:00:10 +0200 Subject: [PATCH 1/2] fix: spill node output that does not fit the hopper Since 3.2.3 a node's whole cycle output had to fit the hopper above it in one go. Six item kinds can never fit a five-slot hopper, so nodes such as the Exotic Fruit Orchard held their output forever and could not be reactivated ("Held output does not fit in the hopper"). Fill the hopper with what fits and drop the rest on top of it, as before 3.2.3, where the hopper pulls it in once it has room. When anything spills, log it and tell the guild's online members how many items spilled and to have the hopper feed into a chest or barrel. Output held by 3.2.3 is delivered the same way on the node's next activation or cycle. Co-Authored-By: Claude Opus 5.5 --- .../net/tfminecraft/dowsing/objects/Node.java | 17 ++- .../dowsing/utils/ItemDropper.java | 62 ++++++----- .../dowsing/objects/NodeBehaviorTest.java | 10 ++ .../dowsing/objects/NodeProductionTest.java | 2 +- .../dowsing/utils/ItemDropperTest.java | 101 +++++++++++------- 5 files changed, 124 insertions(+), 68 deletions(-) diff --git a/src/main/java/net/tfminecraft/dowsing/objects/Node.java b/src/main/java/net/tfminecraft/dowsing/objects/Node.java index a2cada5..a4b3aaf 100644 --- a/src/main/java/net/tfminecraft/dowsing/objects/Node.java +++ b/src/main/java/net/tfminecraft/dowsing/objects/Node.java @@ -153,7 +153,10 @@ 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. */ + /** + * Rolled output that could not be delivered yet, such as one held by a full hopper + * before overflow spilled; null when no cycle output is waiting. + */ public Map getPendingResult() { return pendingResult; } @@ -383,6 +386,16 @@ public void check() { NodeManager.requestNodeBenefitSync(); } + /** Sends a message to the owning guild's online members. */ + public void notifyGuild(String message) { + Guild g = resolveGuild(); + if(g == null) return; + for(String member : g.getMembers()) { + Player p = Members.player(member); + if(p != null) p.sendMessage(message); + } + } + public void tick() { if(!isClaimable()) this.timeLeft = this.timeLeft-1; } @@ -494,7 +507,7 @@ public void activate() { 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"); + this.errors.add("§7Held output cannot be delivered yet"); return; } } diff --git a/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java b/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java index 84609bc..42a6b17 100644 --- a/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java +++ b/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java @@ -1,6 +1,5 @@ package net.tfminecraft.dowsing.utils; -import java.util.Arrays; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; @@ -37,9 +36,9 @@ public ItemDropper() { /** * 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. + * above the hopper cannot trap and despawn it. Output that does not fit spills on + * top of the hopper and the guild is warned. Returns false when a configured reward + * cannot be created, so the cycle can retry instead of losing items. */ public boolean dropItems(Node n) { Map pending = n.getPendingResult(); @@ -94,41 +93,52 @@ public boolean dropItems(Node n) { Location loc = n.getLoc().clone().add(0.5, 2, 0.5); 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; - } + int spilled = deliverToHopper(hopper.getInventory(), items, result, loc); + if(spilled > 0) warnSpill(n, above, spilled); } 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); - } + dropLoose(loc, items.get(entry.getKey()), entry.getValue()); } } 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); + /** + * Fills the hopper with what fits and spills the rest on top of it, where the hopper + * can still pull it in as it drains. Returns how many items spilled. + */ + private int deliverToHopper(Inventory inventory, Map items, Map result, Location loc) { + int delivered = 0; + int spilled = 0; 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; + int left = 0; + for(ItemStack leftover : inventory.addItem(stack).values()) { + left += leftover.getAmount(); } + dropLoose(loc, items.get(entry.getKey()), left); + delivered += entry.getValue() - left; + spilled += left; } - unavailableRewardsLogged.remove("blocked:" + n.getId()); - if(!result.isEmpty()) loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); - return true; + if(delivered > 0) loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + return spilled; + } + private void dropLoose(Location loc, ItemStack item, int amount) { + for(int count = 0; count < amount; count++) { + Item e = loc.getWorld().dropItem(loc, item.clone()); + e.setVelocity(new Vector()); + loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + } + } + private void warnSpill(Node n, Block hopper, int spilled) { + String where = hopper.getX() + ", " + hopper.getY() + ", " + hopper.getZ(); + String count = spilled + (spilled == 1 ? " item" : " items"); + warning.accept("[Dowsing] Node " + n.getId() + " spilled " + count + " past its full hopper at " + where); + n.notifyGuild("§cThe hopper on your " + n.getBlock().getResource() + " node at " + where + " is full. " + + count + " spilled on top of it and may be lost."); + n.notifyGuild("§7Have the hopper feed into a chest or barrel so the output has room."); } public void dropItem(Location loc, String path, Boolean noV) { ItemCreator ic = new ItemCreator(); diff --git a/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java b/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java index 746089e..e7dd114 100644 --- a/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java +++ b/src/test/java/net/tfminecraft/dowsing/objects/NodeBehaviorTest.java @@ -141,6 +141,16 @@ void invalidOwnershipNotifiesTheLeaderAndReleasesTheNode() { node.setGuild(f.guild); node.check(); assertTrue(node.hasGuild()); } + @Test + void guildNoticesReachOnlyOnlineMembersOfTheOwningGuild() { + var leader = f.server.addPlayer("Leader"); + f.node().notifyGuild("§cunowned"); + assertNull(leader.nextMessage()); + f.owned().notifyGuild("§cnotice"); + assertEquals("§cnotice", leader.nextMessage()); + assertNull(leader.nextMessage()); + } + @Test void deletedOwnerDuringResolutionLeavesTheNodeClaimable() { Node node = f.owned(); diff --git a/src/test/java/net/tfminecraft/dowsing/objects/NodeProductionTest.java b/src/test/java/net/tfminecraft/dowsing/objects/NodeProductionTest.java index 9759761..843b71d 100644 --- a/src/test/java/net/tfminecraft/dowsing/objects/NodeProductionTest.java +++ b/src/test/java/net/tfminecraft/dowsing/objects/NodeProductionTest.java @@ -112,7 +112,7 @@ void activationSettlesHeldOutputBeforeTakingNewInputs() { .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(List.of("§7Held output cannot be delivered yet"), node.getErrors()); assertEquals(Map.of("v.STONE", 3), node.getPendingResult()); for (NodeEngine engine : engines.constructed()) verify(engine, never()).takeInputs(any()); node.activate(); diff --git a/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java b/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java index 316b0d3..628f170 100644 --- a/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java +++ b/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.Test; import net.tfminecraft.dowsing.objects.Node; +import net.tfminecraft.dowsing.objects.NodeBlock; class ItemDropperTest { Set logged; @@ -155,9 +156,6 @@ void outputGoesToTheHopperAboveTheNodeInsteadOfTheGround() { 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); @@ -168,55 +166,79 @@ void outputGoesToTheHopperAboveTheNodeInsteadOfTheGround() { 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(); + verify(node, never()).notifyGuild(anyString()); assertEquals(new Location(world, 4, 60, 7), origin); } @Test - void fullHopperHoldsTheCycleAndRestoresItsContents() { + void fullHopperSpillsTheOverflowOnTopAndWarnsTheGuild() { 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); + Node node = nodeAt(new Location(world, 4, 60, 7), Map.of("v.STONE", 1.0), 3); 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))); + ItemStack delivered = mock(ItemStack.class), spilled = mock(ItemStack.class); + when(template.clone()).thenReturn(delivered, spilled); + ItemStack leftover = mock(ItemStack.class); + when(leftover.getAmount()).thenReturn(2); + when(inventory.addItem(delivered)).thenReturn(new HashMap<>(Map.of(0, leftover))); + Item entity = mock(Item.class); + when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(entity); 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")); + assertTrue(new ItemDropper(path -> template, warnings::add).dropItems(node)); + Location top = new Location(world, 4.5, 62, 7.5); + verify(delivered).setAmount(3); + verify(world, times(2)).dropItem(top, spilled); + verify(entity, times(2)).setVelocity(new Vector()); + // One sound for the item that reached the hopper, one per spilled item. + verify(world, times(3)).playSound(top, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + verify(node).setPendingResult(null); verify(node).setLastResult(Map.of("v.STONE", 3)); + assertEquals(List.of("[Dowsing] Node " + node.getId() + " spilled 2 items past its full hopper at 4, 61, 7"), warnings); + verify(node).notifyGuild("§cThe hopper on your Iron node at 4, 61, 7 is full. 2 items spilled on top of it and may be lost."); + verify(node).notifyGuild("§7Have the hopper feed into a chest or barrel so the output has room."); } @Test - void blockedOutputIsDeliveredAsRolledOnceTheHopperHasRoom() { + void heldOutputWiderThanTheHopperIsDeliveredAsRolledInsteadOfBlocking() { 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)); + // Six item kinds can never fit a five-slot hopper at once. + Map held = new LinkedHashMap<>(); + for(String kind : List.of("v.A", "v.B", "v.C", "v.D", "v.E", "v.F")) held.put(kind, 2); + when(node.getPendingResult()).thenReturn(held); + // A reroll at the new yield would deliver 99 instead of the held output. + when(node.getYield()).thenReturn(99); 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)); + ItemStack copy = mock(ItemStack.class); + when(template.clone()).thenReturn(copy); + ItemStack leftover = mock(ItemStack.class); + when(leftover.getAmount()).thenReturn(2); + when(inventory.addItem(copy)).thenReturn(new HashMap<>(), new HashMap<>(), new HashMap<>(), + new HashMap<>(), new HashMap<>(), new HashMap<>(Map.of(0, leftover))); + when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(mock(Item.class)); + assertTrue(new ItemDropper(path -> template, message -> {}).dropItems(node)); + verify(copy, times(6)).setAmount(2); verify(copy, never()).setAmount(99); + verify(world, times(2)).dropItem(new Location(world, 4.5, 62, 7.5), copy); + verify(node).setPendingResult(null); verify(node).setLastResult(held); + verify(node).notifyGuild(contains("2 items spilled")); + } + + @Test + void outputThatAllSpillsMakesNoHopperSound() { + World world = mock(World.class); + Node node = nodeAt(new Location(world, 4, 60, 7), Map.of("v.STONE", 1.0), 1); + Inventory inventory = hopperAbove(world); + ItemStack template = mock(ItemStack.class); + ItemStack copy = mock(ItemStack.class); + when(template.clone()).thenReturn(copy); + ItemStack leftover = mock(ItemStack.class); + when(leftover.getAmount()).thenReturn(1); + when(inventory.addItem(copy)).thenReturn(new HashMap<>(Map.of(0, leftover))); + when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(mock(Item.class)); + assertTrue(new ItemDropper(path -> template, message -> {}).dropItems(node)); + // Only the spilled item's own drop sound plays. + verify(world, times(1)).playSound(any(Location.class), any(Sound.class), anyFloat(), anyFloat()); + verify(node).notifyGuild(contains("1 item spilled")); } @Test @@ -224,7 +246,6 @@ 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()); @@ -237,7 +258,6 @@ void pendingAmountsAboveOneStackAreDeliveredAsOneTransaction() { 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); @@ -254,12 +274,15 @@ private Inventory hopperAbove(World world) { when(world.getBlockAt(any(Location.class))).thenReturn(block); when(block.getType()).thenReturn(Material.HOPPER); when(block.getState()).thenReturn(hopper); + when(block.getX()).thenReturn(4); when(block.getY()).thenReturn(61); when(block.getZ()).thenReturn(7); 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()); + NodeBlock block = mock(NodeBlock.class); when(block.getResource()).thenReturn("Iron"); + when(node.getBlock()).thenReturn(block); when(node.getLoc()).thenReturn(location); when(node.getCompleteDrop()).thenReturn(drops); when(node.getYield()).thenReturn(yield); return node; } From 0c8a3fefa2e32db5ff41c40182198f892d506937 Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Sat, 17 Oct 2026 18:08:26 +0200 Subject: [PATCH 2/2] fix: drop loose node output in full stacks A large overflow spawned one item entity and one sound per item. Drop stacks up to the item's max stack size and play one sound per drop. Co-Authored-By: Claude Opus 5.5 --- .../dowsing/utils/ItemDropper.java | 11 ++++-- .../dowsing/utils/ItemDropperTest.java | 38 +++++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java b/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java index 42a6b17..70b26db 100644 --- a/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java +++ b/src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java @@ -125,12 +125,17 @@ private int deliverToHopper(Inventory inventory, Map items, M if(delivered > 0) loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); return spilled; } + /** Drops full stacks with one sound, so a large overflow does not spawn an entity per item. */ private void dropLoose(Location loc, ItemStack item, int amount) { - for(int count = 0; count < amount; count++) { - Item e = loc.getWorld().dropItem(loc, item.clone()); + if(amount <= 0) return; + int max = Math.max(1, item.getMaxStackSize()); + for(int left = amount; left > 0; left -= max) { + ItemStack stack = item.clone(); + stack.setAmount(Math.min(max, left)); + Item e = loc.getWorld().dropItem(loc, stack); e.setVelocity(new Vector()); - loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); } + loc.getWorld().playSound(loc, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); } private void warnSpill(Node n, Block hopper, int spilled) { String where = hopper.getX() + ", " + hopper.getY() + ", " + hopper.getZ(); diff --git a/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java b/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java index 628f170..50e9d97 100644 --- a/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java +++ b/src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java @@ -75,11 +75,12 @@ void unavailableRewardKeepsCyclePendingAndLastResultIntact() { } @Test - void successfulCyclesDropIndividualCopiesAtTheNodeAndRecordTheResult() { + void successfulCyclesDropFullStacksAtTheNodeAndRecordTheResult() { World world = mock(World.class); Location origin = new Location(world, 4, 60, 7); Node node = nodeAt(origin, Map.of("v.STONE", 1.0), 3); ItemStack template = mock(ItemStack.class); + // Unstackable output drops one item per entity; the sound plays once. ItemStack first = mock(ItemStack.class), second = mock(ItemStack.class), third = mock(ItemStack.class); when(template.clone()).thenReturn(first, second, third); Item entity = mock(Item.class); @@ -87,8 +88,9 @@ void successfulCyclesDropIndividualCopiesAtTheNodeAndRecordTheResult() { assertTrue(new ItemDropper(path -> template, message -> fail(message)).dropItems(node)); Location drop = new Location(world, 4.5, 62, 7.5); verify(world).dropItem(drop, first); verify(world).dropItem(drop, second); verify(world).dropItem(drop, third); + verify(first).setAmount(1); verify(second).setAmount(1); verify(third).setAmount(1); verify(entity, times(3)).setVelocity(new Vector()); - verify(world, times(3)).playSound(drop, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + verify(world, times(1)).playSound(drop, 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); } @@ -176,6 +178,7 @@ void fullHopperSpillsTheOverflowOnTopAndWarnsTheGuild() { Node node = nodeAt(new Location(world, 4, 60, 7), Map.of("v.STONE", 1.0), 3); Inventory inventory = hopperAbove(world); ItemStack template = mock(ItemStack.class); + when(template.getMaxStackSize()).thenReturn(64); ItemStack delivered = mock(ItemStack.class), spilled = mock(ItemStack.class); when(template.clone()).thenReturn(delivered, spilled); ItemStack leftover = mock(ItemStack.class); @@ -187,10 +190,11 @@ void fullHopperSpillsTheOverflowOnTopAndWarnsTheGuild() { assertTrue(new ItemDropper(path -> template, warnings::add).dropItems(node)); Location top = new Location(world, 4.5, 62, 7.5); verify(delivered).setAmount(3); - verify(world, times(2)).dropItem(top, spilled); - verify(entity, times(2)).setVelocity(new Vector()); - // One sound for the item that reached the hopper, one per spilled item. - verify(world, times(3)).playSound(top, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); + verify(spilled).setAmount(2); + verify(world).dropItem(top, spilled); + verify(entity).setVelocity(new Vector()); + // One sound for the item that reached the hopper, one for the spill. + verify(world, times(2)).playSound(top, Sound.ENTITY_ITEM_PICKUP, 0.5f, 1f); verify(node).setPendingResult(null); verify(node).setLastResult(Map.of("v.STONE", 3)); assertEquals(List.of("[Dowsing] Node " + node.getId() + " spilled 2 items past its full hopper at 4, 61, 7"), warnings); verify(node).notifyGuild("§cThe hopper on your Iron node at 4, 61, 7 is full. 2 items spilled on top of it and may be lost."); @@ -216,13 +220,31 @@ void heldOutputWiderThanTheHopperIsDeliveredAsRolledInsteadOfBlocking() { when(inventory.addItem(copy)).thenReturn(new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>(Map.of(0, leftover))); when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(mock(Item.class)); + when(template.getMaxStackSize()).thenReturn(64); assertTrue(new ItemDropper(path -> template, message -> {}).dropItems(node)); - verify(copy, times(6)).setAmount(2); verify(copy, never()).setAmount(99); - verify(world, times(2)).dropItem(new Location(world, 4.5, 62, 7.5), copy); + // Six hopper stacks of 2 and one spilled stack of 2. + verify(copy, times(7)).setAmount(2); verify(copy, never()).setAmount(99); + verify(world).dropItem(new Location(world, 4.5, 62, 7.5), copy); verify(node).setPendingResult(null); verify(node).setLastResult(held); verify(node).notifyGuild(contains("2 items spilled")); } + @Test + void largeLooseOutputDropsInMaxSizeStacksWithOneSound() { + 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)); + ItemStack template = mock(ItemStack.class); + when(template.getMaxStackSize()).thenReturn(64); + ItemStack first = mock(ItemStack.class), second = mock(ItemStack.class), rest = mock(ItemStack.class); + when(template.clone()).thenReturn(first, second, rest); + when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(mock(Item.class)); + assertTrue(new ItemDropper(path -> template, message -> fail(message)).dropItems(node)); + verify(first).setAmount(64); verify(second).setAmount(64); verify(rest).setAmount(2); + verify(world, times(3)).dropItem(any(Location.class), any(ItemStack.class)); + verify(world, times(1)).playSound(any(Location.class), any(Sound.class), anyFloat(), anyFloat()); + } + @Test void outputThatAllSpillsMakesNoHopperSound() { World world = mock(World.class);