diff --git a/CLAUDE.md b/CLAUDE.md
index f5a68f2..a813d0f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -23,9 +23,10 @@ Java 21 is required. The output JAR goes to `target/`.
**Key components:**
- `CaveBlock.java` — Main addon lifecycle (`onLoad`, `onEnable`, `createWorlds`). Creates 3 world generators (normal, nether, end) and registers flags/listeners.
- `Settings.java` — 50+ annotated config fields implementing BentoBox's `WorldSettings`. Maps to `config.yml`.
-- `generators/ChunkGeneratorWorld.java` — Core chunk generator. Fills the world with the dimension's base blocks (stone/netherrack/end stone) and relies on the current populator pipeline for additional material generation.
-- `generators/populators/` — `NewMaterialPopulator` (current vanilla-like material generation) and `FlatBiomeProvider` (biome distribution).
+- `generators/ChunkGeneratorWorld.java` — Core chunk generator. Fills the world with the dimension's base blocks (stone/netherrack/end stone) and delegates additional material generation to the populator pipeline. `NoiseCaveGenerator` and `Ore` support noise-based cave carving and ore placement.
+- `generators/populators/` — `NewMaterialPopulator` (vanilla-like material generation), `CaveDecorationPopulator` (cave decoration), and the biome providers `FlatBiomeProvider` / `NetherBiomeProvider` (biome distribution per dimension).
- `listeners/CustomHeightLimitations.java` — Prevents players from going above the configured world depth. Respects `SKY_WALKER_FLAG` and creative/op bypass.
+- `listeners/StructureGenerationListener.java` — Suppresses vanilla structures the admin disabled in `world.structures` from the CaveBlock overworld: cancels their spawn (`AsyncStructureSpawnEvent`) and removes them from structure searches (`StructuresLocateEvent`) so `/locate`, explorer maps, etc. don't scan to the radius cap and freeze the server.
**BentoBox framework patterns to follow:**
- Addons use `GameModeAddon` lifecycle, not Bukkit's `JavaPlugin` directly
diff --git a/pom.xml b/pom.xml
index 0b9e721..708097e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -50,7 +50,7 @@
{@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 { @@ -47,9 +59,8 @@ public StructureGenerationListener(CaveBlock addon) { */ @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onStructureSpawn(AsyncStructureSpawnEvent event) { - World world = event.getWorld(); // Only the CaveBlock overworld uses vanilla structures; nether/end do not. - if (world.getEnvironment() != World.Environment.NORMAL || !addon.inWorld(world)) { + if (!isCaveOverworld(event.getWorld())) { return; } String structureKey = event.getStructure().getKey().getKey(); @@ -58,6 +69,58 @@ 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 (!isCaveOverworld(event.getWorld())) { + return; + } + ListMatches on the configured world name rather than {@link CaveBlock#inWorld(World)} + * because structures in the spawn area are generated during {@code createWorlds()}, + * before the addon's island-world reference is assigned. At that point + * {@code inWorld()} would compare against a null world and fail, so the first + * structures would slip through (issue #116).
+ */ + private boolean isCaveOverworld(World world) { + if (world.getEnvironment() != World.Environment.NORMAL) { + return false; + } + String configuredName = addon.getSettings().getWorldName(); + return configuredName != null && world.getName().equalsIgnoreCase(configuredName); + } + /** * @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..161ff20 100644 --- a/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java +++ b/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java @@ -1,12 +1,18 @@ 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.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.bukkit.NamespacedKey; import org.bukkit.World; @@ -17,9 +23,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; @@ -44,7 +52,8 @@ public void setUp() { lenient().when(addon.getSettings()).thenReturn(settings); lenient().when(settings.getGenerateStructures()) .thenReturn(Map.of("ancient_city", false, "trial_chambers", false, "mineshaft", true)); - lenient().when(addon.inWorld(world)).thenReturn(true); + lenient().when(settings.getWorldName()).thenReturn("caveblock_world"); + lenient().when(world.getName()).thenReturn("caveblock_world"); lenient().when(world.getEnvironment()).thenReturn(World.Environment.NORMAL); listener = new StructureGenerationListener(addon); } @@ -86,7 +95,7 @@ void testUnlistedStructureGeneratesNormally() { @Test void testStructureOutsideCaveBlockWorldIsIgnored() { - when(addon.inWorld(world)).thenReturn(false); + when(world.getName()).thenReturn("some_other_world"); AsyncStructureSpawnEvent e = event("ancient_city"); listener.onStructureSpawn(e); verify(e, never()).setCancelled(true); @@ -107,4 +116,55 @@ void testConfigKeyWithHyphensAndCaseMatches() { listener.onStructureSpawn(e); verify(e).setCancelled(true); } + + private StructuresLocateEvent locateEvent(String... structureKeys) { + // Collect into a mutable list to mirror Paper's StructuresLocateEvent#getStructures(), + // which is documented as mutable; Stream#toList() would return an unmodifiable list. + List