diff --git a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java index 83bc8ca..4d7adc3 100644 --- a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java +++ b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java @@ -1,5 +1,6 @@ package world.bentobox.caveblock.listeners; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -8,7 +9,9 @@ import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.world.AsyncStructureSpawnEvent; +import org.bukkit.generator.structure.Structure; +import io.papermc.paper.event.world.StructuresLocateEvent; import world.bentobox.caveblock.CaveBlock; /** @@ -23,10 +26,17 @@ * disabled in {@code world.structures}.

* *

{@link AsyncStructureSpawnEvent} fires off the main thread during chunk - * generation, so this handler only reads config and inspects the event. It + * generation, so that handler only reads config and inspects the event. It * queries the event's world for environment and ownership checks but performs * no world or block mutation, which is what makes it safe to run async.

* + *

Cancelling the spawn stops a structure being placed but leaves its + * placement rules intact, so structure searches ({@code /locate}, Eyes of + * Ender, treasure/explorer maps, villager map trades) keep proposing candidate + * positions that are then all cancelled, scanning to the radius cap and freezing + * the server (issue #116). {@link #onStructuresLocate} closes that hole by removing + * disabled structures from the search before it runs.

+ * * @author tastybento */ public class StructureGenerationListener implements Listener { @@ -58,6 +68,38 @@ public void onStructureSpawn(AsyncStructureSpawnEvent event) { } } + /** + * Prevents disabled structures from being located. {@link StructuresLocateEvent} + * fires before any structure search — the {@code /locate} command, Eyes of Ender, + * explorer/treasure maps, dolphins and villager map trades. On a cave world where the + * target structure is suppressed, that search never succeeds and scans out to the radius + * cap, freezing the main thread (issue #116). Removing the disabled structures from the + * search list — and cancelling outright when nothing remains — skips the scan entirely so + * the search returns "not found" instantly. + * + * @param event the structure locate event + */ + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onStructuresLocate(StructuresLocateEvent event) { + if (!addon.inWorld(event.getWorld())) { + return; + } + List targets = event.getStructures(); + List allowed = targets.stream() + .filter(structure -> !isDisabled(structure.getKey().getKey())) + .toList(); + if (allowed.size() == targets.size()) { + // Nothing disabled in this search — let it run normally. + return; + } + if (allowed.isEmpty()) { + // Every requested structure is disabled here: skip the expensive scan entirely. + event.setCancelled(true); + } else { + event.setStructures(allowed); + } + } + /** * @param structureKey the vanilla structure key path, e.g. {@code ancient_city} * @return {@code true} if the config explicitly disables this structure diff --git a/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java b/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java index e61c3f4..417070c 100644 --- a/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java +++ b/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java @@ -1,11 +1,15 @@ package world.bentobox.caveblock.listeners; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.lenient; 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.Arrays; +import java.util.List; import java.util.Map; import org.bukkit.NamespacedKey; @@ -17,9 +21,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import io.papermc.paper.event.world.StructuresLocateEvent; import world.bentobox.caveblock.CaveBlock; import world.bentobox.caveblock.Settings; @@ -107,4 +113,53 @@ void testConfigKeyWithHyphensAndCaseMatches() { listener.onStructureSpawn(e); verify(e).setCancelled(true); } + + private StructuresLocateEvent locateEvent(String... structureKeys) { + List structures = Arrays.stream(structureKeys).map(key -> { + Structure structure = mock(Structure.class); + lenient().when(structure.getKey()).thenReturn(NamespacedKey.minecraft(key)); + return structure; + }).map(Structure.class::cast).toList(); + StructuresLocateEvent e = mock(StructuresLocateEvent.class); + lenient().when(e.getWorld()).thenReturn(world); + lenient().when(e.getStructures()).thenReturn(structures); + return e; + } + + @Test + void testLocateAllDisabledIsCancelled() { + StructuresLocateEvent e = locateEvent("trial_chambers"); + listener.onStructuresLocate(e); + verify(e).setCancelled(true); + verify(e, never()).setStructures(anyList()); + } + + @Test + void testLocateMixedNarrowsToEnabledOnly() { + StructuresLocateEvent e = locateEvent("trial_chambers", "mineshaft"); + listener.onStructuresLocate(e); + verify(e, never()).setCancelled(true); + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(e).setStructures(captor.capture()); + assertEquals(1, captor.getValue().size()); + assertEquals("mineshaft", captor.getValue().get(0).getKey().getKey()); + } + + @Test + void testLocateAllEnabledIsUntouched() { + StructuresLocateEvent e = locateEvent("mineshaft", "village_plains"); + listener.onStructuresLocate(e); + verify(e, never()).setCancelled(true); + verify(e, never()).setStructures(anyList()); + } + + @Test + void testLocateOutsideCaveBlockWorldIsIgnored() { + when(addon.inWorld(world)).thenReturn(false); + StructuresLocateEvent e = locateEvent("trial_chambers"); + listener.onStructuresLocate(e); + verify(e, never()).setCancelled(true); + verify(e, never()).setStructures(anyList()); + } }