diff --git a/pom.xml b/pom.xml index be0a210..688d48d 100644 --- a/pom.xml +++ b/pom.xml @@ -50,7 +50,7 @@ ${build.version}-SNAPSHOT - 1.22.0 + 1.23.0 -LOCAL bentobox-world https://sonarcloud.io diff --git a/src/main/java/world/bentobox/caveblock/CaveBlock.java b/src/main/java/world/bentobox/caveblock/CaveBlock.java index 22dacc5..7fa825f 100644 --- a/src/main/java/world/bentobox/caveblock/CaveBlock.java +++ b/src/main/java/world/bentobox/caveblock/CaveBlock.java @@ -19,6 +19,7 @@ import world.bentobox.caveblock.commands.IslandAboutCommand; import world.bentobox.caveblock.generators.ChunkGeneratorWorld; import world.bentobox.caveblock.listeners.CustomHeightLimitations; +import world.bentobox.caveblock.listeners.StructureGenerationListener; public class CaveBlock extends GameModeAddon @@ -67,8 +68,9 @@ public void onEnable() CaveBlock.SKY_WALKER_FLAG.addGameModeAddon(this); this.getPlugin().getFlagsManager().registerFlag(CaveBlock.SKY_WALKER_FLAG); - // Register listener + // Register listeners this.registerListener(new CustomHeightLimitations(this)); + this.registerListener(new StructureGenerationListener(this)); } diff --git a/src/main/java/world/bentobox/caveblock/Settings.java b/src/main/java/world/bentobox/caveblock/Settings.java index 1891a3d..f0f88ee 100644 --- a/src/main/java/world/bentobox/caveblock/Settings.java +++ b/src/main/java/world/bentobox/caveblock/Settings.java @@ -5,6 +5,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -159,6 +160,18 @@ public class Settings implements WorldSettings @ConfigEntry(path = "world.world-depth", needsReset = true) private int worldDepth = 319; + @ConfigComment("") + @ConfigComment("Vanilla structures that may generate in the overworld cave world.") + @ConfigComment("Set a structure to false to stop it generating; structures not listed here") + @ConfigComment("generate as normal. Use the vanilla structure key, for example:") + @ConfigComment(" ancient_city, trial_chambers, mineshaft, mineshaft_mesa, stronghold,") + @ConfigComment(" mansion, monument, pillager_outpost, ruined_portal, trail_ruins,") + @ConfigComment(" village_plains, desert_pyramid, jungle_pyramid, igloo, swamp_hut") + @ConfigComment("Large structures like Ancient Cities and Trial Chambers can fill or unbalance") + @ConfigComment("a cave world, so they are disabled by default. Only affects the overworld.") + @ConfigEntry(path = "world.structures", since = "1.23.0") + private Map generateStructures = defaultStructures(); + @ConfigComment("") @ConfigComment("Make over world roof of bedrock, if false, it will be made from stone.") @ConfigEntry(path = "world.normal.roof", needsReset = true) @@ -1197,6 +1210,46 @@ public int getWorldDepth() } + /** + * Map of vanilla structure key to whether it may generate in the overworld. + * A structure explicitly mapped to {@code false} is prevented from generating; + * any structure not present in the map generates normally. + * @return the structure generation map. + */ + public Map getGenerateStructures() + { + return generateStructures; + } + + + /** + * Sets the structure generation map. + * @param generateStructures the structure generation map. + */ + public void setGenerateStructures(Map generateStructures) + { + this.generateStructures = generateStructures; + } + + + /** + * Default structure toggles. Large, disruptive structures that tend to fill or + * unbalance a cave world are disabled; the common smaller ones are left on so + * the entry is self-documenting. + * @return an ordered map of structure key to whether it generates. + */ + private static Map defaultStructures() + { + Map map = new LinkedHashMap<>(); + map.put("ancient_city", false); + map.put("trial_chambers", false); + map.put("mansion", false); + map.put("mineshaft", true); + map.put("stronghold", true); + return map; + } + + /** * This method returns the normalRoof value. * @return the value of normalRoof. diff --git a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java new file mode 100644 index 0000000..29b974c --- /dev/null +++ b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java @@ -0,0 +1,81 @@ +package world.bentobox.caveblock.listeners; + +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 world.bentobox.caveblock.CaveBlock; + +/** + * Prevents configured vanilla structures from generating in the CaveBlock + * overworld. + * + *

