From 33495500416532c33c30d8c3738c3e7dd46bd69c Mon Sep 17 00:00:00 2001
From: tastybento
Date: Thu, 9 Jul 2026 07:20:50 -0700
Subject: [PATCH 1/4] Stop /locate freezing the server for disabled structures
(#116)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Disabling a structure in `world.structures` cancels its
AsyncStructureSpawnEvent so it never gets placed, but leaves the world's
structure placement rules untouched. Every structure search — the
/locate command, Eyes of Ender, explorer/treasure maps, dolphins and
villager map trades — therefore keeps proposing candidate positions that
are all cancelled, so the search can never succeed and scans out to the
radius cap. On a test world, `/locate structure minecraft:trial_chambers`
generated ~1,500 cancelled structure starts and froze the main thread for
62 seconds, tripping the Watchdog.
Handle Paper's StructuresLocateEvent (fired before any structure search)
to remove disabled structures from the search list, and cancel outright
when nothing remains, so the search returns "not found" instantly instead
of scanning. Enabled structures and searches in other worlds are
untouched.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_015GBGsNFUshypm5Sj8KQsEa
---
.../StructureGenerationListener.java | 44 ++++++++++++++-
.../StructureGenerationListenerTest.java | 55 +++++++++++++++++++
2 files changed, 98 insertions(+), 1 deletion(-)
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());
+ }
}
From 25ca8eae488aa570e09f1c7b17875bcc3c2173e6 Mon Sep 17 00:00:00 2001
From: tastybento
Date: Thu, 9 Jul 2026 07:30:30 -0700
Subject: [PATCH 2/4] Suppress structures in the spawn area too (#116)
The structure listener was registered in onEnable(), but createWorlds()
runs before onEnable() and generating a world also generates its
spawn-area chunks. Those first structures were therefore placed before
the listener existed, so a disabled structure could still appear near
spawn.
Register the listener at the start of createWorlds(), before any world is
created. Because createWorlds() assigns islandWorld from the return value
of createWorld(), the island-world reference is still null while the
spawn chunks generate, so inWorld() would compare against a null world
and throw. Match on the configured world name instead via a new
isCaveOverworld() helper, used by both handlers.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_015GBGsNFUshypm5Sj8KQsEa
---
.../world/bentobox/caveblock/CaveBlock.java | 8 ++++++-
.../StructureGenerationListener.java | 23 ++++++++++++++++---
.../StructureGenerationListenerTest.java | 7 +++---
3 files changed, 31 insertions(+), 7 deletions(-)
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 4d7adc3..0a51aba 100644
--- a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
+++ b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
@@ -57,9 +57,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();
@@ -81,7 +80,7 @@ public void onStructureSpawn(AsyncStructureSpawnEvent event) {
*/
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
public void onStructuresLocate(StructuresLocateEvent event) {
- if (!addon.inWorld(event.getWorld())) {
+ if (!isCaveOverworld(event.getWorld())) {
return;
}
List targets = event.getStructures();
@@ -100,6 +99,24 @@ public void onStructuresLocate(StructuresLocateEvent event) {
}
}
+ /**
+ * @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 417070c..1642dbe 100644
--- a/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java
+++ b/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java
@@ -50,7 +50,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);
}
@@ -92,7 +93,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);
@@ -156,7 +157,7 @@ void testLocateAllEnabledIsUntouched() {
@Test
void testLocateOutsideCaveBlockWorldIsIgnored() {
- when(addon.inWorld(world)).thenReturn(false);
+ when(world.getName()).thenReturn("some_other_world");
StructuresLocateEvent e = locateEvent("trial_chambers");
listener.onStructuresLocate(e);
verify(e, never()).setCancelled(true);
From d67f23703bad28b57230d1bd30a78440bee7115e Mon Sep 17 00:00:00 2001
From: tastybento
Date: Thu, 9 Jul 2026 07:37:22 -0700
Subject: [PATCH 3/4] Bump build version from 1.23.0 to 1.23.1
---
pom.xml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
From 0262a506b262c1810b34b79005d64e51ca0baabd Mon Sep 17 00:00:00 2001
From: tastybento
Date: Thu, 9 Jul 2026 07:45:47 -0700
Subject: [PATCH 4/4] Address Copilot review comments on PR #119
- StructureGenerationListener: collect allowed structures into a mutable
ArrayList so StructuresLocateEvent#setStructures() gets a list Paper (or
a later listener) can still mutate; Stream#toList() is unmodifiable.
- Test: drop the redundant Structure cast and build the mock structure list
as a mutable ArrayList, mirroring Paper's mutable getStructures().
- CLAUDE.md: refresh the architecture section for the current generators,
populators, and the new StructureGenerationListener.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_015GBGsNFUshypm5Sj8KQsEa
---
CLAUDE.md | 5 +++--
.../caveblock/listeners/StructureGenerationListener.java | 6 +++++-
.../listeners/StructureGenerationListenerTest.java | 6 +++++-
3 files changed, 13 insertions(+), 4 deletions(-)
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/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
index 0a51aba..650768b 100644
--- a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
+++ b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
@@ -1,8 +1,10 @@
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;
@@ -84,9 +86,11 @@ public void onStructuresLocate(StructuresLocateEvent event) {
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()))
- .toList();
+ .collect(Collectors.toCollection(ArrayList::new));
if (allowed.size() == targets.size()) {
// Nothing disabled in this search — let it run normally.
return;
diff --git a/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java b/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java
index 1642dbe..161ff20 100644
--- a/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java
+++ b/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java
@@ -8,9 +8,11 @@
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;
@@ -116,11 +118,13 @@ void testConfigKeyWithHyphensAndCaseMatches() {
}
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;
- }).map(Structure.class::cast).toList();
+ }).collect(Collectors.toCollection(ArrayList::new));
StructuresLocateEvent e = mock(StructuresLocateEvent.class);
lenient().when(e.getWorld()).thenReturn(world);
lenient().when(e.getStructures()).thenReturn(structures);