diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index daa2ba59b..2e9138925 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,4 +20,4 @@ jobs: distribution: 'temurin' cache: 'maven' - name: Build - run: mvn -B verify + run: mvn -B -DskipTests=false verify diff --git a/.gitignore b/.gitignore index 26bf9102f..580487532 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,6 @@ nbproject target dependency-reduced-pom.xml -src/test maven_output.txt # Wrapper files diff --git a/src/main/java/net/coreprotect/listener/block/BlockBreakListener.java b/src/main/java/net/coreprotect/listener/block/BlockBreakListener.java index 698eec5ea..8888b5f0d 100644 --- a/src/main/java/net/coreprotect/listener/block/BlockBreakListener.java +++ b/src/main/java/net/coreprotect/listener/block/BlockBreakListener.java @@ -126,6 +126,15 @@ protected static void processBlockBreak(Player player, String user, Block block, Location scanLocation = locationMap[scanMin - 1]; Block scanBlock = world.getBlockAt(scanLocation); Material scanType = scanBlock.getType(); + if (scanType == Material.SCAFFOLDING) { + // Scaffolding can retain support from the side. Attribute the + // pending collapse, but let BlockFadeEvent confirm each break. + if (scanMin == BlockUtil.TOP || (type == Material.SCAFFOLDING && scanMin < BlockUtil.TOP)) { + BlockFadeListener.trackScaffoldingBreak(user, scanBlock.getLocation()); + } + scanMin++; + continue; + } if (scanMin == 5 && (scanType.hasGravity() || BukkitAdapter.ADAPTER.isSuspiciousBlock(scanType)) && Config.getConfig(world).BLOCK_MOVEMENT) { // FallingBlock listeners read lookupCache to attribute the chain reaction; overwrite the cache up the gravity stack so falls record under whoever broke this block instead of #gravity or the original placer. seedGravityFallAttribution(user, world, x, z, y + 1, scanType); diff --git a/src/main/java/net/coreprotect/listener/block/BlockFadeListener.java b/src/main/java/net/coreprotect/listener/block/BlockFadeListener.java index 080d34f81..f40c4eaa2 100644 --- a/src/main/java/net/coreprotect/listener/block/BlockFadeListener.java +++ b/src/main/java/net/coreprotect/listener/block/BlockFadeListener.java @@ -1,20 +1,41 @@ package net.coreprotect.listener.block; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockState; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.Waterlogged; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.block.BlockFadeEvent; import net.coreprotect.config.Config; import net.coreprotect.consumer.Queue; import net.coreprotect.model.BlockGroup; +import net.coreprotect.thread.CacheHandler; public final class BlockFadeListener extends Queue implements Listener { - @EventHandler + private static final BlockFace[] SCAFFOLDING_DEPENDENTS = { + BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST + }; + + private static String scaffoldingKey(Location location) { + return "scaffolding." + location.getWorld().getUID() + "." + location.getBlockX() + "." + location.getBlockY() + "." + location.getBlockZ(); + } + + static void trackScaffoldingBreak(String user, Location location) { + // Use a separate key from placement/gravity attribution. CacheHandler + // expires these entries after 30 seconds, including surviving scaffolds. + CacheHandler.lookupCache.put(scaffoldingKey(location), new Object[] { + (int) (System.currentTimeMillis() / 1000L), user, Material.SCAFFOLDING + }); + } + + @EventHandler(priority = EventPriority.MONITOR) protected void onBlockFade(BlockFadeEvent event) { if (event.isCancelled()) { return; @@ -24,7 +45,29 @@ protected void onBlockFade(BlockFadeEvent event) { Material type = block.getType(); World world = block.getWorld(); - if (BlockGroup.FIRE.contains(type)) { + if (type == Material.SCAFFOLDING) { + if (!Config.getConfig(world).NATURAL_BREAK || event.getNewState().getType() == type) { + return; + } + + Object[] attribution = CacheHandler.lookupCache.remove(scaffoldingKey(block.getLocation())); + int now = (int) (System.currentTimeMillis() / 1000L); + String user = attribution != null && now - (int) attribution[0] <= 30 ? (String) attribution[1] : "#physics"; + + // Each confirmed collapse passes attribution on to the next tick's + // dependents, without predicting which scaffolds will lose support. + for (BlockFace face : SCAFFOLDING_DEPENDENTS) { + Location dependent = block.getLocation().add(face.getModX(), face.getModY(), face.getModZ()); + trackScaffoldingBreak(user, dependent); + } + + BlockState state = block.getState(); + Queue.queueBlockBreak(user, state, type, state.getBlockData().getAsString(), 0); + if (state.getBlockData() instanceof Waterlogged && ((Waterlogged) state.getBlockData()).isWaterlogged()) { + Queue.queueBlockPlace(user, state, type, null, Material.WATER, -1, 0, null); + } + } + else if (BlockGroup.FIRE.contains(type)) { if (!Config.getConfig(world).FIRE_EXTINGUISH) { return; } diff --git a/src/main/java/net/coreprotect/listener/entity/EntityChangeBlockListener.java b/src/main/java/net/coreprotect/listener/entity/EntityChangeBlockListener.java index 82bb9eceb..25ef1b513 100644 --- a/src/main/java/net/coreprotect/listener/entity/EntityChangeBlockListener.java +++ b/src/main/java/net/coreprotect/listener/entity/EntityChangeBlockListener.java @@ -100,6 +100,11 @@ private void handleFallingBlock(EntityChangeBlockEvent event, FallingBlock falli int worldId = WorldUtils.getWorldId(world.getName()); if (newtype.equals(Material.AIR) || newtype.equals(Material.CAVE_AIR)) { + if (type == Material.SCAFFOLDING) { + // BlockFadeListener records scaffolding losing support, including + // the variant that turns into a falling entity. Avoid duplicates. + return; + } if (!Config.getConfig(world).BLOCK_BREAK) { return; } diff --git a/src/test/java/net/coreprotect/listener/block/ScaffoldingBreakTest.java b/src/test/java/net/coreprotect/listener/block/ScaffoldingBreakTest.java new file mode 100644 index 000000000..fdbb411f5 --- /dev/null +++ b/src/test/java/net/coreprotect/listener/block/ScaffoldingBreakTest.java @@ -0,0 +1,183 @@ +package net.coreprotect.listener.block; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.data.type.Scaffolding; +import org.bukkit.event.block.BlockFadeEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.coreprotect.config.Config; +import net.coreprotect.consumer.Queue; +import net.coreprotect.thread.CacheHandler; + +class ScaffoldingBreakTest extends Queue { + private World world; + private Config config; + private MockedStatic configs; + private MockedStatic queue; + private final BlockFadeListener listener = new BlockFadeListener(); + + @BeforeEach + void setUp() { + world = mock(World.class); + when(world.getUID()).thenReturn(UUID.randomUUID()); + config = new Config(); + config.NATURAL_BREAK = true; + configs = mockStatic(Config.class); + configs.when(() -> Config.getConfig(any(World.class))).thenReturn(config); + queue = mockStatic(Queue.class); + CacheHandler.lookupCache.clear(); + } + + @AfterEach + void tearDown() { + queue.close(); + configs.close(); + CacheHandler.lookupCache.clear(); + } + + private Block scaffold(int x, int y, boolean waterlogged) { + Block block = mock(Block.class); + BlockState state = mock(BlockState.class); + Scaffolding data = mock(Scaffolding.class); + when(block.getWorld()).thenReturn(world); + when(block.getLocation()).thenAnswer(invocation -> new Location(world, x, y, 0)); + when(block.getType()).thenReturn(Material.SCAFFOLDING); + when(block.getState()).thenReturn(state); + when(state.getBlockData()).thenReturn(data); + when(data.isWaterlogged()).thenReturn(waterlogged); + when(data.getAsString()).thenReturn("minecraft:scaffolding[waterlogged=" + waterlogged + "]"); + return block; + } + + private BlockFadeEvent fade(Block block) { + BlockState replacement = mock(BlockState.class); + when(replacement.getType()).thenReturn(Material.AIR); + return new BlockFadeEvent(block, replacement); + } + + private void verifyBreak(String user, Block block) { + BlockState state = block.getState(); + queue.verify(() -> queueBlockBreak(user, state, Material.SCAFFOLDING, state.getBlockData().getAsString(), 0)); + } + + @Test + void supportBreakAttributesScaffoldingWithoutLoggingItPrematurely() { + Block support = mock(Block.class); + BlockState supportState = mock(BlockState.class); + when(support.getType()).thenReturn(Material.STONE); + when(support.getWorld()).thenReturn(world); + when(support.getY()).thenReturn(64); + when(support.getState()).thenReturn(supportState); + + Block air = mock(Block.class); + when(air.getType()).thenReturn(Material.AIR); + when(world.getBlockAt(any(Location.class))).thenReturn(air); + Block above = scaffold(0, 65, false); + when(world.getBlockAt(argThat((Location location) -> location.getBlockY() == 65))).thenReturn(above); + + BlockBreakListener.processBlockBreak(null, "Breaker", support, false, BlockUtil.NONE); + queue.verifyNoInteractions(); + listener.onBlockFade(fade(above)); + verifyBreak("Breaker", above); + } + + @Test + void attributesEntireTowerToPlayerBreakingSupport() { + track("Breaker", new Location(world, 0, 65, 0)); + for (int y = 65; y < 129; y++) { + Block block = scaffold(0, y, false); + listener.onBlockFade(fade(block)); + verifyBreak("Breaker", block); + } + } + + private static void track(String user, Location location) { + BlockFadeListener.trackScaffoldingBreak(user, location); + } + + @Test + void propagatesAttributionToHorizontalDependents() { + Block first = scaffold(0, 65, false); + track("Breaker", first.getLocation()); + listener.onBlockFade(fade(first)); + Block side = scaffold(1, 65, false); + listener.onBlockFade(fade(side)); + verifyBreak("Breaker", side); + } + + @Test + void trackingSurvivingScaffoldingDoesNotLogABreak() { + track("Breaker", scaffold(0, 65, false).getLocation()); + queue.verifyNoInteractions(); + } + + @Test + void cancelledCollapseDoesNotLogOrPropagate() { + Block block = scaffold(0, 65, false); + track("Breaker", block.getLocation()); + BlockFadeEvent event = fade(block); + event.setCancelled(true); + listener.onBlockFade(event); + queue.verifyNoInteractions(); + Block above = scaffold(0, 66, false); + listener.onBlockFade(fade(above)); + verifyBreak("#physics", above); + } + + @Test + void respectsNaturalBreakSetting() { + config.NATURAL_BREAK = false; + listener.onBlockFade(fade(scaffold(0, 65, false))); + queue.verifyNoInteractions(); + } + + @Test + void ignoresStateChangesThatKeepTheScaffolding() { + Block block = scaffold(0, 65, false); + BlockState replacement = mock(BlockState.class); + when(replacement.getType()).thenReturn(Material.SCAFFOLDING); + listener.onBlockFade(new BlockFadeEvent(block, replacement)); + queue.verifyNoInteractions(); + } + + @Test + void ignoresExpiredAttribution() { + Block block = scaffold(0, 65, false); + track("OldPlayer", block.getLocation()); + CacheHandler.lookupCache.values().forEach(value -> value[0] = (int) (System.currentTimeMillis() / 1000L) - 31); + listener.onBlockFade(fade(block)); + verifyBreak("#physics", block); + } + + @Test + void preservesWaterReleasedByWaterloggedScaffolding() { + Block block = scaffold(0, 65, true); + track("Breaker", block.getLocation()); + listener.onBlockFade(fade(block)); + verifyBreak("Breaker", block); + queue.verify(() -> queueBlockPlace("Breaker", block.getState(), Material.SCAFFOLDING, null, Material.WATER, -1, 0, null)); + } + + @Test + void dryScaffoldingDoesNotLogWater() { + Block block = scaffold(0, 65, false); + listener.onBlockFade(fade(block)); + queue.verify(() -> queueBlockPlace("#physics", block.getState(), Material.SCAFFOLDING, null, Material.WATER, -1, 0, null), never()); + } +}