From 33495500416532c33c30d8c3738c3e7dd46bd69c Mon Sep 17 00:00:00 2001
From: tastybento
Date: Thu, 9 Jul 2026 07:20:50 -0700
Subject: [PATCH] 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());
+ }
}