From fac0128339e8127550cfd9f4b295f4b68e393bac Mon Sep 17 00:00:00 2001
From: tastybento
Date: Mon, 6 Jul 2026 13:07:45 -0700
Subject: [PATCH 1/4] Add configurable structure toggles for the overworld
(#112)
The overworld delegates to vanilla generation, which can produce structures
such as Ancient Cities, Trial Chambers and Strongholds. Large ones can fill or
unbalance a cave world (issue #112). The ChunkGenerator flag is all-or-nothing,
so this adds per-structure control:
- Settings: new world.structures map (Map) of structure key to
whether it may generate. Structures not listed generate normally. Defaults
disable ancient_city, trial_chambers and mansion; mineshaft and stronghold
stay on.
- StructureGenerationListener: cancels AsyncStructureSpawnEvent for any disabled
structure in the CaveBlock overworld. Key matching tolerates hyphens/case.
- config.yml: documents the new section.
- pom.xml: bump build.version to 1.23.0.
Adds StructureGenerationListenerTest. All 67 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01KxBgEZz7yHf4eLVzJTw1V5
---
pom.xml | 2 +-
.../world/bentobox/caveblock/CaveBlock.java | 4 +-
.../world/bentobox/caveblock/Settings.java | 53 +++++++++
.../StructureGenerationListener.java | 81 +++++++++++++
src/main/resources/config.yml | 16 +++
.../StructureGenerationListenerTest.java | 110 ++++++++++++++++++
6 files changed, 264 insertions(+), 2 deletions(-)
create mode 100644 src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
create mode 100644 src/test/java/world/bentobox/caveblock/listeners/StructureGenerationListenerTest.java
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);
+ }
+}
From bdfa8698e08d8ccf757af8f719481b5519386040 Mon Sep 17 00:00:00 2001
From: tastybento
Date: Mon, 6 Jul 2026 19:21:23 -0700
Subject: [PATCH 2/4] Add overworld cave-density controls (issue #111)
The overworld delegates to vanilla generation, whose 1.18+ noise caves can
make a cave world "nothing but passageways" (issue #111). Vanilla exposes no
density knob for noise caves, so add a post-generation fill pass plus a coarse
carver toggle:
- Settings: new world.overworld-cave-fill (0.0-1.0, default 0.0) and
world.overworld-carvers (boolean, default true). getOverworldCaveFill()
clamps to [0,1].
- NoiseCaveGenerator: new fillField() - a low-frequency, seed-deterministic
[0,1] field so caves close in broad connected patches, not a random speckle.
- ChunkGeneratorWorld: after capColumn seals the sky, fillCaves() re-solidifies
AIR and CAVE_AIR below the cap where the field falls under the ratio. Both air
types matter: noise caves are placed as AIR, carvers as CAVE_AIR - matching
only CAVE_AIR (the first attempt) missed the noise caves entirely. The carver
toggle gates shouldGenerateCaves(). Underground biomes, ores, decorations and
structures are preserved at any fill value.
- config.yml: documents both keys.
Adds fill-field and generateSurface fill tests. All 74 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01KwMG7jJxLPXGqNfb95nP1W
---
.../world/bentobox/caveblock/Settings.java | 63 +++++++++
.../generators/ChunkGeneratorWorld.java | 48 ++++++-
.../generators/NoiseCaveGenerator.java | 19 +++
src/main/resources/config.yml | 17 +++
.../generators/ChunkGeneratorWorldTest.java | 132 ++++++++++++++++++
.../generators/NoiseCaveGeneratorTest.java | 57 ++++++++
6 files changed, 335 insertions(+), 1 deletion(-)
diff --git a/src/main/java/world/bentobox/caveblock/Settings.java b/src/main/java/world/bentobox/caveblock/Settings.java
index f0f88ee..6dd6abd 100644
--- a/src/main/java/world/bentobox/caveblock/Settings.java
+++ b/src/main/java/world/bentobox/caveblock/Settings.java
@@ -172,6 +172,26 @@ public class Settings implements WorldSettings
@ConfigEntry(path = "world.structures", since = "1.23.0")
private Map generateStructures = defaultStructures();
+ @ConfigComment("")
+ @ConfigComment("Overworld cave density. Vanilla generates a dense 1.18+ cave network")
+ @ConfigComment("(cheese and spaghetti caves) which on a solid cave world can feel like")
+ @ConfigComment("'nothing but passageways'. This re-solidifies a fraction of that cave air")
+ @ConfigComment("after generation, using a low-frequency noise field so whole regions close")
+ @ConfigComment("up (leaving separate chambers) rather than punching random single holes.")
+ @ConfigComment("0.0 = keep every vanilla cave (densest, the original behaviour).")
+ @ConfigComment("1.0 = fill nearly all caves (almost solid). Try 0.4 - 0.6 to thin them out.")
+ @ConfigComment("Underground biomes, ores, decorations and structures are kept either way.")
+ @ConfigComment("Only affects newly generated chunks.")
+ @ConfigEntry(path = "world.overworld-cave-fill", since = "1.23.0")
+ private double overworldCaveFill = 0.0;
+
+ @ConfigComment("")
+ @ConfigComment("Generate vanilla carver caves (big ravines and long round tunnels) in the")
+ @ConfigComment("overworld. These stack on top of the noise caves above. Set to false to")
+ @ConfigComment("remove the ravines and wide tunnels while keeping the noise caves.")
+ @ConfigEntry(path = "world.overworld-carvers", needsReset = true, since = "1.23.0")
+ private boolean overworldCarvers = true;
+
@ConfigComment("")
@ConfigComment("Make over world roof of bedrock, if false, it will be made from stone.")
@ConfigEntry(path = "world.normal.roof", needsReset = true)
@@ -1232,6 +1252,49 @@ public void setGenerateStructures(Map generateStructures)
}
+ /**
+ * Fraction of overworld vanilla cave air to re-solidify after generation to
+ * thin the cave network. {@code 0.0} keeps every cave, {@code 1.0} fills
+ * nearly all of them. Clamped to the [0, 1] range on read.
+ * @return the overworld cave fill ratio.
+ */
+ public double getOverworldCaveFill()
+ {
+ return Math.max(0.0, Math.min(1.0, overworldCaveFill));
+ }
+
+
+ /**
+ * Sets the overworld cave fill ratio.
+ * @param overworldCaveFill the overworld cave fill ratio.
+ */
+ public void setOverworldCaveFill(double overworldCaveFill)
+ {
+ this.overworldCaveFill = overworldCaveFill;
+ }
+
+
+ /**
+ * Whether vanilla carver caves (ravines and long round tunnels) generate in
+ * the overworld.
+ * @return {@code true} if carver caves generate.
+ */
+ public boolean isOverworldCarvers()
+ {
+ return overworldCarvers;
+ }
+
+
+ /**
+ * Sets whether vanilla carver caves generate in the overworld.
+ * @param overworldCarvers whether carver caves generate.
+ */
+ public void setOverworldCarvers(boolean overworldCarvers)
+ {
+ this.overworldCarvers = overworldCarvers;
+ }
+
+
/**
* 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
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/resources/config.yml b/src/main/resources/config.yml
index 150d0a6..041bd39 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -107,6 +107,23 @@ world:
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..81e0bde 100644
--- a/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java
+++ b/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java
@@ -76,4 +76,61 @@ 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 everything; 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));
+ }
+ }
}
From 8bfddf6a717d6b41f85880b024c233ea3b299713 Mon Sep 17 00:00:00 2001
From: tastybento
Date: Mon, 6 Jul 2026 19:30:51 -0700
Subject: [PATCH 3/4] Address Copilot review comments on PR #114
- StructureGenerationListener: correct class Javadoc to describe the
async safety accurately (reads world environment/ownership but does no
world or block mutation) instead of claiming no Bukkit world access.
- StructureGenerationListener.isDisabled: normalize the incoming
structureKey too, so hyphen/underscore/casing differences match on
both sides as the comment promises.
- NoiseCaveGeneratorTest: fix the test Javadoc to match the actual
fill semantics (fillField clamped to [0,1], block fills when
fillField < ratio, so ratio 1.0 fills nearly all, not all).
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01KwMG7jJxLPXGqNfb95nP1W
---
.../listeners/StructureGenerationListener.java | 10 ++++++----
.../caveblock/generators/NoiseCaveGeneratorTest.java | 6 ++++--
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
index 29b974c..83bc8ca 100644
--- a/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
+++ b/src/main/java/world/bentobox/caveblock/listeners/StructureGenerationListener.java
@@ -23,8 +23,9 @@
* 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.
+ * 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
*/
@@ -66,9 +67,10 @@ private boolean isDisabled(String structureKey) {
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()) {
- // Accept hyphen or underscore separators and any casing.
- if (normalize(entry.getKey()).equals(structureKey) && Boolean.FALSE.equals(entry.getValue())) {
+ if (normalize(entry.getKey()).equals(normalizedKey) && Boolean.FALSE.equals(entry.getValue())) {
return true;
}
}
diff --git a/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java b/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java
index 81e0bde..56de0b5 100644
--- a/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java
+++ b/src/test/java/world/bentobox/caveblock/generators/NoiseCaveGeneratorTest.java
@@ -92,8 +92,10 @@ void testFillFieldInRange() {
}
/**
- * A ratio of 0 fills nothing and a ratio of 1 fills everything; a mid ratio must
- * fill some but not all, and a higher ratio must never fill fewer blocks.
+ * 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() {
From cbf3bbf28367c5e825b36b4a49bf6c12f3d92140 Mon Sep 17 00:00:00 2001
From: tastybento
Date: Mon, 6 Jul 2026 19:38:08 -0700
Subject: [PATCH 4/4] Fix 0% Sonar coverage: prepend @{argLine} to Surefire
argLine
The Surefire plugin hardcoded its own (the --add-opens
directives), which overwrote the argLine property that JaCoCo's
prepare-agent goal sets. The JaCoCo java agent was therefore never
attached to the test JVM, no jacoco.exec was produced, and SonarCloud
reported 0% coverage.
Prepend Surefire's late-evaluated @{argLine} token so JaCoCo's
-javaagent flag is included alongside the --add-opens directives,
matching the convention used in sibling addons (e.g. OneBlock).
jacoco.exec is now generated and coverage is reported.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01KwMG7jJxLPXGqNfb95nP1W
---
pom.xml | 1 +
1 file changed, 1 insertion(+)
diff --git a/pom.xml b/pom.xml
index 688d48d..0b9e721 100644
--- a/pom.xml
+++ b/pom.xml
@@ -226,6 +226,7 @@
**/*Test??.java
+ @{argLine}
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.math=ALL-UNNAMED
--add-opens java.base/java.io=ALL-UNNAMED