From bdfa8698e08d8ccf757af8f719481b5519386040 Mon Sep 17 00:00:00 2001 From: tastybento Date: Mon, 6 Jul 2026 19:21:23 -0700 Subject: [PATCH] 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)); + } + } }