From 04fe9280840a714a1b8fb5b792ce3c51efddcadf Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Jul 2026 07:43:42 -0700 Subject: [PATCH 1/2] Add core structure suppression for all game modes (#116/#117/#118) Ports the CaveBlock structure-suppression fix into core BentoBox so every game mode with vanilla-generated worlds (Boxed, SkyGrid, CaveBlock, ...) can disable structures without the /locate freeze and spawn-area leak. Two config layers: - Global default: world.disabled-structures in config.yml (Settings), applied to every game mode world. Empty by default, so behaviour is unchanged. - Per-world override: new WorldSettings.getStructureSettings() default method (binary-compatible) letting a game mode disable more structures or force- enable one the global list disables. StructureListener handles both halves: - AsyncStructureSpawnEvent -> stops the structure being placed - StructuresLocateEvent -> narrows/cancels searches (/locate, Eyes of Ender, explorer/treasure maps, dolphins, cartographer trades) so they no longer scan to the border and freeze the main thread (#117) One listener is registered per GameModeAddon in AddonsManager, immediately before createWorlds(), so it is active for the initial spawn-area generation (#118). Worlds are matched by configured name (overworld/nether/end) because they are not yet registered with the world manager during createWorlds(). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Jbkc3n7vnNbtMx3s9dbkbc --- .../world/bentobox/bentobox/Settings.java | 34 ++++ .../api/configuration/WorldSettings.java | 25 +++ .../bentobox/listeners/StructureListener.java | 156 +++++++++++++++ .../bentobox/managers/AddonsManager.java | 6 + src/main/resources/config.yml | 16 ++ .../listeners/StructureListenerTest.java | 183 ++++++++++++++++++ 6 files changed, 420 insertions(+) create mode 100644 src/main/java/world/bentobox/bentobox/listeners/StructureListener.java create mode 100644 src/test/java/world/bentobox/bentobox/listeners/StructureListenerTest.java diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 2bb9a1489..6426ad264 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -492,6 +492,23 @@ public class Settings implements ConfigObject { @ConfigEntry(path = "island.obsidian-scooping-lava-tip-duration", since = "3.14.0") private int obsidianScoopingLavaTipDuration = 5; + /* WORLD */ + @ConfigComment("Vanilla structures disabled by default in EVERY BentoBox game mode world") + @ConfigComment("(overworld, nether and end). List the structure keys to stop generating and to") + @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("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<>(); + /* WEB */ @ConfigComment("Toggle whether BentoBox can connect to GitHub to get data about updates and addons.") @ConfigComment("Disabling this will result in the deactivation of the update checker and of some other") @@ -1753,4 +1770,21 @@ public void setIslandPurgeLevel(int islandPurgeLevel) { this.islandPurgeLevel = islandPurgeLevel; } + /** + * Vanilla structures disabled by default in every BentoBox game mode world. + * @return the list of disabled structure keys, never {@code null} + * @since 3.19.1 + */ + public List getDisabledStructures() { + return disabledStructures == null ? new ArrayList<>() : disabledStructures; + } + + /** + * @param disabledStructures the disabledStructures to set + * @since 3.19.1 + */ + public void setDisabledStructures(List disabledStructures) { + this.disabledStructures = disabledStructures; + } + } diff --git a/src/main/java/world/bentobox/bentobox/api/configuration/WorldSettings.java b/src/main/java/world/bentobox/bentobox/api/configuration/WorldSettings.java index 56b405d76..7bc423d86 100644 --- a/src/main/java/world/bentobox/bentobox/api/configuration/WorldSettings.java +++ b/src/main/java/world/bentobox/bentobox/api/configuration/WorldSettings.java @@ -697,4 +697,29 @@ default boolean isDisallowTeamMemberIslands() { default boolean isTeamsDisabled() { return false; } + + /** + * Per-world override for vanilla structure generation and searching. + *

+ * The key is a structure name, e.g. {@code "trial_chambers"} or {@code "ancient_city"} + * (with {@code '-'} or {@code '_'} separators and any case). The value is whether that + * structure should generate: + *

    + *
  • {@code false} — disable the structure in this world: it will not generate and is + * removed from structure searches ({@code /locate}, Eyes of Ender, explorer/treasure + * maps, dolphins, villager cartographer trades), which also stops those searches + * scanning to the world border and freezing the server.
  • + *
  • {@code true} — force the structure on in this world even if BentoBox's global + * {@code world.disabled-structures} list disables it.
  • + *
+ * A structure that is absent from this map inherits the global + * {@code world.disabled-structures} setting. The default is an empty map, so a game mode + * that does not override this method inherits BentoBox's global configuration entirely. + * + * @return a map of structure key to whether it should generate, never {@code null} + * @since 3.19.1 + */ + default Map getStructureSettings() { + return Collections.emptyMap(); + } } diff --git a/src/main/java/world/bentobox/bentobox/listeners/StructureListener.java b/src/main/java/world/bentobox/bentobox/listeners/StructureListener.java new file mode 100644 index 000000000..a929e26cb --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/listeners/StructureListener.java @@ -0,0 +1,156 @@ +package world.bentobox.bentobox.listeners; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +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.bentobox.BentoBox; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.configuration.WorldSettings; + +/** + * Suppresses vanilla structures in a game mode's worlds, both when they would be + * placed and when they are searched for. + * + *

Game modes whose worlds delegate to vanilla generation (e.g. Boxed, SkyGrid, + * CaveBlock) can generate structures that fill or unbalance an island world. The + * {@link org.bukkit.generator.ChunkGenerator} flag can only turn all structures on or + * off, so this listener provides per-structure control driven by BentoBox's global + * {@code world.disabled-structures} list and each world's + * {@link WorldSettings#getStructureSettings()} override.

+ * + *

Suppression has two halves:

+ *
    + *
  • {@link AsyncStructureSpawnEvent} — stops a disabled structure being placed during + * chunk generation. It fires off the main thread, so this handler only reads config + * and inspects the event; it performs no world or block mutation, which is what makes + * it safe to run async.
  • + *
  • {@link StructuresLocateEvent} — fires before any structure search ({@code /locate}, + * Eyes of Ender, explorer/treasure maps, dolphins, villager cartographer trades). + * Cancelling the spawn alone leaves the world's placement rules intact, so a search + * for a suppressed structure never succeeds and scans out to the radius cap, freezing + * the main thread. Removing disabled structures from the search — and cancelling when + * nothing enabled remains — skips that scan entirely.
  • + *
+ * + *

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)} + * because the spawn chunks generate during {@code createWorlds()}, before the game mode's + * worlds are registered with the world manager.

