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..70b26db 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,57 @@ 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; + } + /** 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) { + 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); + } + 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..50e9d97 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; @@ -74,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); @@ -86,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); } @@ -155,9 +158,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 +168,99 @@ 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))); + 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); + 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(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."); + 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)); + when(template.getMaxStackSize()).thenReturn(64); + assertTrue(new ItemDropper(path -> template, message -> {}).dropItems(node)); + // 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); + 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 +268,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 +280,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 +296,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; }