Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/main/java/world/bentobox/caveblock/Settings.java
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,26 @@
@ConfigEntry(path = "world.structures", since = "1.23.0")
private Map<String, Boolean> 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)
Expand Down Expand Up @@ -1232,6 +1252,49 @@
}


/**
* 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));

Check warning on line 1263 in src/main/java/world/bentobox/caveblock/Settings.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Math.clamp" instead of "Math.min" or "Math.max".

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_CaveBlock&issues=AZ86ZElfvN4LJ-2ExFhD&open=AZ86ZElfvN4LJ-2ExFhD&pullRequest=115
}


/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
*/
@Override
public boolean shouldGenerateCaves() {
return this.environment == World.Environment.NORMAL;
return this.environment == World.Environment.NORMAL && this.settings.isOverworldCarvers();
}

/**
Expand Down Expand Up @@ -251,9 +251,18 @@
// 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
Expand Down Expand Up @@ -345,6 +354,43 @@
}
}

/**
* Re-solidifies a fraction of the vanilla overworld cave air to thin the cave
* network. Runs <b>after</b> {@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.
*
* <p>Both air types must be matched: the vanilla 1.18+ <i>noise</i> 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.</p>
*
* @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,

Check warning on line 382 in src/main/java/world/bentobox/caveblock/generators/ChunkGeneratorWorld.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 10 parameters, which is greater than 7 authorized.

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_CaveBlock&issues=AZ86ZEk9vN4LJ-2ExFhB&open=AZ86ZEk9vN4LJ-2ExFhB&pullRequest=115
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
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;
Expand Down Expand Up @@ -94,4 +96,21 @@
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));

Check warning on line 114 in src/main/java/world/bentobox/caveblock/generators/NoiseCaveGenerator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Math.clamp" instead of "Math.min" or "Math.max".

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_CaveBlock&issues=AZ86ZElIvN4LJ-2ExFhC&open=AZ86ZElIvN4LJ-2ExFhC&pullRequest=115
}
}
17 changes: 17 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Check warning on line 10 in src/test/java/world/bentobox/caveblock/generators/ChunkGeneratorWorldTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import 'org.mockito.Mockito.doAnswer'.

See more on https://sonarcloud.io/project/issues?id=BentoBoxWorld_CaveBlock&issues=AZ86ZEiBvN4LJ-2ExFhA&open=AZ86ZEiBvN4LJ-2ExFhA&pullRequest=115
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;
Expand Down Expand Up @@ -91,6 +101,13 @@
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());
Expand Down Expand Up @@ -200,4 +217,119 @@
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<String, Material> 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<String, Material> 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<String, Material> 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<String, Material> 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<String, Material> 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;
}
}
Loading
Loading