Add VEGETATION_DIES world effect — trees and plants visibly die under bad conditions

WorldEffectType: new VEGETATION_DIES variant.

WorldEffectsModule: emits VEGETATION_DIES whenever soilQuality < 20 OR
pollutionScore > 30, mirroring VegetationModule's bad-conditions gate so
block changes stay in sync with the data-layer die-off. Intensity is the
max of the soil-shortage and pollution-excess fractions.

NeoForgeWorldEffectExecutor.killVegetation():
- Detects tree canopy by comparing MOTION_BLOCKING (includes leaves/logs)
  height vs MOTION_BLOCKING_NO_LEAVES (ground) height.
- When canopy is present: strips all leaf blocks downward from the canopy
  top; at intensity > 0.3 also removes upper trunk log sections, leaving
  bottom two blocks as a visible stump.
- When no canopy: clears surface plants (short grass, fern, tall grass,
  large fern, saplings, dandelion, poppy).

Previously the simulation correctly tracked vegetation pressure dropping
to zero but the Minecraft world remained a full forest, so there was no
visible feedback for degradation. /lw demo degrade now visibly kills the
forest canopy while the succession stage regresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George
2026-06-07 22:32:23 +01:00
parent 725ac6b33f
commit 30e680b650
3 changed files with 81 additions and 1 deletions
@@ -46,6 +46,13 @@ public enum WorldEffectType {
*/ */
WILDFIRE, WILDFIRE,
/**
* Sustained bad conditions (low soil quality or high pollution) cause living
* vegetation to die visibly: leaves are removed, upper log sections stripped to
* stumps, and surface plants replaced with dead material.
*/
VEGETATION_DIES,
/** /**
* Heavy regional rain on low-succession (barren / sparse-grass) terrain causes * Heavy regional rain on low-succession (barren / sparse-grass) terrain causes
* water to pool in low-lying spots. The platform adapter places water source blocks * water to pool in low-lying spots. The platform adapter places water source blocks
@@ -63,6 +63,9 @@ public final class WorldEffectsModule implements SimulationModule {
// Soil defaults to 60 so the gate is set at 75 to avoid blocking early effects. // Soil defaults to 60 so the gate is set at 75 to avoid blocking early effects.
private static final double GRASS_DEGRADE_POLLUTION_MIN = 15.0; private static final double GRASS_DEGRADE_POLLUTION_MIN = 15.0;
private static final double GRASS_DEGRADE_SOIL_MAX = 75.0; private static final double GRASS_DEGRADE_SOIL_MAX = 75.0;
// Vegetation die-off: mirrors VegetationModule's bad-conditions thresholds.
private static final double DIEOFF_SOIL_MAX = 20.0;
private static final double DIEOFF_POLLUTION_MIN = 30.0;
private static final double VEG_SPREAD_VEG_MIN = 60.0; private static final double VEG_SPREAD_VEG_MIN = 60.0;
private static final double VEG_SPREAD_SOIL_MIN = 50.0; private static final double VEG_SPREAD_SOIL_MIN = 50.0;
private static final double SAPLING_SLOW_LOGGING_MIN = 50.0; private static final double SAPLING_SLOW_LOGGING_MIN = 50.0;
@@ -206,7 +209,26 @@ public final class WorldEffectsModule implements SimulationModule {
emitted = true; emitted = true;
} }
// --- Effect 7: rain pools in arid low-succession terrain --- // --- Effect 7: vegetation dies under bad soil or heavy pollution ---
// Mirrors the bad-conditions gate in VegetationModule so block changes
// stay in sync with the data-layer die-off.
boolean badSoil = m.getSoilQuality() < DIEOFF_SOIL_MAX;
boolean badPollution = m.getPollutionScore() > DIEOFF_POLLUTION_MIN;
if (badSoil || badPollution) {
double intensitySoil = badSoil
? computeIntensity(DIEOFF_SOIL_MAX - m.getSoilQuality(), DIEOFF_SOIL_MAX)
: 0.0;
double intensityPoll = badPollution
? computeIntensity(m.getPollutionScore() - DIEOFF_POLLUTION_MIN, 70.0)
: 0.0;
double dieoffIntensity = Math.max(intensitySoil, intensityPoll);
emit(new WorldEffectRequest(
WorldEffectType.VEGETATION_DIES, region.getCoordinate(),
Math.max(0.1, dieoffIntensity)));
emitted = true;
}
// --- Effect 8: rain pools in arid low-succession terrain ---
// Heavy regional rain on barren/sparse-grass land cannot drain into vegetation; // Heavy regional rain on barren/sparse-grass land cannot drain into vegetation;
// water collects in depressions, forming puddles visible as actual water blocks. // water collects in depressions, forming puddles visible as actual water blocks.
if (recovery != null && atm != null if (recovery != null && atm != null
@@ -70,6 +70,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred
case WILDFIRE -> case WILDFIRE ->
igniteVegetation(level, baseX, baseZ, request.intensity()); igniteVegetation(level, baseX, baseZ, request.intensity());
case VEGETATION_DIES ->
killVegetation(level, baseX, baseZ, request.intensity());
case WATER_POOL_FORMS -> case WATER_POOL_FORMS ->
formWaterPool(level, baseX, baseZ, request.intensity()); formWaterPool(level, baseX, baseZ, request.intensity());
} }
@@ -188,6 +190,55 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
} }
} }
/**
* Strips leaves and upper trunk sections from trees, and removes surface plants.
* Uses MOTION_BLOCKING (includes canopy) vs MOTION_BLOCKING_NO_LEAVES (ground) to
* detect trees — when the two heights differ, a canopy is present.
* At low intensity: leaves only. At high intensity: leaves + upper log sections.
*/
private void killVegetation(ServerLevel level, int baseX, int baseZ, double intensity) {
int attempts = Math.max(2, (int)(intensity * BLOCK_ATTEMPTS * 2));
for (int i = 0; i < attempts; i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
int canopyY = level.getHeight(Heightmap.Types.MOTION_BLOCKING, x, z) - 1;
int groundY = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1;
if (canopyY > groundY) {
// A tree canopy is present — strip downward from canopy.
for (int y = canopyY; y > groundY; y--) {
BlockPos pos = new BlockPos(x, y, z);
if (!level.isLoaded(pos)) break;
var state = level.getBlockState(pos);
if (state.is(BlockTags.LEAVES)) {
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
} else if (state.is(BlockTags.LOGS) && y > groundY + 1 && intensity > 0.3) {
// Remove upper trunk, leave bottom two blocks as a stump.
level.setBlock(pos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
}
} else {
// No canopy — clear surface plants (grass, flowers, saplings).
if (groundY < level.getMinBuildHeight()) continue;
BlockPos surfacePos = new BlockPos(x, groundY, z);
if (!level.isLoaded(surfacePos)) continue;
BlockPos abovePos = surfacePos.above();
if (!level.isLoaded(abovePos)) continue;
var above = level.getBlockState(abovePos);
if (above.is(Blocks.SHORT_GRASS) || above.is(Blocks.FERN)
|| above.is(Blocks.TALL_GRASS) || above.is(Blocks.LARGE_FERN)
|| above.is(BlockTags.SAPLINGS)
|| above.is(Blocks.DANDELION) || above.is(Blocks.POPPY)) {
level.setBlock(abovePos, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
}
}
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"WorldEffect VEGETATION_DIES intensity=" + String.format("%.2f", intensity)
+ " at (" + baseX + "," + baseZ + ")");
}
/** /**
* Places water source blocks in the lowest depression found among random surface samples. * Places water source blocks in the lowest depression found among random surface samples.
* Fires ~5% of times it is called so pools build gradually rather than flooding the region. * Fires ~5% of times it is called so pools build gradually rather than flooding the region.