From a8150231baa6b635354be8f26291e6c1ffdf4e26 Mon Sep 17 00:00:00 2001 From: tastybento Date: Fri, 24 Jul 2026 08:14:33 -0700 Subject: [PATCH] Auto-suppress structure searches in structureless worlds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cartographer villager levelling up in a void world triggers a synchronous findNearestMapStructure scan on the main thread. Because the world's custom generator never places structures, the search can never succeed and grinds through vanilla noise generation for every candidate position until the watchdog kills the server — on auto-restarting hosts this repeats as soon as the villager's chunk loads again, producing a reboot loop. StructureListener now asks the world's Bukkit ChunkGenerator whether it places structures at all (shouldGenerateStructures). If not, every StructuresLocateEvent in that world is cancelled (or narrowed) with no configuration needed — a search in a structureless world is always the pathological scan-to-the-cap case. A per-world getStructureSettings() entry of true still force-enables a structure, as an escape hatch for converted worlds containing pre-existing structures. Game modes that do generate structures (SkyGrid, Boxed with allow-structures, CaveBlock overworld) report true and are unaffected, so no addon changes are required. Also expands the world.disabled-structures config comments: how to list valid keys in-game (/locate structure tab-completion) and a concrete example disabling the structures that explorer/treasure maps search for (monument, mansion, trial_chambers, buried_treasure). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EDVXb9Lg33agNqUHrs5mzR --- .../world/bentobox/bentobox/Settings.java | 18 +++-- .../bentobox/listeners/StructureListener.java | 51 ++++++++++++-- src/main/resources/config.yml | 18 +++-- .../listeners/StructureListenerTest.java | 68 +++++++++++++++++++ 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 3f1c857f1..6f3982d05 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -535,14 +535,22 @@ public class Settings implements ConfigObject { @ConfigComment("skip in structure searches (/locate, Eyes of Ender, explorer/treasure maps,") @ConfigComment("dolphins and villager cartographer trades). Suppressing a structure this way also") @ConfigComment("prevents those searches scanning to the world border and freezing the server.") - @ConfigComment("Keys may use '-' or '_' and any case, e.g. trial_chambers, ancient-city.") + @ConfigComment("Keys are the vanilla structure ids: tab-complete '/locate structure ' in-game for") + @ConfigComment("the full list ('minecraft:mansion' is listed here as 'mansion'). Keys may use '-'") + @ConfigComment("or '_' and any case, e.g. trial_chambers, ancient-city.") + @ConfigComment("To stop the map searches that most commonly freeze servers with void worlds") + @ConfigComment("(villager cartographer explorer-map trades, dolphins, chest treasure maps),") + @ConfigComment("disable the structures those maps look for:") + @ConfigComment(" disabled-structures:") + @ConfigComment(" - monument") + @ConfigComment(" - mansion") + @ConfigComment(" - trial_chambers") + @ConfigComment(" - buried_treasure") + @ConfigComment("Worlds whose generator places no structures at all (e.g. classic skyblock void") + @ConfigComment("worlds) skip these searches automatically — no entries are needed for them.") @ConfigComment("A game mode can override this per structure in its own config, both to disable") @ConfigComment("more structures and to force-enable one that this list disables.") @ConfigComment("Empty (the default) disables nothing, so behaviour is unchanged.") - @ConfigComment("Example:") - @ConfigComment(" disabled-structures:") - @ConfigComment(" - trial_chambers") - @ConfigComment(" - ancient_city") @ConfigEntry(path = "world.disabled-structures", since = "3.19.1") private List disabledStructures = new ArrayList<>(); diff --git a/src/main/java/world/bentobox/bentobox/listeners/StructureListener.java b/src/main/java/world/bentobox/bentobox/listeners/StructureListener.java index a929e26cb..5c795fb3c 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/StructureListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/StructureListener.java @@ -3,12 +3,15 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Random; +import org.bukkit.Location; 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.ChunkGenerator; import org.bukkit.generator.structure.Structure; import io.papermc.paper.event.world.StructuresLocateEvent; @@ -41,6 +44,14 @@ * nothing enabled remains — skips that scan entirely. * * + *

