Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/main/java/net/tfminecraft/dowsing/objects/Node.java
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,10 @@ public Map<String, Integer> getLastResult() {
public void setLastResult(Map<String, Integer> 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<String, Integer> getPendingResult() {
return pendingResult;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
}
Expand Down
67 changes: 41 additions & 26 deletions src/main/java/net/tfminecraft/dowsing/utils/ItemDropper.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<String, Integer> pending = n.getPendingResult();
Expand Down Expand Up @@ -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<String, Integer> 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<String, ItemStack> items, Map<String, Integer> 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<String, ItemStack> items, Map<String, Integer> result, Location loc) {
int delivered = 0;
int spilled = 0;
for(Map.Entry<String, Integer> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
127 changes: 86 additions & 41 deletions src/test/java/net/tfminecraft/dowsing/utils/ItemDropperTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> logged;
Expand Down Expand Up @@ -74,20 +75,22 @@ 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);
when(world.dropItem(any(Location.class), any(ItemStack.class))).thenReturn(entity);
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);
}
Expand Down Expand Up @@ -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);
Expand All @@ -168,63 +168,106 @@ 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<String, Integer> 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<String> 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<String, Integer> 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
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());
Expand All @@ -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);
Expand All @@ -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<String, Double> 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;
}
Expand Down