The overworld delegates to vanilla generation, which includes structures + * such as Ancient Cities, Trial Chambers and Strongholds. Some of these fill or + * unbalance a cave world (see issue #112). The {@link org.bukkit.generator.ChunkGenerator} + * flag can only turn all structures on or off, so this listener provides + * per-structure control by cancelling the spawn of any structure the admin has + * 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 + * performs no Bukkit world access.

+ * + * @author tastybento + */ +public class StructureGenerationListener implements Listener { + + private final CaveBlock addon; + + /** + * @param addon CaveBlock addon + */ + public StructureGenerationListener(CaveBlock addon) { + this.addon = addon; + } + + /** + * Cancels the spawn of a disabled structure in the CaveBlock overworld. + * + * @param event the structure spawn event + */ + @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)) { + return; + } + String structureKey = event.getStructure().getKey().getKey(); + if (isDisabled(structureKey)) { + event.setCancelled(true); + } + } + + /** + * @param structureKey the vanilla structure key path, e.g. {@code ancient_city} + * @return {@code true} if the config explicitly disables this structure + */ + private boolean isDisabled(String structureKey) { + Map structures = addon.getSettings().getGenerateStructures(); + if (structures == null || structures.isEmpty()) { + return false; + } + for (Map.Entry entry : structures.entrySet()) { + // Accept hyphen or underscore separators and any casing. + if (normalize(entry.getKey()).equals(structureKey) && Boolean.FALSE.equals(entry.getValue())) { + return true; + } + } + return false; + } + + private String normalize(String key) { + return key.toLowerCase(Locale.ROOT).replace('-', '_'); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 5c36969..150d0a6 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -91,6 +91,22 @@ world: # Should not be less than cave height. # /!\ BentoBox currently does not support changing this value mid-game. If you do need to change it, do a full reset of your databases and worlds. world-depth: 319 + # + # Vanilla structures that may generate in the overworld cave world. + # Set a structure to false to stop it generating; structures not listed here + # generate as normal. Use the vanilla structure key, for example: + # ancient_city, trial_chambers, mineshaft, mineshaft_mesa, stronghold, + # mansion, monument, pillager_outpost, ruined_portal, trail_ruins, + # village_plains, desert_pyramid, jungle_pyramid, igloo, swamp_hut + # Large structures like Ancient Cities and Trial Chambers can fill or unbalance + # a cave world, so they are disabled by default. Only affects the overworld. + # Added since 1.23.0. + structures: + ancient_city: false + trial_chambers: false + mansion: false + mineshaft: true + stronghold: true normal: # Make over world roof of bedrock. If false, it will be made from the main block (stone). # /!\ BentoBox currently does not support changing this value mid-game. If you do need to change it, do a full reset of your databases and worlds. diff --git a/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java b/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java new file mode 100644 index 0000000..e61c3f4 --- /dev/null +++ b/src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java @@ -0,0 +1,110 @@ +package world.bentobox.caveblock.listeners; + +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.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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import world.bentobox.caveblock.CaveBlock; +import world.bentobox.caveblock.Settings; + +/** + * Tests for {@link StructureGenerationListener}. + */ +@ExtendWith(MockitoExtension.class) +class StructureGenerationListenerTest { + + @Mock + private CaveBlock addon; + @Mock + private Settings settings; + @Mock + private World world; + + private StructureGenerationListener listener; + + @BeforeEach + public void setUp() { + MockBukkit.mock(); + 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(world.getEnvironment()).thenReturn(World.Environment.NORMAL); + listener = new StructureGenerationListener(addon); + } + + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + private AsyncStructureSpawnEvent event(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; + } + + @Test + void testDisabledStructureIsCancelled() { + AsyncStructureSpawnEvent e = event("ancient_city"); + listener.onStructureSpawn(e); + verify(e).setCancelled(true); + } + + @Test + void testEnabledStructureIsNotCancelled() { + AsyncStructureSpawnEvent e = event("mineshaft"); + listener.onStructureSpawn(e); + verify(e, never()).setCancelled(true); + } + + @Test + void testUnlistedStructureGeneratesNormally() { + AsyncStructureSpawnEvent e = event("village_plains"); + listener.onStructureSpawn(e); + verify(e, never()).setCancelled(true); + } + + @Test + void testStructureOutsideCaveBlockWorldIsIgnored() { + when(addon.inWorld(world)).thenReturn(false); + AsyncStructureSpawnEvent e = event("ancient_city"); + listener.onStructureSpawn(e); + verify(e, never()).setCancelled(true); + } + + @Test + void testNetherEnvironmentIsIgnored() { + when(world.getEnvironment()).thenReturn(World.Environment.NETHER); + AsyncStructureSpawnEvent e = event("ancient_city"); + listener.onStructureSpawn(e); + verify(e, never()).setCancelled(true); + } + + @Test + void testConfigKeyWithHyphensAndCaseMatches() { + when(settings.getGenerateStructures()).thenReturn(Map.of("Ancient-City", false)); + AsyncStructureSpawnEvent e = event("ancient_city"); + listener.onStructureSpawn(e); + verify(e).setCancelled(true); + } +}