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); + } }