diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 41f869604..028e24ab3 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -402,6 +402,13 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "island.obsidian-scooping-cooldown", since = "3.11.4") private int obsidianScoopingCooldown = 1; + @ConfigComment("How long (in seconds) to show a hologram tip above newly formed obsidian") + @ConfigComment("that can be scooped back into lava. The hologram reminds players they can") + @ConfigComment("right-click obsidian with an empty bucket to recover lava.") + @ConfigComment("Set to 0 or less to disable the tip entirely. Default is 30 seconds.") + @ConfigEntry(path = "island.obsidian-scooping-lava-tip-duration", since = "3.12.0") + private int obsidianScoopingLavaTipDuration = 30; + /* WEB */ @ConfigComment("Toggle whether BentoBox can connect to GitHub to get data about updates and addons.") @ConfigComment("Disabling this will result in the deactivation of the update checker and of some other") @@ -1243,6 +1250,28 @@ public void setObsidianScoopingCooldown(int obsidianScoopingCooldown) { this.obsidianScoopingCooldown = Math.max(1, obsidianScoopingCooldown); } + /** + * Gets the duration (in seconds) for showing the lava tip hologram above + * newly formed obsidian blocks that can be scooped. + * + * @return the lava tip duration in seconds; 0 or less means disabled + * @since 3.12.0 + */ + public int getObsidianScoopingLavaTipDuration() { + return obsidianScoopingLavaTipDuration; + } + + /** + * Sets the duration (in seconds) for showing the lava tip hologram above + * newly formed obsidian blocks that can be scooped. + * + * @param obsidianScoopingLavaTipDuration the duration in seconds; 0 or less disables + * @since 3.12.0 + */ + public void setObsidianScoopingLavaTipDuration(int obsidianScoopingLavaTipDuration) { + this.obsidianScoopingLavaTipDuration = obsidianScoopingLavaTipDuration; + } + /** * @return the islandNumber * @since 2.0.0 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 141c34da0..2a3f426b6 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 @@ -9,23 +9,31 @@ import org.bukkit.Bukkit; import org.bukkit.FluidCollisionMode; import org.bukkit.GameMode; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Display; import org.bukkit.entity.Player; +import org.bukkit.entity.TextDisplay; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; 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.util.RayTraceResult; +import net.kyori.adventure.text.Component; + import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.flags.FlagListener; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.lists.Flags; import world.bentobox.bentobox.util.ExpiringSet; +import world.bentobox.bentobox.util.Util; /** * Enables changing of obsidian back into lava @@ -34,6 +42,17 @@ */ public class ObsidianScoopingListener extends FlagListener { + private static final String LAVA_TIP_REFERENCE = "protection.flags.OBSIDIAN_SCOOPING.lavaTip"; + + /** + * The preferred order for hologram placement: above, sides, then below. + */ + private static final BlockFace[] HOLOGRAM_FACES = { + BlockFace.UP, + BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST, + BlockFace.DOWN + }; + /** * Cooldown to prevent lava duplication by rapid obsidian scooping. * Initialized lazily on first use so that the configured duration from settings @@ -69,6 +88,88 @@ private ExpiringSet getCooldowns() { public void onPlayerInteractEvent(final PlayerInteractEvent e) { onPlayerInteract(e); } + + /** + * Shows a hologram tip when obsidian forms from lava and water mixing, + * if the obsidian could potentially be scooped back into lava. + * + * @param e the block form event + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onObsidianForm(final BlockFormEvent e) { + handleObsidianForm(e); + } + + /** + * Handles obsidian formation and shows a lava tip hologram if applicable. + * + * @param e the block form event + * @return true if a hologram was spawned, false otherwise + */ + boolean handleObsidianForm(final BlockFormEvent e) { + if (!Material.OBSIDIAN.equals(e.getNewState().getType())) { + return false; + } + Block b = e.getBlock(); + if (!getIWM().inWorld(b.getLocation()) || !Flags.OBSIDIAN_SCOOPING.isSetForWorld(b.getWorld())) { + return false; + } + BentoBox bentoBox = BentoBox.getInstance(); + int duration = bentoBox.getSettings().getObsidianScoopingLavaTipDuration(); + if (duration <= 0) { + return false; + } + int radius = bentoBox.getSettings().getObsidianScoopingRadius(); + // Check if this obsidian is solitary (could be scooped) + if (radius > 0 && getBlocksAround(b, radius).stream().anyMatch(block -> block.getType().equals(Material.OBSIDIAN))) { + return false; + } + // Find a suitable location for the hologram + Location holoLoc = findHologramLocation(b); + if (holoLoc == null) { + return false; + } + // Get the lava tip text from the locale + String tipText = bentoBox.getLocalesManager().getOrDefault(LAVA_TIP_REFERENCE, ""); + if (tipText.isEmpty()) { + return false; + } + Component tipComponent = Util.parseMiniMessage(tipText); + // Spawn a TextDisplay hologram + TextDisplay hologram = b.getWorld().spawn(holoLoc, TextDisplay.class, td -> { + td.text(tipComponent); + td.setBillboard(Display.Billboard.CENTER); + td.setSeeThrough(true); + td.setGravity(false); + }); + // Schedule removal after the configured duration + Bukkit.getScheduler().runTaskLater(bentoBox, () -> { + if (hologram.isValid()) { + hologram.remove(); + } + }, duration * 20L); + return true; + } + + /** + * Finds a suitable location for a hologram near the given block. + * Prefers above the block, then sides, then below. + * A location is suitable if the block there is air or a liquid. + * + * @param b the obsidian block + * @return a suitable location, or null if none found + */ + Location findHologramLocation(Block b) { + for (BlockFace face : HOLOGRAM_FACES) { + Block relative = b.getRelative(face); + Material type = relative.getType(); + if (type.isAir() || relative.isLiquid()) { + return relative.getLocation().add(0.5, 0.5, 0.5); + } + } + return null; + } + /** * Enables changing of obsidian back into lava * diff --git a/src/main/resources/locales/en-US.yml b/src/main/resources/locales/en-US.yml index d45e50b2f..64d6a5258 100644 --- a/src/main/resources/locales/en-US.yml +++ b/src/main/resources/locales/en-US.yml @@ -1508,6 +1508,7 @@ protection: cooldown: 'You must wait before scooping another obsidian block.' obsidian-nearby: 'There are obsidian blocks within a [radius]-block radius of this obsidian. You cannot scoop it up into lava.' + lavaTip: 'Scoop this up as lava with a bucket if you need it!' OFFLINE_GROWTH: description: |- When disabled, plants 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 f509f417c..cce712968 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 @@ -1,15 +1,23 @@ package world.bentobox.bentobox.listeners.flags.worldsettings; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.function.Consumer; +import org.bukkit.Bukkit; import org.bukkit.FluidCollisionMode; import org.bukkit.GameMode; import org.bukkit.Location; @@ -17,10 +25,14 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; +import org.bukkit.entity.TextDisplay; import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockFormEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.PlayerInventory; +import org.bukkit.scheduler.BukkitTask; import org.bukkit.util.RayTraceResult; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -31,6 +43,7 @@ import world.bentobox.bentobox.CommonTestSetup; import world.bentobox.bentobox.api.configuration.WorldSettings; import world.bentobox.bentobox.api.user.User; +import world.bentobox.bentobox.managers.LocalesManager; class ObsidianScoopingListenerTest extends CommonTestSetup { @@ -229,6 +242,189 @@ void testOnPlayerInteractCooldown() { assertFalse(listener.onPlayerInteract(event)); } + // --- Tests for BlockFormEvent (lava tip hologram) --- + + @Test + void testObsidianFormNotObsidian() { + BlockFormEvent event = createBlockFormEvent(Material.COBBLESTONE); + assertFalse(listener.handleObsidianForm(event)); + } + + @Test + void testObsidianFormNotInWorld() { + when(iwm.inWorld(any(Location.class))).thenReturn(false); + BlockFormEvent event = createBlockFormEvent(Material.OBSIDIAN); + assertFalse(listener.handleObsidianForm(event)); + } + + @Test + void testObsidianFormFlagDisabled() { + WorldSettings ws = mock(WorldSettings.class); + when(iwm.getWorldSettings(Mockito.any())).thenReturn(ws); + Map map = new HashMap<>(); + map.put("OBSIDIAN_SCOOPING", false); + when(ws.getWorldFlags()).thenReturn(map); + + BlockFormEvent event = createBlockFormEvent(Material.OBSIDIAN); + assertFalse(listener.handleObsidianForm(event)); + } + + @Test + void testObsidianFormDurationDisabled() { + // Set duration to 0 (disabled) + plugin.getSettings().setObsidianScoopingLavaTipDuration(0); + + BlockFormEvent event = createBlockFormEvent(Material.OBSIDIAN); + assertFalse(listener.handleObsidianForm(event)); + } + + @Test + void testObsidianFormWithNearbyObsidian() { + // Set up block with nearby obsidian + Block obsidianBlock = mock(Block.class); + when(obsidianBlock.getType()).thenReturn(Material.OBSIDIAN); + when(world.getBlockAt(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyInt())).thenReturn(obsidianBlock); + + BlockFormEvent event = createBlockFormEvent(Material.OBSIDIAN); + assertFalse(listener.handleObsidianForm(event)); + } + + @Test + void testObsidianFormSolitaryShowsHologram() { + // Set up solitary obsidian (no nearby obsidian) + Block airBlock = mock(Block.class); + when(airBlock.getType()).thenReturn(Material.AIR); + when(world.getBlockAt(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyInt())).thenReturn(airBlock); + + // Mock the locale manager to return a tip text + LocalesManager localesManager = mock(LocalesManager.class); + when(plugin.getLocalesManager()).thenReturn(localesManager); + when(localesManager.getOrDefault(any(String.class), any(String.class))) + .thenReturn("Scoop this up!"); + + // Mock TextDisplay spawning + TextDisplay mockHologram = mock(TextDisplay.class); + when(mockHologram.isValid()).thenReturn(true); + when(world.spawn(any(Location.class), eq(TextDisplay.class), any(Consumer.class))).thenReturn(mockHologram); + + // Mock scheduler + BukkitTask mockTask = mock(BukkitTask.class); + when(sch.runTaskLater(any(), any(Runnable.class), anyLong())).thenReturn(mockTask); + + BlockFormEvent event = createBlockFormEvent(Material.OBSIDIAN); + assertTrue(listener.handleObsidianForm(event)); + + // Verify hologram was spawned + verify(world).spawn(any(Location.class), eq(TextDisplay.class), any(Consumer.class)); + // Verify a delayed removal task was scheduled (30 seconds = 600 ticks) + verify(sch).runTaskLater(any(), any(Runnable.class), eq(600L)); + } + + @Test + void testObsidianFormEmptyTipText() { + // Set up solitary obsidian + Block airBlock = mock(Block.class); + when(airBlock.getType()).thenReturn(Material.AIR); + when(world.getBlockAt(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyInt())).thenReturn(airBlock); + + // Mock the locale manager to return empty text + LocalesManager localesManager = mock(LocalesManager.class); + when(plugin.getLocalesManager()).thenReturn(localesManager); + when(localesManager.getOrDefault(any(String.class), any(String.class))).thenReturn(""); + + BlockFormEvent event = createBlockFormEvent(Material.OBSIDIAN); + assertFalse(listener.handleObsidianForm(event)); + + // Verify no hologram was spawned + verify(world, never()).spawn(any(Location.class), eq(TextDisplay.class), any(Consumer.class)); + } + + @Test + void testFindHologramLocationAbove() { + Block above = mock(Block.class); + when(above.getType()).thenReturn(Material.AIR); + Location aboveLoc = mock(Location.class); + when(above.getLocation()).thenReturn(aboveLoc); + when(aboveLoc.add(0.5, 0.5, 0.5)).thenReturn(aboveLoc); + when(above.isLiquid()).thenReturn(false); + + when(clickedBlock.getRelative(BlockFace.UP)).thenReturn(above); + + Location result = listener.findHologramLocation(clickedBlock); + assertNotNull(result); + } + + @Test + void testFindHologramLocationSide() { + // Above is solid + Block solidBlock = mock(Block.class); + when(solidBlock.getType()).thenReturn(Material.STONE); + when(solidBlock.isLiquid()).thenReturn(false); + when(clickedBlock.getRelative(BlockFace.UP)).thenReturn(solidBlock); + + // North is air + Block northBlock = mock(Block.class); + when(northBlock.getType()).thenReturn(Material.AIR); + Location northLoc = mock(Location.class); + when(northBlock.getLocation()).thenReturn(northLoc); + when(northLoc.add(0.5, 0.5, 0.5)).thenReturn(northLoc); + when(northBlock.isLiquid()).thenReturn(false); + when(clickedBlock.getRelative(BlockFace.NORTH)).thenReturn(northBlock); + + Location result = listener.findHologramLocation(clickedBlock); + assertNotNull(result); + } + + @Test + void testFindHologramLocationLiquid() { + // Above is water (liquid) - should be valid + Block waterBlock = mock(Block.class); + when(waterBlock.getType()).thenReturn(Material.WATER); + when(waterBlock.isLiquid()).thenReturn(true); + Location waterLoc = mock(Location.class); + when(waterBlock.getLocation()).thenReturn(waterLoc); + when(waterLoc.add(0.5, 0.5, 0.5)).thenReturn(waterLoc); + when(clickedBlock.getRelative(BlockFace.UP)).thenReturn(waterBlock); + + Location result = listener.findHologramLocation(clickedBlock); + assertNotNull(result); + } + + @Test + void testFindHologramLocationAllBlocked() { + // All surrounding blocks are solid + Block solidBlock = mock(Block.class); + when(solidBlock.getType()).thenReturn(Material.STONE); + when(solidBlock.isLiquid()).thenReturn(false); + when(clickedBlock.getRelative(any(BlockFace.class))).thenReturn(solidBlock); + + Location result = listener.findHologramLocation(clickedBlock); + assertNull(result); + } + + private BlockFormEvent createBlockFormEvent(Material newStateType) { + Block formBlock = mock(Block.class); + when(formBlock.getX()).thenReturn(0); + when(formBlock.getY()).thenReturn(64); + when(formBlock.getZ()).thenReturn(0); + when(formBlock.getWorld()).thenReturn(world); + when(formBlock.getLocation()).thenReturn(location); + + // Set up relative blocks for hologram placement + Block airAbove = mock(Block.class); + when(airAbove.getType()).thenReturn(Material.AIR); + when(airAbove.isLiquid()).thenReturn(false); + Location aboveLoc = mock(Location.class); + when(airAbove.getLocation()).thenReturn(aboveLoc); + when(aboveLoc.add(0.5, 0.5, 0.5)).thenReturn(aboveLoc); + when(formBlock.getRelative(any(BlockFace.class))).thenReturn(airAbove); + + BlockState newState = mock(BlockState.class); + when(newState.getType()).thenReturn(newStateType); + + return new BlockFormEvent(formBlock, newState); + } + private void testEvent() { when(item.getType()).thenReturn(inHand); when(clickedBlock.getType()).thenReturn(block);