+ * + * @author tastybento + * @since 3.19.1 + */ +public class StructureListener implements Listener { + + private final BentoBox plugin; + private final GameModeAddon gameMode; + + /** + * @param plugin BentoBox instance + * @param gameMode the game mode whose worlds this listener guards + */ + public StructureListener(BentoBox plugin, GameModeAddon gameMode) { + this.plugin = plugin; + this.gameMode = gameMode; + } + + /** + * Cancels the spawn of a disabled structure in one of this game mode's worlds. + * + * @param event the structure spawn event + */ + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onStructureSpawn(AsyncStructureSpawnEvent event) { + if (!isGameModeWorld(event.getWorld())) { + return; + } + if (isDisabled(event.getStructure().getKey().getKey())) { + event.setCancelled(true); + } + } + + /** + * Removes disabled structures from a structure search in one of this game mode's worlds, + * cancelling outright when nothing enabled remains so the expensive scan is skipped. + * + * @param event the structure locate event + */ + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onStructuresLocate(StructuresLocateEvent event) { + if (!isGameModeWorld(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 world a world + * @return {@code true} if {@code world} is this game mode's overworld, nether or end. + * + *

Matches on the configured world name rather than the world manager because the + * spawn-area chunks generate during {@code createWorlds()}, before the game mode's worlds + * are registered. At that point a world-manager lookup would not yet know the world and + * the first structures would slip through.

+ */ + private boolean isGameModeWorld(World world) { + String base = gameMode.getWorldSettings().getWorldName(); + if (base == null) { + return false; + } + String name = world.getName(); + return name.equalsIgnoreCase(base) || name.equalsIgnoreCase(base + "_nether") + || name.equalsIgnoreCase(base + "_the_end"); + } + + /** + * Decides whether a structure is disabled in this game mode. A per-world entry in + * {@link WorldSettings#getStructureSettings()} always wins ({@code false} disables, + * {@code true} force-enables); otherwise the global {@code world.disabled-structures} + * list applies. + * + * @param structureKey the vanilla structure key path, e.g. {@code ancient_city} + * @return {@code true} if this structure should be suppressed + */ + private boolean isDisabled(String structureKey) { + String normalizedKey = normalize(structureKey); + // Per-world override wins: value is whether the structure should generate. + Map overrides = gameMode.getWorldSettings().getStructureSettings(); + if (overrides != null) { + for (Map.Entry entry : overrides.entrySet()) { + if (normalize(entry.getKey()).equals(normalizedKey)) { + return Boolean.FALSE.equals(entry.getValue()); + } + } + } + // Otherwise fall back to the global default list. + return plugin.getSettings().getDisabledStructures().stream() + .anyMatch(key -> normalize(key).equals(normalizedKey)); + } + + private String normalize(String key) { + return key.toLowerCase(Locale.ROOT).replace('-', '_'); + } +} diff --git a/src/main/java/world/bentobox/bentobox/managers/AddonsManager.java b/src/main/java/world/bentobox/bentobox/managers/AddonsManager.java index 3634a3af0..9133d8ec1 100644 --- a/src/main/java/world/bentobox/bentobox/managers/AddonsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/AddonsManager.java @@ -52,6 +52,7 @@ import world.bentobox.bentobox.api.events.addon.AddonEvent; import world.bentobox.bentobox.commands.BentoBoxCommand; import world.bentobox.bentobox.database.objects.DataObject; +import world.bentobox.bentobox.listeners.StructureListener; import world.bentobox.bentobox.util.Util; /** @@ -329,6 +330,11 @@ private void enableAddon(Addon addon) { try { // If this is a GameModeAddon create the worlds, register it and load the blueprints if (addon instanceof GameModeAddon gameMode) { + // Suppress disabled structures before the worlds (and their spawn-area chunks) + // are generated. createWorlds() generates the spawn chunks, so a listener + // registered any later would miss those first structures. + plugin.getServer().getPluginManager().registerEvents(new StructureListener(plugin, gameMode), + plugin); // Create the gameWorlds gameMode.createWorlds(); plugin.getIWM().addGameMode(gameMode); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4c59dc684..929b8f0d2 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -284,6 +284,22 @@ island: # This value is also used for valid nether portal linking between dimension. # Added since 1.21.0. safe-spot-search-range: 16 +world: + # Vanilla structures disabled by default in EVERY BentoBox game mode world + # (overworld, nether and end). List the structure keys to stop generating and to + # 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. + # 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: github: # Toggle whether BentoBox can connect to GitHub to get data about updates and addons. diff --git a/src/test/java/world/bentobox/bentobox/listeners/StructureListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/StructureListenerTest.java new file mode 100644 index 000000000..84fbe4fe5 --- /dev/null +++ b/src/test/java/world/bentobox/bentobox/listeners/StructureListenerTest.java @@ -0,0 +1,183 @@ +package world.bentobox.bentobox.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; +import org.bukkit.World; +import org.bukkit.event.world.AsyncStructureSpawnEvent; +import org.bukkit.generator.structure.Structure; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.bentobox.BentoBox; +import world.bentobox.bentobox.Settings; +import world.bentobox.bentobox.api.addons.GameModeAddon; +import world.bentobox.bentobox.api.configuration.WorldSettings; + +/** + * Tests for {@link StructureListener}. + */ +@ExtendWith(MockitoExtension.class) +class StructureListenerTest { + + @Mock + private BentoBox plugin; + @Mock + private Settings settings; + @Mock + private GameModeAddon gameMode; + @Mock + private WorldSettings worldSettings; + @Mock + private World world; + + private StructureListener listener; + + @BeforeEach + public void setUp() { + MockBukkit.mock(); + lenient().when(plugin.getSettings()).thenReturn(settings); + // Global default disables ancient_city and trial_chambers everywhere. + lenient().when(settings.getDisabledStructures()) + .thenReturn(List.of("ancient_city", "trial-chambers")); + lenient().when(gameMode.getWorldSettings()).thenReturn(worldSettings); + lenient().when(worldSettings.getWorldName()).thenReturn("bskyblock_world"); + // Per-world override: force-enable ancient_city, and disable mineshaft (not in global). + lenient().when(worldSettings.getStructureSettings()) + .thenReturn(Map.of("ancient_city", true, "mineshaft", false)); + lenient().when(world.getName()).thenReturn("bskyblock_world"); + listener = new StructureListener(plugin, gameMode); + } + + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + private AsyncStructureSpawnEvent spawnEvent(String structureKey) { + Structure structure = mock(Structure.class); + lenient().when(structure.getKey()).thenReturn(NamespacedKey.minecraft(structureKey)); + AsyncStructureSpawnEvent e = mock(AsyncStructureSpawnEvent.class); + lenient().when(e.getWorld()).thenReturn(world); + lenient().when(e.getStructure()).thenReturn(structure); + return e; + } + + 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; + } + + // --- Spawn suppression ------------------------------------------------- + + @Test + void testGlobalDisabledStructureIsCancelled() { + // trial_chambers is disabled by the global list (note hyphen/underscore normalisation). + AsyncStructureSpawnEvent e = spawnEvent("trial_chambers"); + listener.onStructureSpawn(e); + verify(e).setCancelled(true); + } + + @Test + void testPerWorldOverrideDisablesStructureNotInGlobalList() { + AsyncStructureSpawnEvent e = spawnEvent("mineshaft"); + listener.onStructureSpawn(e); + verify(e).setCancelled(true); + } + + @Test + void testPerWorldOverrideForceEnablesGloballyDisabledStructure() { + // Global disables ancient_city, but this world's override sets it true — override wins. + AsyncStructureSpawnEvent e = spawnEvent("ancient_city"); + listener.onStructureSpawn(e); + verify(e, never()).setCancelled(true); + } + + @Test + void testUnlistedStructureGeneratesNormally() { + AsyncStructureSpawnEvent e = spawnEvent("village_plains"); + listener.onStructureSpawn(e); + verify(e, never()).setCancelled(true); + } + + @Test + void testSpawnInNetherWorldIsMatchedByName() { + when(world.getName()).thenReturn("bskyblock_world_nether"); + AsyncStructureSpawnEvent e = spawnEvent("trial_chambers"); + listener.onStructureSpawn(e); + verify(e).setCancelled(true); + } + + @Test + void testSpawnOutsideGameModeWorldIsIgnored() { + when(world.getName()).thenReturn("some_other_world"); + AsyncStructureSpawnEvent e = spawnEvent("trial_chambers"); + listener.onStructureSpawn(e); + verify(e, never()).setCancelled(true); + } + + // --- Locate suppression ------------------------------------------------ + + @Test + void testLocateAllDisabledIsCancelled() { + StructuresLocateEvent e = locateEvent("trial_chambers"); + listener.onStructuresLocate(e); + verify(e).setCancelled(true); + verify(e, never()).setStructures(anyList()); + } + + @Test + void testLocateMixedNarrowsToEnabledOnly() { + // trial_chambers disabled (global), mineshaft disabled (override) — only ancient_city + // (override-enabled) survives. + StructuresLocateEvent e = locateEvent("trial_chambers", "mineshaft", "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 testLocateAllEnabledIsUntouched() { + StructuresLocateEvent e = locateEvent("village_plains", "ancient_city"); + listener.onStructuresLocate(e); + verify(e, never()).setCancelled(true); + verify(e, never()).setStructures(anyList()); + } + + @Test + void testLocateOutsideGameModeWorldIsIgnored() { + 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()); + } +} From 2d6199054a08db354fc7b054b1d4477f7d99b12b Mon Sep 17 00:00:00 2001 From: tastybento Date: Thu, 9 Jul 2026 07:51:11 -0700 Subject: [PATCH 2/2] Register StructureListener via registerListener for cleanup (Copilot) Use AddonsManager.registerListener(addon, listener) instead of a raw PluginManager.registerEvents call so the listener is tracked in the listeners map and unregistered when the game mode addon is disabled or unloaded. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Jbkc3n7vnNbtMx3s9dbkbc --- .../world/bentobox/bentobox/managers/AddonsManager.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/managers/AddonsManager.java b/src/main/java/world/bentobox/bentobox/managers/AddonsManager.java index 9133d8ec1..a2047a4d3 100644 --- a/src/main/java/world/bentobox/bentobox/managers/AddonsManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/AddonsManager.java @@ -332,9 +332,9 @@ private void enableAddon(Addon addon) { if (addon instanceof GameModeAddon gameMode) { // Suppress disabled structures before the worlds (and their spawn-area chunks) // are generated. createWorlds() generates the spawn chunks, so a listener - // registered any later would miss those first structures. - plugin.getServer().getPluginManager().registerEvents(new StructureListener(plugin, gameMode), - plugin); + // registered any later would miss those first structures. Registered through + // registerListener so it is tracked and unregistered when the addon is disabled. + registerListener(gameMode, new StructureListener(plugin, gameMode)); // Create the gameWorlds gameMode.createWorlds(); plugin.getIWM().addGameMode(gameMode);