diff --git a/build.gradle.kts b/build.gradle.kts index 1a90e47..55bf0f2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,17 @@ dependencies { compileOnly("com.github.GriefPrevention:GriefPrevention:16.18.6") { isTransitive = false } + + testImplementation("io.papermc.paper:paper-api:26.2.build.+") + testImplementation("com.github.Zrips:Jobs:v5.2.6.2") { + isTransitive = false + } + testImplementation("com.github.GriefPrevention:GriefPrevention:16.18.6") { + isTransitive = false + } + testImplementation("org.junit.jupiter:junit-jupiter:5.11.3") + testImplementation("org.mockito:mockito-core:5.23.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.3") } java { @@ -32,6 +43,10 @@ tasks.withType().configureEach { options.compilerArgs.addAll(listOf("-Xlint:deprecation", "-Xlint:unchecked")) } +tasks.test { + useJUnitPlatform() +} + tasks.processResources { inputs.property("pluginVersion", pluginVersion) filesMatching("plugin.yml") { diff --git a/src/main/java/net/therosegarden/firefighter/EmergencyLocationProvider.java b/src/main/java/net/therosegarden/firefighter/EmergencyLocationProvider.java index ff1b62d..829b9a6 100644 --- a/src/main/java/net/therosegarden/firefighter/EmergencyLocationProvider.java +++ b/src/main/java/net/therosegarden/firefighter/EmergencyLocationProvider.java @@ -8,5 +8,7 @@ import org.bukkit.World; interface EmergencyLocationProvider { EmergencySite randomClaimSite(Random random, Predicate worldAllowed, int maxRadius, int minClaimArea); + EmergencySite associateClaim(EmergencySite site); + String nearbyClaims(Location location, int radius, int maxResults); } diff --git a/src/main/java/net/therosegarden/firefighter/EmergencyManager.java b/src/main/java/net/therosegarden/firefighter/EmergencyManager.java index 77b9119..0db5ae0 100644 --- a/src/main/java/net/therosegarden/firefighter/EmergencyManager.java +++ b/src/main/java/net/therosegarden/firefighter/EmergencyManager.java @@ -280,6 +280,10 @@ final class EmergencyManager { return false; } + if (locationProvider != null) { + site = locationProvider.associateClaim(site); + } + activeSite = site; activeFires.addAll(spawned); if (construction != null) { diff --git a/src/main/java/net/therosegarden/firefighter/EmergencySite.java b/src/main/java/net/therosegarden/firefighter/EmergencySite.java index fb3eff6..dc9415e 100644 --- a/src/main/java/net/therosegarden/firefighter/EmergencySite.java +++ b/src/main/java/net/therosegarden/firefighter/EmergencySite.java @@ -4,7 +4,15 @@ import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.World; -record EmergencySite(String name, String worldName, int x, int y, int z, int radius) { +record EmergencySite(String name, String worldName, int x, int y, int z, int radius, Long griefPreventionClaimId) { + EmergencySite(String name, String worldName, int x, int y, int z, int radius) { + this(name, worldName, x, y, z, radius, null); + } + + EmergencySite withGriefPreventionClaimId(Long claimId) { + return new EmergencySite(name, worldName, x, y, z, radius, claimId); + } + Location center() { World world = Bukkit.getWorld(worldName); return world == null ? null : new Location(world, x + 0.5, y, z + 0.5); diff --git a/src/main/java/net/therosegarden/firefighter/GriefPreventionBridge.java b/src/main/java/net/therosegarden/firefighter/GriefPreventionBridge.java index 61d590e..ea03b31 100644 --- a/src/main/java/net/therosegarden/firefighter/GriefPreventionBridge.java +++ b/src/main/java/net/therosegarden/firefighter/GriefPreventionBridge.java @@ -12,6 +12,7 @@ import org.bukkit.event.Listener; import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerInteractEvent; final class GriefPreventionBridge implements Listener { private final EmergencyManager emergencies; @@ -25,7 +26,7 @@ final class GriefPreventionBridge implements Listener { this.construction = construction; } - @EventHandler(priority = EventPriority.HIGHEST) + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false) public void onClaimPermissionCheck(ClaimPermissionCheckEvent event) { if (event.getRequiredPermission() != ClaimPermission.Build || event.getDenialReason() == null) { return; @@ -36,6 +37,11 @@ final class GriefPreventionBridge implements Listener { return; } + EmergencySite activeSite = emergencies.activeSite(); + if (!isIncidentClaim(activeSite, event)) { + return; + } + Event trigger = event.getTriggeringEvent(); if (trigger instanceof BlockPlaceEvent placeEvent) { allowIfInside(event, placeEvent.getBlock().getLocation()); @@ -47,6 +53,14 @@ final class GriefPreventionBridge implements Listener { return; } + if (trigger instanceof PlayerInteractEvent interactEvent) { + Block block = interactEvent.getClickedBlock(); + if (block != null) { + allowIfInside(event, block.getLocation()); + } + return; + } + if (trigger instanceof BlockBreakEvent breakEvent) { Block block = breakEvent.getBlock(); if (permissions.isInsideActiveSite(block.getLocation()) @@ -58,6 +72,14 @@ final class GriefPreventionBridge implements Listener { } } + private boolean isIncidentClaim(EmergencySite activeSite, ClaimPermissionCheckEvent event) { + if (activeSite == null || activeSite.griefPreventionClaimId() == null || event.getClaim() == null) { + return false; + } + Long checkedClaimId = event.getClaim().getID(); + return checkedClaimId != null && checkedClaimId.equals(activeSite.griefPreventionClaimId()); + } + private void allowIfInside(ClaimPermissionCheckEvent event, Location location) { if (permissions.isInsideActiveSite(location)) { event.setDenialReason(null); diff --git a/src/main/java/net/therosegarden/firefighter/GriefPreventionLocationProvider.java b/src/main/java/net/therosegarden/firefighter/GriefPreventionLocationProvider.java index 94b15bb..acdc244 100644 --- a/src/main/java/net/therosegarden/firefighter/GriefPreventionLocationProvider.java +++ b/src/main/java/net/therosegarden/firefighter/GriefPreventionLocationProvider.java @@ -60,6 +60,21 @@ final class GriefPreventionLocationProvider implements EmergencyLocationProvider return new EmergencySite(owner + "'s home", world.getName(), centerX, y, centerZ, radius); } + @Override + public EmergencySite associateClaim(EmergencySite site) { + if (site == null) { + return null; + } + + Location center = site.center(); + if (center == null || center.getWorld() == null) { + return site.withGriefPreventionClaimId(null); + } + + Claim claim = GriefPrevention.instance.dataStore.getClaimAt(center, false, null); + return site.withGriefPreventionClaimId(claim == null ? null : claim.getID()); + } + @Override public String nearbyClaims(Location location, int radius, int maxResults) { if (location == null || location.getWorld() == null) { diff --git a/src/test/java/net/therosegarden/firefighter/GriefPreventionBridgeTest.java b/src/test/java/net/therosegarden/firefighter/GriefPreventionBridgeTest.java new file mode 100644 index 0000000..50052b0 --- /dev/null +++ b/src/test/java/net/therosegarden/firefighter/GriefPreventionBridgeTest.java @@ -0,0 +1,217 @@ +package net.therosegarden.firefighter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.anyBoolean; +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.lang.reflect.Method; +import java.util.UUID; +import java.util.function.Supplier; +import me.ryanhamshire.GriefPrevention.Claim; +import me.ryanhamshire.GriefPrevention.ClaimPermission; +import me.ryanhamshire.GriefPrevention.events.ClaimPermissionCheckEvent; +import org.bukkit.Location; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GriefPreventionBridgeTest { + private static final Long INCIDENT_CLAIM_ID = 42L; + private static final Supplier DENIED = () -> "denied"; + + private EmergencyManager emergencies; + private EmergencyPermissionManager permissions; + private EmergencyConstructionManager construction; + private GriefPreventionBridge bridge; + private Player player; + private EmergencySite incidentSite; + + @BeforeEach + void setUp() { + emergencies = mock(EmergencyManager.class); + permissions = mock(EmergencyPermissionManager.class); + construction = mock(EmergencyConstructionManager.class); + bridge = new GriefPreventionBridge(emergencies, permissions, construction); + player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + incidentSite = new EmergencySite("Incident", "world", 100, 64, 100, 18, INCIDENT_CLAIM_ID); + } + + @Test + void alreadyDeniedPlayerInteractBuildIsProcessedAndClearedForEligibleIncident() { + Block block = mock(Block.class); + Location location = mock(Location.class); + when(block.getLocation()).thenReturn(location); + + PlayerInteractEvent trigger = mock(PlayerInteractEvent.class); + when(trigger.getClickedBlock()).thenReturn(block); + + allowEligibleIncident(location); + ClaimPermissionCheckEvent event = deniedEvent(ClaimPermission.Build, trigger, INCIDENT_CLAIM_ID); + + assertTrue(event.isCancelled(), "GriefPrevention denial should make the permission event cancelled before RoseFirefighter handles it"); + bridge.onClaimPermissionCheck(event); + + assertFalse(event.isCancelled()); + assertNull(event.getDenialReason()); + } + + @Test + void correctIncidentClaimAllowsPlaceAndBreakWithoutUncancellingTriggerEvents() { + Block placeBlock = mock(Block.class); + Location placeLocation = mock(Location.class); + when(placeBlock.getLocation()).thenReturn(placeLocation); + BlockPlaceEvent placeTrigger = mock(BlockPlaceEvent.class); + when(placeTrigger.getBlock()).thenReturn(placeBlock); + + Block breakBlock = mock(Block.class); + Location breakLocation = mock(Location.class); + when(breakBlock.getLocation()).thenReturn(breakLocation); + BlockBreakEvent breakTrigger = mock(BlockBreakEvent.class); + when(breakTrigger.getBlock()).thenReturn(breakBlock); + + when(permissions.hasBuildAccess(player)).thenReturn(true); + when(emergencies.activeSite()).thenReturn(incidentSite); + when(permissions.isInsideActiveSite(placeLocation)).thenReturn(true); + when(permissions.isInsideActiveSite(breakLocation)).thenReturn(true); + when(construction.isForcedEntryEnabled()).thenReturn(true); + + ClaimPermissionCheckEvent placeEvent = deniedEvent(ClaimPermission.Build, placeTrigger, INCIDENT_CLAIM_ID); + ClaimPermissionCheckEvent breakEvent = deniedEvent(ClaimPermission.Build, breakTrigger, INCIDENT_CLAIM_ID); + + bridge.onClaimPermissionCheck(placeEvent); + bridge.onClaimPermissionCheck(breakEvent); + + assertNull(placeEvent.getDenialReason()); + assertNull(breakEvent.getDenialReason()); + verify(placeTrigger, never()).setCancelled(anyBoolean()); + verify(breakTrigger, never()).setCancelled(anyBoolean()); + } + + @Test + void noActiveEmergencyKeepsGriefPreventionDenial() { + Block block = blockAt(mock(Location.class)); + BlockPlaceEvent trigger = mock(BlockPlaceEvent.class); + when(trigger.getBlock()).thenReturn(block); + when(permissions.hasBuildAccess(player)).thenReturn(true); + when(emergencies.activeSite()).thenReturn(null); + + ClaimPermissionCheckEvent event = deniedEvent(ClaimPermission.Build, trigger, INCIDENT_CLAIM_ID); + bridge.onClaimPermissionCheck(event); + + assertNotNull(event.getDenialReason()); + assertTrue(event.isCancelled()); + } + + @Test + void nonFirefighterOrOtherwiseIneligiblePlayerKeepsDenial() { + Location location = mock(Location.class); + Block block = blockAt(location); + BlockPlaceEvent trigger = mock(BlockPlaceEvent.class); + when(trigger.getBlock()).thenReturn(block); + when(permissions.hasBuildAccess(player)).thenReturn(false); + when(emergencies.activeSite()).thenReturn(incidentSite); + + ClaimPermissionCheckEvent event = deniedEvent(ClaimPermission.Build, trigger, INCIDENT_CLAIM_ID); + bridge.onClaimPermissionCheck(event); + + assertNotNull(event.getDenialReason()); + } + + @Test + void unrelatedOrChildClaimKeepsDenialEvenInsideResponseRadius() { + Location location = mock(Location.class); + Block block = blockAt(location); + BlockPlaceEvent trigger = mock(BlockPlaceEvent.class); + when(trigger.getBlock()).thenReturn(block); + allowEligibleIncident(location); + + ClaimPermissionCheckEvent unrelatedClaim = deniedEvent(ClaimPermission.Build, trigger, 99L); + ClaimPermissionCheckEvent childClaim = deniedEvent(ClaimPermission.Build, trigger, 43L); + + bridge.onClaimPermissionCheck(unrelatedClaim); + bridge.onClaimPermissionCheck(childClaim); + + assertNotNull(unrelatedClaim.getDenialReason()); + assertNotNull(childClaim.getDenialReason()); + } + + @Test + void incidentEndImmediatelyRestoresDenial() { + Location location = mock(Location.class); + Block block = blockAt(location); + BlockPlaceEvent trigger = mock(BlockPlaceEvent.class); + when(trigger.getBlock()).thenReturn(block); + + allowEligibleIncident(location); + ClaimPermissionCheckEvent duringIncident = deniedEvent(ClaimPermission.Build, trigger, INCIDENT_CLAIM_ID); + bridge.onClaimPermissionCheck(duringIncident); + assertNull(duringIncident.getDenialReason()); + + when(permissions.hasBuildAccess(player)).thenReturn(false); + when(emergencies.activeSite()).thenReturn(null); + ClaimPermissionCheckEvent afterIncident = deniedEvent(ClaimPermission.Build, trigger, INCIDENT_CLAIM_ID); + bridge.onClaimPermissionCheck(afterIncident); + + assertNotNull(afterIncident.getDenialReason()); + assertTrue(afterIncident.isCancelled()); + } + + @Test + void onlyBuildPermissionIsBypassed() { + Location location = mock(Location.class); + Block block = blockAt(location); + BlockPlaceEvent trigger = mock(BlockPlaceEvent.class); + when(trigger.getBlock()).thenReturn(block); + allowEligibleIncident(location); + + ClaimPermissionCheckEvent editEvent = deniedEvent(ClaimPermission.Edit, trigger, INCIDENT_CLAIM_ID); + bridge.onClaimPermissionCheck(editEvent); + + assertNotNull(editEvent.getDenialReason()); + assertTrue(editEvent.isCancelled()); + } + + @Test + void listenerExplicitlyReceivesCancelledPermissionChecksAtHighestPriority() throws Exception { + Method method = GriefPreventionBridge.class.getMethod("onClaimPermissionCheck", ClaimPermissionCheckEvent.class); + EventHandler handler = method.getAnnotation(EventHandler.class); + + assertNotNull(handler); + assertEquals(EventPriority.HIGHEST, handler.priority()); + assertFalse(handler.ignoreCancelled()); + } + + private void allowEligibleIncident(Location location) { + when(permissions.hasBuildAccess(player)).thenReturn(true); + when(emergencies.activeSite()).thenReturn(incidentSite); + when(permissions.isInsideActiveSite(location)).thenReturn(true); + } + + private Block blockAt(Location location) { + Block block = mock(Block.class); + when(block.getLocation()).thenReturn(location); + return block; + } + + private ClaimPermissionCheckEvent deniedEvent(ClaimPermission permission, org.bukkit.event.Event trigger, Long claimId) { + Claim claim = mock(Claim.class); + when(claim.getID()).thenReturn(claimId); + ClaimPermissionCheckEvent event = new ClaimPermissionCheckEvent(player, claim, permission, trigger); + event.setDenialReason(DENIED); + return event; + } +}