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/generators/ChunkGeneratorWorld.java b/src/main/java/world/bentobox/caveblock/generators/ChunkGeneratorWorld.java
index ab93772..16691f0 100644
--- a/src/main/java/world/bentobox/caveblock/generators/ChunkGeneratorWorld.java
+++ b/src/main/java/world/bentobox/caveblock/generators/ChunkGeneratorWorld.java
@@ -126,7 +126,7 @@ public boolean shouldGenerateSurface() {
*/
@Override
public boolean shouldGenerateCaves() {
- return this.environment == World.Environment.NORMAL;
+ return this.environment == World.Environment.NORMAL && this.settings.isOverworldCarvers();
}
/**
@@ -251,9 +251,18 @@ public void generateSurface(WorldInfo worldInfo, Random random, int chunkX, int
// Cap every column: replace sky air and surface water with stone.
// CAVE_AIR (vanilla cave pockets) marks the underground boundary and is left alone.
final Material fillMaterial = settings.getNormalMainBlock();
+ // Optional density control: re-solidify a fraction of the vanilla caves
+ // so the overworld is not "nothing but passageways" (issue #111).
+ final double caveFill = settings.getOverworldCaveFill();
+ final NoiseCaveGenerator fillField = caveFill > 0 ? getCaveGenerator(worldInfo.getSeed()) : null;
for (int x = 0; x < 16; x++) {
+ final int worldX = (chunkX << 4) + x;
for (int z = 0; z < 16; z++) {
capColumn(chunkData, x, z, minHeight, maxHeight, fillMaterial);
+ if (fillField != null) {
+ fillCaves(chunkData, x, z, worldX, (chunkZ << 4) + z, minHeight, maxHeight,
+ fillMaterial, fillField, caveFill);
+ }
}
}
// Roof at the very top of the world
@@ -345,6 +354,43 @@ private void capColumn(ChunkData chunkData, int x, int z, int minHeight, int max
}
}
+ /**
+ * Re-solidifies a fraction of the vanilla overworld cave air to thin the cave
+ * network. Runs after {@link #capColumn} has already sealed this column's
+ * sky, so any remaining {@code AIR} or {@code CAVE_AIR} below the cap is genuine
+ * underground cave.
+ *
+ * Both air types must be matched: the vanilla 1.18+ noise caves (the
+ * dense "cheese and spaghetti" network) are placed as plain {@code AIR}, while
+ * the older carvers use {@code CAVE_AIR}. Matching only {@code CAVE_AIR} would
+ * miss the noise caves entirely — which are exactly the passageways this setting
+ * is meant to thin. A low-frequency noise field decides which blocks to fill, so
+ * caves close in broad connected patches rather than as a random speckle, and the
+ * decision is deterministic for the world seed.
+ *
+ * @param chunkData chunk being generated
+ * @param x block X within chunk (0-15)
+ * @param z block Z within chunk (0-15)
+ * @param worldX absolute world X of this column
+ * @param worldZ absolute world Z of this column
+ * @param minHeight world min height
+ * @param maxHeight world max height (roof reserved at maxHeight-1)
+ * @param fillMaterial material used to fill caves (normally the main block)
+ * @param fillField noise field seeded from the world seed
+ * @param ratio fraction to fill; a block is filled when its field value is below this
+ */
+ private void fillCaves(ChunkData chunkData, int x, int z, int worldX, int worldZ, int minHeight, int maxHeight,
+ Material fillMaterial, NoiseCaveGenerator fillField, double ratio) {
+ // Leave the floor (minHeight, set by generateBedrock) and roof (maxHeight-1) alone.
+ for (int y = maxHeight - 2; y > minHeight; y--) {
+ Material type = chunkData.getType(x, y, z);
+ if ((type == Material.AIR || type == Material.CAVE_AIR)
+ && fillField.fillField(worldX, y, worldZ) < ratio) {
+ chunkData.setBlock(x, y, z, fillMaterial);
+ }
+ }
+ }
+
private Material getGroundRoofMaterial(World.Environment env) {
return switch (env) {
case NETHER -> settings.isNetherRoof() ? Material.BEDROCK : settings.getNetherMainBlock();
diff --git a/src/main/java/world/bentobox/caveblock/generators/NoiseCaveGenerator.java b/src/main/java/world/bentobox/caveblock/generators/NoiseCaveGenerator.java
index 03321bf..97c99ba 100644
--- a/src/main/java/world/bentobox/caveblock/generators/NoiseCaveGenerator.java
+++ b/src/main/java/world/bentobox/caveblock/generators/NoiseCaveGenerator.java
@@ -35,6 +35,8 @@ public class NoiseCaveGenerator {
private static final double CHEESE_THRESHOLD = 0.3;
/** How strongly the cheese field eats into the rock. */
private static final double CHEESE_STRENGTH = 0.3;
+ /** Low frequency for the overworld fill field: closes caves in broad patches. */
+ private static final double FILL_FREQUENCY = 0.02;
private final NoiseGenerator noiseGen1;
private final NoiseGenerator noiseGen2;
@@ -94,4 +96,21 @@ public double noise(double x, double y, double z) {
public boolean isCave(double x, double y, double z, double threshold) {
return noise(x, y, z) < threshold;
}
+
+ /**
+ * A coherent [0, 1] field used to thin an existing cave network. Because the
+ * frequency is low, nearby blocks share similar values, so filling every
+ * block whose value falls below a ratio closes caves in broad connected
+ * patches rather than as a random speckle. Deterministic for the seed.
+ *
+ * @param x world X
+ * @param y world Y
+ * @param z world Z
+ * @return a value in the range [0, 1]
+ */
+ public double fillField(double x, double y, double z) {
+ // noise() returns roughly [-1, 1]; remap to [0, 1] and clamp for safety.
+ double v = (noiseGen2.noise(x * FILL_FREQUENCY, y * FILL_FREQUENCY, z * FILL_FREQUENCY) + 1.0) / 2.0;
+ return Math.max(0.0, Math.min(1.0, v));
+ }
}
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..83bc8ca
--- /dev/null
+++ b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
@@ -0,0 +1,83 @@
+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
+ * 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.
+ *
+ * @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;
+ }
+ // Accept hyphen or underscore separators and any casing on both sides.
+ String normalizedKey = normalize(structureKey);
+ for (Map.Entry entry : structures.entrySet()) {
+ if (normalize(entry.getKey()).equals(normalizedKey) && 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..041bd39 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -91,6 +91,39 @@ 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
+ #
+ # Overworld cave density. Vanilla generates a dense 1.18+ cave network (cheese and
+ # spaghetti caves) which on a solid cave world can feel like 'nothing but passageways'.
+ # This re-solidifies a fraction of that cave air after generation, using a low-frequency
+ # noise field so whole regions close up (leaving separate chambers) rather than punching
+ # random single holes.
+ # 0.0 = keep every vanilla cave (densest, the original behaviour)
+ # 1.0 = fill nearly all caves (almost solid)
+ # Try 0.4 - 0.6 to thin them out. Underground biomes, ores, decorations and structures
+ # are kept either way. Only affects newly generated chunks. Added since 1.23.0.
+ overworld-cave-fill: 0.0
+ #
+ # Generate vanilla carver caves (big ravines and long round tunnels) in the overworld.
+ # These stack on top of the noise caves above. Set to false to remove the ravines and
+ # wide tunnels while keeping the noise caves. Added since 1.23.0.
+ # /!\ 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.
+ overworld-carvers: 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/generators/ChunkGeneratorWorldTest.java b/src/test/java/world/bentobox/caveblock/generators/ChunkGeneratorWorldTest.java
index 0176c6c..fcb4874 100644
--- a/src/test/java/world/bentobox/caveblock/generators/ChunkGeneratorWorldTest.java
+++ b/src/test/java/world/bentobox/caveblock/generators/ChunkGeneratorWorldTest.java
@@ -5,10 +5,20 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Random;
+
+import org.bukkit.Material;
import org.bukkit.World;
+import org.bukkit.generator.ChunkGenerator.ChunkData;
import org.bukkit.generator.WorldInfo;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -91,6 +101,13 @@ void testShouldGenerateCavesNormal() {
assertTrue(new ChunkGeneratorWorld(addon, World.Environment.NORMAL).shouldGenerateCaves());
}
+ @Test
+ void testShouldGenerateCavesNormalCarversDisabled() {
+ settings.setOverworldCarvers(false);
+ assertFalse(new ChunkGeneratorWorld(addon, World.Environment.NORMAL).shouldGenerateCaves(),
+ "Disabling overworld carvers must stop vanilla carver caves");
+ }
+
@Test
void testShouldGenerateCavesNether() {
assertFalse(new ChunkGeneratorWorld(addon, World.Environment.NETHER).shouldGenerateCaves());
@@ -200,4 +217,119 @@ void testReloadClearsAndRebuildsPopulatorsNether() {
cg.reload();
assertEquals(2, cg.getDefaultPopulators(world).size());
}
+
+ // -------------------------------------------------------------------------
+ // Overworld cave fill (generateSurface)
+ // -------------------------------------------------------------------------
+
+ private static final int MIN_HEIGHT = 0;
+ private static final int MAX_HEIGHT = 64;
+ private static final int CAVE_LOW = 20;
+ private static final int CAVE_HIGH = 40;
+ private static final int CAVE_COUNT = 16 * 16 * (CAVE_HIGH - CAVE_LOW);
+
+ /**
+ * With the default fill of 0.0 no cave air is solidified — the overworld keeps
+ * every vanilla cave (the pre-existing behaviour) — but sky air is still sealed.
+ */
+ @Test
+ void testGenerateSurfaceNormalNoFillKeepsCaves() {
+ Map store = new HashMap<>();
+ ChunkData data = buildChunkData(store);
+ // fill defaults to 0.0
+ runNormalSurface(data);
+
+ assertEquals(CAVE_COUNT, countCaveAir(store), "Fill 0.0 must leave every cave untouched");
+ assertEquals(settings.getNormalMainBlock(), store.get("0,60,0"), "Sky air must still be sealed");
+ }
+
+ /**
+ * A full fill of 1.0 re-solidifies essentially the whole cave network.
+ */
+ @Test
+ void testGenerateSurfaceNormalFullFillClosesCaves() {
+ settings.setOverworldCaveFill(1.0);
+ Map store = new HashMap<>();
+ ChunkData data = buildChunkData(store);
+ runNormalSurface(data);
+
+ assertTrue(countCaveAir(store) < CAVE_COUNT * 0.02,
+ "Fill 1.0 should close nearly all caves, left " + countCaveAir(store));
+ }
+
+ /**
+ * A partial fill thins the caves: fewer remain than at 0.0, more than at 1.0.
+ */
+ @Test
+ void testGenerateSurfaceNormalPartialFillThinsCaves() {
+ settings.setOverworldCaveFill(0.5);
+ Map store = new HashMap<>();
+ ChunkData data = buildChunkData(store);
+ runNormalSurface(data);
+
+ int remaining = countCaveAir(store);
+ assertTrue(remaining > 0 && remaining < CAVE_COUNT,
+ "Fill 0.5 should thin but not clear the caves, left " + remaining);
+ }
+
+ /** Runs generateSurface for a NORMAL world over chunk (0,0). */
+ private void runNormalSurface(ChunkData data) {
+ when(worldInfo.getEnvironment()).thenReturn(World.Environment.NORMAL);
+ when(worldInfo.getMinHeight()).thenReturn(MIN_HEIGHT);
+ when(worldInfo.getMaxHeight()).thenReturn(MAX_HEIGHT);
+ lenient().when(worldInfo.getSeed()).thenReturn(123L);
+ ChunkGeneratorWorld cg = new ChunkGeneratorWorld(addon, World.Environment.NORMAL);
+ cg.generateSurface(worldInfo, new Random(0), 0, 0, data);
+ }
+
+ private static int countCaveAir(Map store) {
+ // Both air types count as cave: noise caves are AIR, carver caves are CAVE_AIR.
+ return (int) store.values().stream()
+ .filter(m -> m == Material.AIR || m == Material.CAVE_AIR).count();
+ }
+
+ /**
+ * An in-memory {@link ChunkData} backed by a map. Unset blocks read as solid
+ * STONE; a mid band is seeded with caves and the top with sky air so the surface
+ * pass has both caves to thin and sky to seal. The cave band mixes plain AIR
+ * (vanilla noise caves) and CAVE_AIR (carver caves) so the fill must handle both.
+ */
+ private ChunkData buildChunkData(Map store) {
+ for (int x = 0; x < 16; x++) {
+ for (int z = 0; z < 16; z++) {
+ for (int y = CAVE_LOW; y < CAVE_HIGH; y++) {
+ // Lower half AIR (noise caves), upper half CAVE_AIR (carver caves).
+ store.put(key(x, y, z), y < (CAVE_LOW + CAVE_HIGH) / 2 ? Material.AIR : Material.CAVE_AIR);
+ }
+ for (int y = MAX_HEIGHT - 4; y < MAX_HEIGHT - 1; y++) {
+ store.put(key(x, y, z), Material.AIR);
+ }
+ }
+ }
+ ChunkData cd = mock(ChunkData.class);
+ lenient().when(cd.getType(anyInt(), anyInt(), anyInt())).thenAnswer(inv ->
+ store.getOrDefault(key(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2)), Material.STONE));
+ lenient().doAnswer(inv -> {
+ store.put(key(inv.getArgument(0), inv.getArgument(1), inv.getArgument(2)), inv.getArgument(3));
+ return null;
+ }).when(cd).setBlock(anyInt(), anyInt(), anyInt(), any(Material.class));
+ lenient().doAnswer(inv -> {
+ int x0 = inv.getArgument(0), y0 = inv.getArgument(1), z0 = inv.getArgument(2);
+ int x1 = inv.getArgument(3), y1 = inv.getArgument(4), z1 = inv.getArgument(5);
+ Material m = inv.getArgument(6);
+ for (int x = x0; x < x1; x++) {
+ for (int y = y0; y < y1; y++) {
+ for (int z = z0; z < z1; z++) {
+ store.put(key(x, y, z), m);
+ }
+ }
+ }
+ return null;
+ }).when(cd).setRegion(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(Material.class));
+ return cd;
+ }
+
+ private static String key(int x, int y, int z) {
+ return x + "," + y + "," + z;
+ }
}
diff --git a/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java b/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java
index da63734..56de0b5 100644
--- a/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java
+++ b/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java
@@ -76,4 +76,63 @@ void testIsCaveMatchesThreshold() {
assertNotEquals(gen.isCave(5, 5, 5, -1.0), gen.isCave(5, 5, 5, 2.0),
"An always-solid vs always-cave threshold must differ");
}
+
+ /**
+ * The fill field must stay within [0, 1] so it can be compared against a ratio.
+ */
+ @Test
+ void testFillFieldInRange() {
+ NoiseCaveGenerator gen = new NoiseCaveGenerator(99L);
+ for (int x = 0; x < 40; x++) {
+ for (int z = 0; z < 40; z++) {
+ double v = gen.fillField(x, 30, z);
+ assertTrue(v >= 0.0 && v <= 1.0, "fillField out of range: " + v);
+ }
+ }
+ }
+
+ /**
+ * A ratio of 0 fills nothing and a ratio of 1 fills nearly everything (fillField is
+ * clamped to [0,1] and a block fills when fillField < ratio, so an exact 1.0 field
+ * value is not filled); a mid ratio must fill some but not all, and a higher ratio
+ * must never fill fewer blocks.
+ */
+ @Test
+ void testFillFieldMonotonicWithRatio() {
+ NoiseCaveGenerator gen = new NoiseCaveGenerator(2024L);
+ int none = 0;
+ int half = 0;
+ int all = 0;
+ int total = 0;
+ for (int x = 0; x < 48; x++) {
+ for (int z = 0; z < 48; z++) {
+ double v = gen.fillField(x, 30, z);
+ if (v < 0.0) {
+ none++;
+ }
+ if (v < 0.5) {
+ half++;
+ }
+ if (v < 1.0) {
+ all++;
+ }
+ total++;
+ }
+ }
+ assertEquals(0, none, "Ratio 0.0 should fill no blocks");
+ assertTrue(half > 0 && half < total, "Ratio 0.5 should fill some but not all, got " + half);
+ assertTrue(all >= half, "A higher ratio must not fill fewer blocks");
+ }
+
+ /**
+ * The fill field must be deterministic for a seed so chunks re-solidify the same way.
+ */
+ @Test
+ void testFillFieldDeterministic() {
+ NoiseCaveGenerator a = new NoiseCaveGenerator(555L);
+ NoiseCaveGenerator b = new NoiseCaveGenerator(555L);
+ for (int i = 0; i < 50; i++) {
+ assertEquals(a.fillField(i, i + 5, i * 2), b.fillField(i, i + 5, i * 2));
+ }
+ }
}
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);
+ }
+}