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 @@ ${build.version}-SNAPSHOT - 1.23.0 + 1.23.1 -LOCAL bentobox-world https://sonarcloud.io diff --git a/src/main/java/world/bentobox/caveblock/CaveBlock.java b/src/main/java/world/bentobox/caveblock/CaveBlock.java index 7fa825f..3f813b7 100644 --- a/src/main/java/world/bentobox/caveblock/CaveBlock.java +++ b/src/main/java/world/bentobox/caveblock/CaveBlock.java @@ -70,7 +70,8 @@ public void onEnable() // Register listeners this.registerListener(new CustomHeightLimitations(this)); - this.registerListener(new StructureGenerationListener(this)); + // StructureGenerationListener is registered earlier, in createWorlds(), so it is + // active before the first chunks (including the spawn area) are generated. } @@ -138,6 +139,11 @@ public void allLoaded() { @Override public void createWorlds() { + // Register the structure listener before any world is created. createWorlds() runs + // before onEnable(), and generating a world also generates its spawn-area chunks, so a + // listener registered in onEnable() would miss those first structures (issue #116). + this.registerListener(new StructureGenerationListener(this)); + String worldName = this.settings.getWorldName().toLowerCase(); if (this.getServer().getWorld(worldName) == null) diff --git a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java index 83bc8ca..650768b 100644 --- a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java +++ b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java @@ -1,14 +1,19 @@ package world.bentobox.caveblock.listeners; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; import org.bukkit.World; import org.bukkit.event.EventHandler; 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 +28,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 { @@ -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; + } + List targets = event.getStructures(); + // Collect into a mutable list: StructuresLocateEvent#setStructures expects one + // that Paper (or a later listener) can still mutate. Stream#toList() is immutable. + List allowed = targets.stream() + .filter(structure -> !isDisabled(structure.getKey().getKey())) + .collect(Collectors.toCollection(ArrayList::new)); + 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 world a world + * @return {@code true} if {@code world} is the CaveBlock overworld. + * + *

Matches 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 structures = Arrays.stream(structureKeys).map(key -> { + Structure structure = mock(Structure.class); + lenient().when(structure.getKey()).thenReturn(NamespacedKey.minecraft(key)); + return structure; + }).collect(Collectors.toCollection(ArrayList::new)); + 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(world.getName()).thenReturn("some_other_world"); + StructuresLocateEvent e = locateEvent("trial_chambers"); + listener.onStructuresLocate(e); + verify(e, never()).setCancelled(true); + verify(e, never()).setStructures(anyList()); + } }