From 3036f8dc20808d42d12664d8adb43c6ba76d11f0 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 3 Oct 2026 08:15:44 -0700 Subject: [PATCH] fix: re-validate obsidian scoop before giving lava The lava bucket is handed out one tick after the interact event, but that deferred step re-checked nothing. Mining the obsidian inside that tick gave the player both the obsidian and the lava, and moving the bucket out of the hand let the lava be given without using a bucket. givePlayerLava now confirms the player is online, the block is still obsidian and the hand still holds an empty bucket, reading the bucket fresh from the hand slot. The scooping message and sound move into it so they only play when lava is actually given. Co-Authored-By: Claude Opus 5.5 --- .../ObsidianScoopingListener.java | 31 +++++++-- .../ObsidianScoopingListenerTest.java | 63 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/ObsidianScoopingListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/ObsidianScoopingListener.java index f1392b962..9a2d4b5ec 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/ObsidianScoopingListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/worldsettings/ObsidianScoopingListener.java @@ -230,7 +230,7 @@ boolean onPlayerInteract(final PlayerInteractEvent e) { */ private boolean lookForLava(PlayerInteractEvent e) { Player player = e.getPlayer(); - ItemStack bucket = e.getItem(); + EquipmentSlot hand = e.getHand() == null ? EquipmentSlot.HAND : e.getHand(); // Get block player is looking at RayTraceResult rtBlocks = e.getPlayer().rayTraceBlocks(5, FluidCollisionMode.ALWAYS); @@ -257,19 +257,37 @@ private boolean lookForLava(PlayerInteractEvent e) { } // Add player to cooldown set to prevent rapid scooping getCooldowns().add(player.getUniqueId()); - user.sendMessage("protection.flags.OBSIDIAN_SCOOPING.scooping"); - player.getWorld().playSound(player.getLocation(), Sound.ITEM_BUCKET_FILL_LAVA, 1F, 1F); e.setCancelled(true); - Bukkit.getScheduler().runTask(BentoBox.getInstance(), () -> givePlayerLava(player, b, bucket)); + Bukkit.getScheduler().runTask(BentoBox.getInstance(), () -> givePlayerLava(user, b, hand)); return true; } return false; } - private void givePlayerLava(Player player, Block b, ItemStack bucket) { + /** + * Converts the obsidian into a lava bucket. This runs a tick after the interact event, + * so everything checked there is re-checked here: in the meantime the obsidian may have + * been mined (which would give the player both the obsidian and the lava) or the bucket + * moved out of the hand (which would give the lava without using up the bucket). + * + * @param user the player scooping + * @param b the obsidian block + * @param hand the hand that held the empty bucket + * @return true if the lava was given, false if the scoop was abandoned + */ + boolean givePlayerLava(User user, Block b, EquipmentSlot hand) { + Player player = user.getPlayer(); + if (!player.isOnline() || !b.getType().equals(Material.OBSIDIAN)) { + return false; + } + ItemStack bucket = player.getInventory().getItem(hand); + if (bucket == null || !bucket.getType().equals(Material.BUCKET)) { + return false; + } // Remove one empty bucket and add a lava bucket to the player's inventory bucket.setAmount(bucket.getAmount() - 1); + player.getInventory().setItem(hand, bucket); Map map = player.getInventory().addItem(new ItemStack(Material.LAVA_BUCKET)); if (!map.isEmpty()) { map.values().forEach(i -> player.getWorld().dropItem(player.getLocation(), i)); @@ -278,6 +296,9 @@ private void givePlayerLava(Player player, Block b, ItemStack bucket) { b.setType(Material.AIR); // Remove the lava tip hologram, if any, since the obsidian is gone removeHologramFor(b); + user.sendMessage("protection.flags.OBSIDIAN_SCOOPING.scooping"); + player.getWorld().playSound(player.getLocation(), Sound.ITEM_BUCKET_FILL_LAVA, 1F, 1F); + return true; } private List getBlocksAround(Block b, int radius) { diff --git a/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/ObsidianScoopingListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/ObsidianScoopingListenerTest.java index 1fcc24fba..41d55f0d5 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/ObsidianScoopingListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/flags/worldsettings/ObsidianScoopingListenerTest.java @@ -30,6 +30,7 @@ import org.bukkit.event.block.Action; import org.bukkit.event.block.BlockFormEvent; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.PlayerInventory; import org.bukkit.scheduler.BukkitTask; @@ -111,7 +112,10 @@ public void setUp() throws Exception { ItemStack air = mock(ItemStack.class); when(air.getType()).thenReturn(Material.AIR); when(playerInventory.getItemInOffHand()).thenReturn(air); + when(playerInventory.getItem(EquipmentSlot.HAND)).thenReturn(item); + when(playerInventory.getItem(EquipmentSlot.OFF_HAND)).thenReturn(air); when(mockPlayer.getInventory()).thenReturn(playerInventory); + when(mockPlayer.isOnline()).thenReturn(true); // Addon when(iwm.getAddon(Mockito.any())).thenReturn(Optional.empty()); @@ -365,6 +369,65 @@ void testScoopingRemovesActiveHologramImmediately() { verify(mockHologram).remove(); } + @Test + void testGivePlayerLava() { + when(item.getType()).thenReturn(Material.BUCKET); + when(clickedBlock.getType()).thenReturn(Material.OBSIDIAN); + when(clickedBlock.getLocation()).thenReturn(location); + when(mockPlayer.getInventory().addItem(any(ItemStack.class))).thenReturn(new HashMap<>()); + + assertTrue(listener.givePlayerLava(User.getInstance(mockPlayer), clickedBlock, EquipmentSlot.HAND)); + + verify(item).setAmount(0); + verify(mockPlayer.getInventory()).addItem(any(ItemStack.class)); + verify(clickedBlock).setType(Material.AIR); + } + + /** + * The lava is handed out a tick after the click. If the obsidian is mined in that + * window the player must not also get the lava. + */ + @Test + void testGivePlayerLavaObsidianGone() { + when(item.getType()).thenReturn(Material.BUCKET); + when(clickedBlock.getType()).thenReturn(Material.WATER); + + assertFalse(listener.givePlayerLava(User.getInstance(mockPlayer), clickedBlock, EquipmentSlot.HAND)); + + verify(item, never()).setAmount(Mockito.anyInt()); + verify(mockPlayer.getInventory(), never()).addItem(any(ItemStack.class)); + verify(clickedBlock, never()).setType(any()); + } + + /** + * If the empty bucket left the hand before the lava is handed out, the scoop is abandoned + * rather than giving lava without using up a bucket. + */ + @Test + void testGivePlayerLavaBucketNoLongerInHand() { + when(clickedBlock.getType()).thenReturn(Material.OBSIDIAN); + ItemStack pickaxe = mock(ItemStack.class); + when(pickaxe.getType()).thenReturn(Material.DIAMOND_PICKAXE); + when(mockPlayer.getInventory().getItem(EquipmentSlot.HAND)).thenReturn(pickaxe); + + assertFalse(listener.givePlayerLava(User.getInstance(mockPlayer), clickedBlock, EquipmentSlot.HAND)); + + verify(pickaxe, never()).setAmount(Mockito.anyInt()); + verify(mockPlayer.getInventory(), never()).addItem(any(ItemStack.class)); + verify(clickedBlock, never()).setType(any()); + } + + @Test + void testGivePlayerLavaPlayerOffline() { + when(item.getType()).thenReturn(Material.BUCKET); + when(clickedBlock.getType()).thenReturn(Material.OBSIDIAN); + when(mockPlayer.isOnline()).thenReturn(false); + + assertFalse(listener.givePlayerLava(User.getInstance(mockPlayer), clickedBlock, EquipmentSlot.HAND)); + + verify(clickedBlock, never()).setType(any()); + } + @Test void testObsidianFormEmptyTipText() { // Set up solitary obsidian