Searches are additionally suppressed, with no configuration required, in worlds whose + * {@link ChunkGenerator} reports {@link ChunkGenerator#shouldGenerateStructures(org.bukkit.generator.WorldInfo, Random, int, int) + * shouldGenerateStructures} {@code false} (e.g. void skyblock worlds): a world that never + * places structures can never satisfy a search, so every search there is the pathological + * scan-to-the-cap case. A per-world {@link WorldSettings#getStructureSettings()} entry of + * {@code true} still force-enables searching for that structure — the escape hatch for + * converted worlds that contain structures generated before the game mode took over.

+ * *

One instance is registered per {@link GameModeAddon}, immediately before its worlds are * created, so it is active for the initial spawn-area generation. Worlds are matched by * configured name rather than {@link world.bentobox.bentobox.managers.IslandWorldManager#inWorld(World)} @@ -90,9 +101,10 @@ public void onStructuresLocate(StructuresLocateEvent event) { if (!isGameModeWorld(event.getWorld())) { return; } + boolean structurelessWorld = !generatesStructures(event.getWorld(), event.getOrigin()); List targets = event.getStructures(); List allowed = targets.stream() - .filter(structure -> !isDisabled(structure.getKey().getKey())) + .filter(structure -> !isSuppressed(structure.getKey().getKey(), structurelessWorld)) .toList(); if (allowed.size() == targets.size()) { // Nothing disabled in this search — let it run normally. @@ -135,6 +147,18 @@ private boolean isGameModeWorld(World world) { * @return {@code true} if this structure should be suppressed */ private boolean isDisabled(String structureKey) { + return isSuppressed(structureKey, false); + } + + /** + * As {@link #isDisabled(String)}, with one extra final layer: in a world whose generator + * never places structures, every structure not explicitly force-enabled is suppressed. + * + * @param structureKey the vanilla structure key path, e.g. {@code ancient_city} + * @param structurelessWorld whether the world's generator reports it never places structures + * @return {@code true} if this structure should be suppressed + */ + private boolean isSuppressed(String structureKey, boolean structurelessWorld) { String normalizedKey = normalize(structureKey); // Per-world override wins: value is whether the structure should generate. Map overrides = gameMode.getWorldSettings().getStructureSettings(); @@ -145,9 +169,28 @@ private boolean isDisabled(String structureKey) { } } } - // Otherwise fall back to the global default list. - return plugin.getSettings().getDisabledStructures().stream() - .anyMatch(key -> normalize(key).equals(normalizedKey)); + // Then the global default list. + if (plugin.getSettings().getDisabledStructures().stream() + .anyMatch(key -> normalize(key).equals(normalizedKey))) { + return true; + } + // Finally, a world that never places structures can never satisfy a search. + return structurelessWorld; + } + + /** + * @param world a game mode world + * @param origin the origin of the structure search + * @return {@code true} if {@code world}'s generator places vanilla structures. A world + * with no custom generator is vanilla-generated, so structures are assumed. + */ + private boolean generatesStructures(World world, Location origin) { + ChunkGenerator generator = world.getGenerator(); + if (generator == null) { + return true; + } + return generator.shouldGenerateStructures(world, new Random(world.getSeed()), + origin.getBlockX() >> 4, origin.getBlockZ() >> 4); } private String normalize(String key) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b3f1f02f1..4280faa7a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -320,14 +320,22 @@ world: # skip in structure searches (/locate, Eyes of Ender, explorer/treasure maps, # dolphins and villager cartographer trades). Suppressing a structure this way also # prevents those searches scanning to the world border and freezing the server. - # Keys may use '-' or '_' and any case, e.g. trial_chambers, ancient-city. + # Keys are the vanilla structure ids: tab-complete '/locate structure ' in-game for + # the full list ('minecraft:mansion' is listed here as 'mansion'). Keys may use '-' + # or '_' and any case, e.g. trial_chambers, ancient-city. + # To stop the map searches that most commonly freeze servers with void worlds + # (villager cartographer explorer-map trades, dolphins, chest treasure maps), + # disable the structures those maps look for: + # disabled-structures: + # - monument + # - mansion + # - trial_chambers + # - buried_treasure + # Worlds whose generator places no structures at all (e.g. classic skyblock void + # worlds) skip these searches automatically — no entries are needed for them. # A game mode can override this per structure in its own config, both to disable # more structures and to force-enable one that this list disables. # Empty (the default) disables nothing, so behaviour is unchanged. - # Example: - # disabled-structures: - # - trial_chambers - # - ancient_city # Added since 3.19.1. disabled-structures: [] web: diff --git a/src/test/java/world/bentobox/bentobox/listeners/StructureListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/StructureListenerTest.java index 84fbe4fe5..e28243f85 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/StructureListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/StructureListenerTest.java @@ -8,13 +8,20 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; + import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Random; +import org.bukkit.Location; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.event.world.AsyncStructureSpawnEvent; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; import org.bukkit.generator.structure.Structure; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -89,9 +96,21 @@ private StructuresLocateEvent locateEvent(String... structureKeys) { StructuresLocateEvent e = mock(StructuresLocateEvent.class); lenient().when(e.getWorld()).thenReturn(world); lenient().when(e.getStructures()).thenReturn(structures); + lenient().when(e.getOrigin()).thenReturn(mock(Location.class)); return e; } + /** + * Gives the test world a custom generator whose {@code shouldGenerateStructures} + * reports {@code generates}. + */ + private void withGenerator(boolean generates) { + ChunkGenerator generator = mock(ChunkGenerator.class); + lenient().when(generator.shouldGenerateStructures(any(WorldInfo.class), any(Random.class), anyInt(), anyInt())) + .thenReturn(generates); + lenient().when(world.getGenerator()).thenReturn(generator); + } + // --- Spawn suppression ------------------------------------------------- @Test @@ -180,4 +199,53 @@ void testLocateOutsideGameModeWorldIsIgnored() { verify(e, never()).setCancelled(true); verify(e, never()).setStructures(anyList()); } + + // --- Structureless-world locate suppression ---------------------------- + + @Test + void testLocateInStructurelessWorldIsCancelledWithoutConfig() { + // Generator never places structures — even structures on no disabled list + // (e.g. a cartographer map's monument search) must not be scanned for. + withGenerator(false); + StructuresLocateEvent e = locateEvent("monument", "mansion"); + listener.onStructuresLocate(e); + verify(e).setCancelled(true); + verify(e, never()).setStructures(anyList()); + } + + @Test + void testLocateInStructurelessWorldKeepsForceEnabledStructure() { + // ancient_city is force-enabled by the per-world override, so it survives + // even though the world generates no structures. + withGenerator(false); + StructuresLocateEvent e = locateEvent("monument", "ancient_city"); + 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("ancient_city", captor.getValue().get(0).getKey().getKey()); + } + + @Test + void testLocateWithStructureGeneratingGeneratorIsUntouched() { + // Generator places structures (e.g. SkyGrid, Boxed with allow-structures) — + // unlisted structures search normally. + withGenerator(true); + StructuresLocateEvent e = locateEvent("monument", "village_plains"); + listener.onStructuresLocate(e); + verify(e, never()).setCancelled(true); + verify(e, never()).setStructures(anyList()); + } + + @Test + void testStructurelessWorldDoesNotAffectSpawnSuppression() { + // The generator layer applies to searches only; spawn events for enabled + // structures pass through (they cannot fire in a structureless world anyway). + withGenerator(false); + AsyncStructureSpawnEvent e = spawnEvent("village_plains"); + listener.onStructureSpawn(e); + verify(e, never()).setCancelled(true); + } }