Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ jobs:
distribution: 'temurin'
cache: 'maven'
- name: Build
run: mvn -B verify
run: mvn -B -DskipTests=false verify
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ nbproject

target
dependency-reduced-pom.xml
src/test
maven_output.txt

# Wrapper files
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
183 changes: 183 additions & 0 deletions src/test/java/net/coreprotect/listener/block/ScaffoldingBreakTest.java
Original file line number Diff line number Diff line change
@@ -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<Config> configs;
private MockedStatic<Queue> 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());
}
}