diff --git a/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java b/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java index d9c50ae..32d4ef6 100644 --- a/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java +++ b/src/main/java/world/bentobox/chunkblock/listeners/BlockListener.java @@ -25,9 +25,11 @@ import org.bukkit.block.Biome; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockSupport; import org.bukkit.block.BrushableBlock; import org.bukkit.block.Chest; import org.bukkit.block.data.Brushable; +import org.bukkit.block.data.MultipleFacing; import org.bukkit.block.data.type.Leaves; import org.bukkit.entity.Entity; import org.bukkit.entity.EntityType; @@ -140,7 +142,24 @@ private record BrushSession(BukkitTask task, Block block) {} } private static final Random RAND = new Random(); - + + /** + * Multiface plants, mapped to the block they are grown on when the magic block has nothing + * for them to attach to. Placed with their default block data these plants have no face set, + * a state that vanilla deletes at the next block update and that bone meal cannot spread from. + */ + private static final Map MULTIFACE_SUPPORT = Map.of( + Material.GLOW_LICHEN, Material.MOSS_BLOCK, + Material.SCULK_VEIN, Material.SCULK, + Material.RESIN_CLUMP, Material.STONE, + Material.VINE, Material.MOSS_BLOCK); + + /** + * Directions tried, in order, when a multiface plant has to be given a block to grow on. + */ + private static final List MULTIFACE_OFFSETS = List.of(BlockFace.UP, BlockFace.NORTH, + BlockFace.EAST, BlockFace.SOUTH, BlockFace.WEST, BlockFace.DOWN); + /** * Constructs the BlockListener. * @param addon - The ChunkBlock addon instance. @@ -710,6 +729,10 @@ private void spawnBlock(@NonNull OneBlockObject nextBlock, @NonNull Block block) return; } Material type = nextBlock.getMaterial(); + if (MULTIFACE_SUPPORT.containsKey(type)) { + spawnMultifaceBlock(block, type); + return; + } block.setType(type, false); if (type.equals(Material.CHEST) && nextBlock.getChest() != null) { fillChest(nextBlock, block); @@ -720,6 +743,63 @@ private void spawnBlock(@NonNull OneBlockObject nextBlock, @NonNull Block block) } } + /** + * Spawns a multiface plant - glow lichen, sculk vein, resin clump or vines. + *

+ * These blocks are a film on the face of a neighboring block, not a cube. Placing one with + * {@code setType} gives it its default block data, which has no face set at all. The client + * draws that state on all six sides, so it looks fine, but the server treats it as + * unsupported: the next block update deletes it, and bone meal has no face to spread from. + *

+ * So attach it to whatever solid neighbors the magic block already has. If it is floating in + * mid-air there is nothing to cling to - and nothing for bone meal to spread onto either - so + * the magic block becomes the plant's support block and the plant grows on the first free + * side of it. + * + * @param block The magic block being replaced. + * @param type The multiface material, e.g. {@link Material#GLOW_LICHEN}. + */ + private void spawnMultifaceBlock(@NonNull Block block, @NonNull Material type) { + if (!(type.createBlockData() instanceof MultipleFacing plant)) { + // Not a multiface block on this server version, so place it as-is + block.setType(type, false); + return; + } + boolean attached = false; + for (BlockFace face : plant.getAllowedFaces()) { + if (canAttachTo(block.getRelative(face), face.getOppositeFace())) { + plant.setFace(face, true); + attached = true; + } + } + if (attached) { + block.setBlockData(plant, false); + return; + } + // Nothing to grow on, so grow the plant a block to live on + block.setType(MULTIFACE_SUPPORT.get(type), false); + for (BlockFace offset : MULTIFACE_OFFSETS) { + BlockFace face = offset.getOppositeFace(); + Block target = block.getRelative(offset); + if (plant.getAllowedFaces().contains(face) && target.getType().isAir()) { + plant.setFace(face, true); + target.setBlockData(plant, false); + return; + } + } + } + + /** + * Checks whether a multiface plant can attach itself to the given face of a block. + * + * @param block The neighboring block. + * @param face The face of that block the plant would sit on. + * @return {@code true} if that face is a full, solid face. + */ + private boolean canAttachTo(@NonNull Block block, @NonNull BlockFace face) { + return !block.getType().isAir() && block.getBlockData().isFaceSturdy(face, BlockSupport.FULL); + } + /** * Sets a leaves block to persistent so it does not decay. * @param block The leaves block. diff --git a/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest2.java b/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest2.java index 541a569..b78eed2 100644 --- a/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest2.java +++ b/src/test/java/world/bentobox/chunkblock/listeners/BlockListenerTest2.java @@ -24,10 +24,12 @@ import java.io.File; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Random; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -38,11 +40,15 @@ import org.bukkit.Sound; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; import org.bukkit.block.BlockState; +import org.bukkit.block.BlockSupport; import org.bukkit.block.BrushableBlock; import org.bukkit.block.Chest; import org.bukkit.block.data.type.Leaves; +import org.bukkit.block.data.BlockData; import org.bukkit.block.data.Brushable; +import org.bukkit.block.data.MultipleFacing; import org.bukkit.entity.EntityType; import org.bukkit.entity.Item; import org.bukkit.event.EventHandler; @@ -62,6 +68,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.block.data.MultipleFacingDataMock; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -601,6 +609,105 @@ void testSpawnBlockRegularMaterial() throws Exception { verify(magicBlock).setType(Material.STONE, false); } + /** + * Stubs every neighbor of the magic block as air. + * @return map of the neighbor mocks, keyed by the face of the magic block they sit on. + */ + private Map stubAirNeighbors(Material plant) { + // Bukkit is statically mocked here, so supply the multiface block data ourselves. + // MockBukkit does not model every multiface material, so build the mock directly. + mockedBukkit.when(() -> Bukkit.createBlockData(plant)) + .thenAnswer(i -> new MultipleFacingDataMock(plant)); + return stubAirNeighbors(); + } + + private Map stubAirNeighbors() { + Map neighbors = new EnumMap<>(BlockFace.class); + for (BlockFace face : List.of(BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.EAST, + BlockFace.SOUTH, BlockFace.WEST)) { + Block neighbor = mock(Block.class); + when(neighbor.getType()).thenReturn(Material.AIR); + when(magicBlock.getRelative(face)).thenReturn(neighbor); + neighbors.put(face, neighbor); + } + return neighbors; + } + + /** + * Turns a neighbor mock into a solid block that a multiface plant can attach to. + * @param neighbor the neighbor mock + * @param face the face of the neighbor that the plant would sit on + */ + private void makeSolid(Block neighbor, BlockFace face) { + BlockData data = mock(BlockData.class); + when(neighbor.getType()).thenReturn(Material.MOSS_BLOCK); + when(neighbor.getBlockData()).thenReturn(data); + when(data.isFaceSturdy(face, BlockSupport.FULL)).thenReturn(true); + } + + /** + * Test method for + * {@link world.bentobox.chunkblock.listeners.BlockListener} spawnBlock — GLOW_LICHEN with support. + * Glow lichen is attached to the faces of the solid neighbors the magic block has, so that + * vanilla does not delete it and bone meal has a face to spread from. + */ + @Test + void testSpawnBlockGlowLichenAttachesToNeighbors() throws Exception { + Map neighbors = stubAirNeighbors(Material.GLOW_LICHEN); + makeSolid(neighbors.get(BlockFace.DOWN), BlockFace.UP); + makeSolid(neighbors.get(BlockFace.NORTH), BlockFace.SOUTH); + + callSpawnBlock(new OneBlockObject(Material.GLOW_LICHEN, 1), magicBlock); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BlockData.class); + verify(magicBlock).setBlockData(captor.capture(), eq(false)); + MultipleFacing lichen = (MultipleFacing) captor.getValue(); + assertEquals(Material.GLOW_LICHEN, lichen.getMaterial()); + assertEquals(Set.of(BlockFace.DOWN, BlockFace.NORTH), lichen.getFaces()); + // The magic block itself is the lichen, so no support block is placed + verify(magicBlock, never()).setType(any(Material.class), anyBoolean()); + } + + /** + * Test method for + * {@link world.bentobox.chunkblock.listeners.BlockListener} spawnBlock — GLOW_LICHEN in mid-air. + * With nothing to cling to, the magic block becomes moss and the lichen grows on top of it. + */ + @Test + void testSpawnBlockGlowLichenWithNoSupport() throws Exception { + Map neighbors = stubAirNeighbors(Material.GLOW_LICHEN); + + callSpawnBlock(new OneBlockObject(Material.GLOW_LICHEN, 1), magicBlock); + + verify(magicBlock).setType(Material.MOSS_BLOCK, false); + ArgumentCaptor captor = ArgumentCaptor.forClass(BlockData.class); + verify(neighbors.get(BlockFace.UP)).setBlockData(captor.capture(), eq(false)); + MultipleFacing lichen = (MultipleFacing) captor.getValue(); + assertEquals(Material.GLOW_LICHEN, lichen.getMaterial()); + assertEquals(Set.of(BlockFace.DOWN), lichen.getFaces()); + } + + /** + * Test method for + * {@link world.bentobox.chunkblock.listeners.BlockListener} spawnBlock — VINE in mid-air. + * Vines cannot hang off the underside of a block, so they are grown on the side of the + * support block instead. + */ + @Test + void testSpawnBlockVineWithNoSupport() throws Exception { + Map neighbors = stubAirNeighbors(Material.VINE); + + callSpawnBlock(new OneBlockObject(Material.VINE, 1), magicBlock); + + verify(magicBlock).setType(Material.MOSS_BLOCK, false); + verify(neighbors.get(BlockFace.UP), never()).setBlockData(any(), anyBoolean()); + ArgumentCaptor captor = ArgumentCaptor.forClass(BlockData.class); + verify(neighbors.get(BlockFace.NORTH)).setBlockData(captor.capture(), eq(false)); + MultipleFacing vine = (MultipleFacing) captor.getValue(); + assertEquals(Material.VINE, vine.getMaterial()); + assertEquals(Set.of(BlockFace.SOUTH), vine.getFaces()); + } + /** * Test method for * {@link world.bentobox.chunkblock.listeners.BlockListener} spawnBlock — CHEST with items.