Add water physics, river currents, volcanic system with lava & new land

Groundwater aquifer (valley springs):
- Per-region subsurface water table recharges from rainfall + lateral seepage
  from uphill neighbours; valley floors (≥2 higher neighbours) develop springs
- GROUND_SPRING_EMERGES places a permanent water source block at the lowest
  natural terrain point; vanilla fluid physics carries it downhill from there
- Active springs feed riverFlowIntensity for erosion even without rain

River current physics (real player forces):
- Every 4 ticks, players in flowing water receive a velocity push via
  FluidState.getFlow() — the exact direction Minecraft's own rendering uses
- Force scales with fluid level: level 7 (near source) = strong; level 1 = gentle

Hydraulic erosion:
- HYDRAULIC_EROSION fires whenever flow intensity is significant, rain-independent
- Flowing water adjacent to stone/gravel/sand/dirt progressively softens it:
  stone→gravel→sand→air; river valleys widen over time

Volcano lifecycle (DORMANT→BUILDING→ERUPTING→COOLING→FERTILE→DORMANT):
- High-elevation regions (avg surface Y ≥ 85) registered as potentially volcanic
- LAVA_FLOW places source blocks at summit; vanilla physics flows them downhill
- ASH_DEPOSIT covers surrounding terrain with tuff/gravel; kills surface plants;
  ash clouds spread to adjacent regions with air pollution spike
- COBBLESTONE_FORMS solidifies lava: lava+water→cobblestone, lava+air→basalt
  — creates permanent new terrain that did not exist before the eruption
- FERTILE phase: soil fertility +30, contamination −20, rapid pollution decay
- Volcanic ambience: fire crackling during eruption, deep rumble while building

New commands: /lw events (active climate events), /lw volcanoes (lifecycle states)
New ClimateEventType: VOLCANIC_ERUPTION
New WorldEffectTypes: GROUND_SPRING_EMERGES, HYDRAULIC_EROSION, LAVA_FLOW,
  ASH_DEPOSIT, COBBLESTONE_FORMS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George
2026-06-09 21:23:13 +01:00
parent 113741abd6
commit fde7264815
7 changed files with 650 additions and 11 deletions
+51 -5
View File
@@ -53,6 +53,11 @@ All effects are real block changes players can see:
| Wildfire | Drought > 70, thunder > 0.5 | Fire placed on surface vegetation |
| Water pools form | Rain + barren/sparse terrain | Water source blocks in surface depressions |
| **River carve** | High accumulated water runoff | Water channels carved through natural terrain; gradually deepens |
| **Ground spring** | Saturated aquifer in valley floor | Permanent water source placed; river flows downhill naturally |
| **Hydraulic erosion** | Flowing water adjacent to rock | Stone → gravel → sand → air; valley walls slowly widen |
| **Lava flow** | Volcanic eruption | Lava source blocks placed at summit; flows and reshapes terrain |
| **Ash deposit** | Active eruption or nearby blast | Grass → tuff, soil → gravel; surface plants killed over wide area |
| **Cobblestone forms** | Lava cooling | Lava + water → cobblestone; lava in air → basalt; new permanent land |
| Pollution particles | Pollution > 10 | Smoke particles in degraded regions |
### Player Feedback — **Complete**
@@ -126,13 +131,48 @@ Automatically triggered when regional conditions reach critical thresholds. All
- Visible in the compass HUD next to the region coordinates
- Full trend detail in `/lw atmosphere`
### River Erosion — **Complete**
### Groundwater & Springs — **Complete**
- Every region tracks a subsurface aquifer level that recharges slowly from rainfall
- Underground seepage: high-elevation neighbours drain laterally into the aquifer of lower regions (water flows underground even between sim cycles)
- When the aquifer is saturated in a **valley floor** (≥2 higher neighbours), a `GROUND_SPRING_EMERGES` effect fires — a water source block is placed at the lowest natural terrain point
- The spring flows downhill under Minecraft's own fluid physics, forming a permanent river without further simulation involvement
- Active springs contribute to river erosion intensity even without rain
### River Erosion & Hydraulic Erosion — **Complete**
- Accumulated water runoff intensity tracked per region (flow from higher → lower elevation)
- When flow intensity crosses the threshold (80 units), a `RIVER_CARVE` world effect fires
- Block effect: water source placed on natural terrain (sand/dirt/gravel/grass); at high intensity the block below is also removed, deepening the channel
- Block effect: water source placed on natural terrain; at high intensity the block below is removed (deepening channel)
- **Hydraulic erosion** fires independently of rainfall whenever significant flow intensity is present: flowing water (level 17) adjacent to soft rock softens it — stone → gravel → sand → air — progressively widening river valleys
- Rivers form gradually over many sim cycles — permanent terrain changes
### River Current Physics — **Complete** (real player forces)
- Every 4 ticks, all players standing in **flowing** water (level 17) receive a directional velocity push
- Force direction: `FluidState.getFlow()` — the exact vector Minecraft already computes for its own flow rendering; currents always point the same way the water visually flows
- Force magnitude: scales with flow level — level 7 (one block from a spring, fastest) pushes hard; level 1 (distant, slow lowland river) barely nudges
- Mountain rivers are genuinely dangerous to wade against; lowland rivers are gentle enough to cross
- Boats are naturally carried by vanilla water physics; swimming players and mobs now feel the additional push
### Volcanic System — **Complete** (new land formation)
Four-phase lifecycle per high-elevation region (average surface Y ≥ 85):
| Phase | What Happens |
|-------|-------------|
| **DORMANT** | Normal state; rare stochastic chance to awaken (~0.2% per check) |
| **BUILDING** | Seismic venting — mild pollution spike; player broadcast warns of imminent eruption |
| **ERUPTING** | `LAVA_FLOW` places source blocks at the summit; `ASH_DEPOSIT` covers surrounding terrain; ash clouds spread to adjacent regions; vegetation dies in the eruption zone; air pollution spikes; lasts ~40 sim cycles |
| **COOLING** | `COBBLESTONE_FORMS` converts lava adjacent to water → cobblestone; lava in air → basalt; permanent new terrain created where none existed before |
| **FERTILE** | Volcanic minerals boost soil fertility +30, contamination 20; pollution decays rapidly; succession cap can rise; then returns DORMANT |
Visible effects:
- Lava flows downhill under vanilla physics, permanently reshaping terrain
- Ash converts grass → tuff, loose soil → gravel; kills surface plants across a wide area
- New cobblestone and basalt islands form where lava met water
- Players in active eruption zones hear fire crackle ambience; building-phase rumble in BUILDING regions
### Tidal Simulation — **Complete** (physical blocks)
- Two tidal cycles per Minecraft day (24 000 ticks) using a sine wave
@@ -210,6 +250,8 @@ All commands require permission level 2.
| `/lw simulate <ticks>` | Force N simulation cycles on all active regions |
| `/lw stats` | Simulation profiler statistics |
| `/lw modules list` | List all registered modules and their enabled state |
| `/lw events` | List all active climate events (drought/wildfire/flood/eruption) with epicentre, severity, affected regions |
| `/lw volcanoes` | List all tracked volcanic regions and their current lifecycle phase |
| `/lw demo degrade` | One-command demo: degrade current region to barren |
| `/lw demo recover` | One-command demo: restore current region to mature forest |
@@ -291,13 +333,16 @@ LivingWorldMod (NeoForge event wiring)
applySeasonalEffects() [soil/water/veg seasonal]
applyClimateWarmingEffects() [drought pressure from CO₂]
applyWaterRunoff() [elevation-based; river erosion]
applyGroundwaterAndSprings() [aquifer recharge; valley springs]
applyDynamicCapUpdate() [raise/lower succession ceiling]
applySeedDispersal() [corridor-boosted recolonisation]
recordHealthTrend() [↑↓→ trend tracking]
applyClimateEvents() [drought/wildfire/flood events]
applyVolcanicActivity() [volcano lifecycle; lava/ash/new land]
LivingWorldMod platform hooks:
updateTidalEffects() [block-level tidal simulation]
applyRiverCurrents() [player/entity velocity in flowing water]
initializeRegionFromBiome() [biome-aware starting values]
checkPlayerRegions() [HUD, effects, ambient sounds]
scanAndRecordFurnaceActivity() [pollution from lit furnaces]
@@ -320,7 +365,8 @@ Requires NeoForge 21.1.172 / Minecraft 1.21.1.
## Planned / In Progress
- Full worldgen integration (custom biome distribution, terrain shaping beyond post-load modification)
- `/lw events` command to list active climate events and their affected regions
- Persistence for river flow intensity and accumulated seed rain across restarts
- Persistence for river flow intensity, groundwater level, and volcanic state across restarts
- Multiplayer region ownership / notification system
- More succession stages (wetland, alpine, volcanic)
- More succession stages (wetland, alpine, volcanic basalt plain)
- Snowmelt rivers: winter snow accumulation releases as meltwater in spring
- Player damage in active eruption zones (lava proximity heat)
@@ -96,6 +96,10 @@ public class LivingWorldMod {
private static final double HOSTILE_SUPPRESS_HEALTH = 60.0;
private static final int TIDE_CHECK_INTERVAL = 1200; // ~1 real minute
/** Apply river current forces to players every N ticks. */
private static final int RIVER_CURRENT_INTERVAL = 4;
/** Additional horizontal push (m/tick²) per unit of normalised flow speed. */
private static final double CURRENT_STRENGTH = 0.045;
private final LivingWorldBootstrap bootstrap;
private final Random random = new Random();
@@ -104,6 +108,7 @@ public class LivingWorldMod {
private int playerCheckTick = 0;
private int tideTick = 0;
private double lastTideLevel = 0.0;
private int riverCurrentTick = 0;
private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>();
private final Map<UUID, Boolean> playerRainState = new HashMap<>();
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
@@ -157,6 +162,7 @@ public class LivingWorldMod {
regionSoundLastTick.clear();
tideTick = 0;
lastTideLevel = 0.0;
riverCurrentTick = 0;
this.minecraftServer = null;
});
@@ -172,6 +178,9 @@ public class LivingWorldMod {
tideTick = 0;
updateTidalEffects();
}
if (++riverCurrentTick % RIVER_CURRENT_INTERVAL == 0) {
applyRiverCurrents();
}
// Broadcast any climate event messages queued by the bootstrap
java.util.List<String> eventMsgs = bootstrap.pollClimateEventMessages();
if (!eventMsgs.isEmpty()) {
@@ -390,6 +399,36 @@ public class LivingWorldMod {
}
}
/**
* Applies realistic river current forces to players and non-player entities standing
* in flowing water. Uses {@link net.minecraft.world.level.material.FluidState#getFlow}
* — the same directional vector Minecraft computes internally — so currents always
* follow the actual water flow path, not a simulation approximation.
* Mountain rivers (level 7, close to source) push hard; lowland channels (level 1-2)
* are gentle enough to wade through. Heavy armour players notice the difference.
*/
private void applyRiverCurrents() {
if (minecraftServer == null) return;
for (ServerPlayer player : minecraftServer.getPlayerList().getPlayers()) {
if (!(player.level() instanceof ServerLevel level)) continue;
BlockPos pos = player.blockPosition();
var fluid = level.getFluidState(pos);
// Only flowing water — source blocks carry no directional current
if (!fluid.is(FluidTags.WATER) || fluid.isSource()) continue;
net.minecraft.world.phys.Vec3 flowVec = fluid.getFlow(level, pos);
if (flowVec.lengthSqr() < 0.0001) continue;
// Fluid amount: 8 = source, decreases with distance.
// Amount 7 = one block from source (fastest); amount 1 = farthest (slowest).
double flowStrength = fluid.getAmount() / 8.0;
double force = CURRENT_STRENGTH * flowStrength;
var movement = player.getDeltaMovement();
player.setDeltaMovement(
movement.x + flowVec.x * force,
movement.y,
movement.z + flowVec.z * force);
}
}
/**
* Plays a single ambient sound appropriate for the region's ecological state.
* Each sound fires at low volume and random pitch so it blends naturally.
@@ -397,8 +436,25 @@ public class LivingWorldMod {
*/
private boolean tryPlayRegionAmbience(
ServerPlayer player, SuccessionStage stage, double health, double pollution) {
float pitch = 0.75f + random.nextFloat() * 0.5f;
String dimId = player.level().dimension().location().toString();
int regionX = (int) Math.floor(player.getX() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
int regionZ = (int) Math.floor(player.getZ() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
RegionCoordinate coord = new RegionCoordinate(dimId, regionX, regionZ);
// Volcanic ambience — highest priority when eruption is active
String volcanoPhase = bootstrap.getVolcanoPhaseAt(coord);
if (volcanoPhase != null) {
if ("ERUPTING".equals(volcanoPhase) && random.nextInt(4) == 0) {
playAmbientSound(player, Holder.direct(SoundEvents.FIRE_AMBIENT), 0.55f, 0.8f + random.nextFloat() * 0.4f);
return true;
}
if ("BUILDING".equals(volcanoPhase) && random.nextInt(20) == 0) {
playAmbientSound(player, SoundEvents.AMBIENT_BASALT_DELTAS_MOOD, 0.25f, 0.7f + random.nextFloat() * 0.2f);
return true;
}
}
float pitch = 0.75f + random.nextFloat() * 0.5f;
if (stage != null && stage.ordinal() >= SuccessionStage.YOUNG_WOODLAND.ordinal()
&& health > 50.0 && random.nextInt(10) == 0) {
// Rustling leaves — healthy forest ambience.
@@ -83,6 +83,15 @@ public final class LivingWorldBootstrap {
private static final double RIVER_CARVE_THRESHOLD = 80.0;
/** Climate event checks run every N post-sim cycles to avoid per-tick overhead. */
private static final int CLIMATE_CHECK_INTERVAL = 8;
/** Groundwater level required for a valley spring to emerge. */
private static final double GROUNDWATER_THRESHOLD = 50.0;
/** Average surface elevation (Y) above which a region is treated as potentially volcanic. */
private static final double VOLCANIC_ELEVATION = 85.0;
/** Volcano state machine advances every N post-sim cycles. */
private static final int VOLCANO_TICK_INTERVAL = 16;
/** Lifecycle states for a volcanic region. */
private enum VolcanoPhase { DORMANT, BUILDING, ERUPTING, COOLING, FERTILE }
private double windAngle = 0.0;
private final Random windRandom = new Random();
@@ -108,6 +117,15 @@ public final class LivingWorldBootstrap {
private final List<String> pendingEventMessages = new ArrayList<>();
private int climateEventTick = 0;
// --- Groundwater aquifer (transient) ---
private final Map<RegionCoordinate, Double> groundwaterLevel = new HashMap<>();
// --- Volcano lifecycle (transient — re-derives from elevation on restart) ---
private final Map<RegionCoordinate, VolcanoPhase> volcanoPhase = new HashMap<>();
private final Map<RegionCoordinate, Integer> volcanoTicks = new HashMap<>();
private final Set<RegionCoordinate> volcanicRegions = new HashSet<>();
private int volcanicActivityTick = 0;
private PlatformAdapter platformAdapter;
private Path worldSaveDirectory;
private ServiceRegistry services;
@@ -259,6 +277,11 @@ public final class LivingWorldBootstrap {
activeClimateEvents.clear();
pendingEventMessages.clear();
climateEventTick = 0;
groundwaterLevel.clear();
volcanoPhase.clear();
volcanoTicks.clear();
volcanicRegions.clear();
volcanicActivityTick = 0;
simSpeedMultiplier = 1;
serverReady = false;
LivingWorldLogger.info(
@@ -410,12 +433,16 @@ public final class LivingWorldBootstrap {
climateTracker.update(regionManager.getActiveRegions());
applyClimateWarmingEffects();
applyWaterRunoff();
applyGroundwaterAndSprings();
applyDynamicCapUpdate();
applySeedDispersal();
recordHealthTrend();
if (++climateEventTick % CLIMATE_CHECK_INTERVAL == 0) {
applyClimateEvents();
}
if (++volcanicActivityTick % VOLCANO_TICK_INTERVAL == 0) {
applyVolcanicActivity();
}
}
/** Sets the simulation speed multiplier (1 = real-time, max 100). */
@@ -547,6 +574,272 @@ public final class LivingWorldBootstrap {
}
}
/**
* Tracks subsurface groundwater per region: recharges from rainfall and lateral seepage
* from uphill neighbours. When the aquifer level is high in a valley (lower elevation
* than ≥2 neighbours), a GROUND_SPRING_EMERGES effect is queued — placing a permanent
* water source that flows naturally downhill under Minecraft's own fluid physics.
*/
private void applyGroundwaterAndSprings() {
Collection<Region> active = regionManager.getActiveRegions();
if (active.isEmpty()) return;
int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (Region region : active) {
RegionCoordinate coord = region.getCoordinate();
AtmosphereRegionData atm = region.getModuleData()
.get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null);
Double myElev = regionElevations.get(coord);
// Recharge from rainfall
if (atm != null && atm.getRainLevel() > 0.1) {
groundwaterLevel.merge(coord, atm.getRainLevel() * 0.008, Double::sum);
}
// Lateral underground seepage from higher neighbours
if (myElev != null) {
for (int[] off : offsets) {
RegionCoordinate nCoord = new RegionCoordinate(
coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]);
Double nElev = regionElevations.get(nCoord);
if (nElev != null && nElev > myElev + 3) {
double seepage = 0.002 * Math.min((nElev - myElev) / 50.0, 1.0);
groundwaterLevel.merge(coord, seepage, Double::sum);
}
}
}
// Slow evaporation / natural discharge
double level = Math.max(0, groundwaterLevel.getOrDefault(coord, 0.0) - 0.001);
groundwaterLevel.put(coord, level);
// Spring emergence: valley floor with saturated aquifer
if (level >= GROUNDWATER_THRESHOLD && myElev != null) {
int higherNeighbours = 0;
for (int[] off : offsets) {
RegionCoordinate nCoord = new RegionCoordinate(
coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]);
Double nElev = regionElevations.get(nCoord);
if (nElev != null && nElev > myElev + 5) higherNeighbours++;
}
if (higherNeighbours >= 2 && worldEffectsModule != null) {
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.GROUND_SPRING_EMERGES, coord,
Math.min(1.0, (level - GROUNDWATER_THRESHOLD) / 50.0 + 0.3)));
// Discharge: spring reduces aquifer level
groundwaterLevel.put(coord, level - 15.0);
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
if (water != null) {
water.setWaterAvailability(Math.min(100, water.getWaterAvailability() + 5.0));
water.setDroughtRisk(Math.max(0, water.getDroughtRisk() - 3.0));
region.getModuleData().put(WaterModule.MODULE_ID, water);
}
// Active spring contributes to river erosion even without rain
riverFlowIntensity.merge(coord, 10.0, Double::sum);
regionManager.markDirty(region);
}
}
// Hydraulic erosion fires when the region has meaningful flow intensity
// from any source (rainfall runoff OR active spring), independent of rain
double flowHere = riverFlowIntensity.getOrDefault(coord, 0.0);
if (flowHere > 20.0 && worldEffectsModule != null) {
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.HYDRAULIC_EROSION, coord,
Math.min(1.0, flowHere / (RIVER_CARVE_THRESHOLD * 2))));
}
}
}
/**
* Advances the volcanic lifecycle for all high-elevation regions:
* DORMANT → BUILDING → ERUPTING → COOLING → FERTILE → DORMANT.
* Each phase applies physical world effects and ecosystem data changes.
*/
private void applyVolcanicActivity() {
Collection<Region> active = regionManager.getActiveRegions();
if (active.isEmpty()) return;
Map<RegionCoordinate, Region> byCoord = new HashMap<>();
for (Region r : active) byCoord.put(r.getCoordinate(), r);
int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (Region region : active) {
RegionCoordinate coord = region.getCoordinate();
Double elev = regionElevations.get(coord);
if (elev == null) continue;
// Register high-elevation regions as potentially volcanic on first encounter
if (!volcanoPhase.containsKey(coord)) {
if (elev < VOLCANIC_ELEVATION) continue;
volcanicRegions.add(coord);
volcanoPhase.put(coord, VolcanoPhase.DORMANT);
volcanoTicks.put(coord, 0);
}
VolcanoPhase phase = volcanoPhase.get(coord);
int ticks = volcanoTicks.getOrDefault(coord, 0);
volcanoTicks.put(coord, ticks + 1);
switch (phase) {
case DORMANT -> {
// Rare stochastic chance to awaken (~0.2% per check)
if (windRandom.nextDouble() < 0.002) {
volcanoPhase.put(coord, VolcanoPhase.BUILDING);
volcanoTicks.put(coord, 0);
pendingEventMessages.add("[LW] 🌋 Seismic activity at mountain region ("
+ coord.x() + "," + coord.z() + ") — a volcano may be awakening...");
}
}
case BUILDING -> {
// Venting gases: mild pollution increase; 30 cycles before eruption
PollutionRegionData poll = region.getModuleData()
.get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null);
if (poll != null) {
poll.addPollution(0.3, 0.1, 0);
region.getModuleData().put(PollutionModule.MODULE_ID, poll);
regionManager.markDirty(region);
}
if (ticks >= 30) {
volcanoPhase.put(coord, VolcanoPhase.ERUPTING);
volcanoTicks.put(coord, 0);
pendingEventMessages.add("[LW] 🌋 VOLCANIC ERUPTION at region ("
+ coord.x() + "," + coord.z() + ")! Lava and ash spreading now!");
}
}
case ERUPTING -> {
// Lava flows and ash — physical block changes
if (worldEffectsModule != null) {
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.LAVA_FLOW, coord,
Math.min(1.0, 0.4 + (ticks / 20.0) * 0.6)));
if (windRandom.nextDouble() < 0.40) {
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.ASH_DEPOSIT, coord, 0.7));
}
if (windRandom.nextDouble() < 0.25) {
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.VEGETATION_DIES, coord, 0.9));
}
}
// Ash cloud spreads to adjacent regions
for (int[] off : offsets) {
RegionCoordinate nCoord = new RegionCoordinate(
coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]);
Region nb = byCoord.get(nCoord);
if (nb == null) continue;
if (worldEffectsModule != null && windRandom.nextDouble() < 0.3) {
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.ASH_DEPOSIT, nCoord, 0.3));
}
PollutionRegionData nbPoll = nb.getModuleData()
.get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null);
if (nbPoll != null) {
nbPoll.addPollution(0.8, 0.2, 0);
nb.getModuleData().put(PollutionModule.MODULE_ID, nbPoll);
regionManager.markDirty(nb);
}
}
// Eruption zone: spike air pollution, drain vegetation
PollutionRegionData poll = region.getModuleData()
.get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null);
if (poll != null) {
poll.addPollution(2.0, 1.0, 0.5);
region.getModuleData().put(PollutionModule.MODULE_ID, poll);
}
VegetationRegionData veg = region.getModuleData()
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElse(null);
if (veg != null) {
veg.setGrassPressure(Math.max(0, veg.getGrassPressure() - 5));
veg.setTreePressure(Math.max(0, veg.getTreePressure() - 3));
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
}
regionManager.markDirty(region);
// Eruption lasts ~40 cycles
if (ticks >= 40) {
volcanoPhase.put(coord, VolcanoPhase.COOLING);
volcanoTicks.put(coord, 0);
pendingEventMessages.add("[LW] 🌋 Eruption subsiding at ("
+ coord.x() + "," + coord.z() + ") — lava cooling and solidifying...");
}
}
case COOLING -> {
// Lava solidifies — new land forms from cooling rock
if (worldEffectsModule != null && windRandom.nextDouble() < 0.5) {
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.COBBLESTONE_FORMS, coord, 0.6));
}
if (ticks >= 30) {
volcanoPhase.put(coord, VolcanoPhase.FERTILE);
volcanoTicks.put(coord, 0);
pendingEventMessages.add("[LW] 🌱 Volcanic soil enriching region ("
+ coord.x() + "," + coord.z() + ") — fertile basalt plain emerging!");
}
}
case FERTILE -> {
// One-time volcanic mineral fertility boost in the first 10 cycles
if (ticks < 10) {
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null);
if (soil != null) {
soil.setFertility(Math.min(100, soil.getFertility() + 30));
soil.setContamination(Math.max(0, soil.getContamination() - 20));
region.getModuleData().put(SoilModule.MODULE_ID, soil);
}
}
// Pollution clears naturally as fresh minerals neutralise it
if (ticks < 20) {
PollutionRegionData poll = region.getModuleData()
.get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null);
if (poll != null) {
poll.addPollution(-1.0, -0.5, -0.3);
region.getModuleData().put(PollutionModule.MODULE_ID, poll);
}
}
regionManager.markDirty(region);
if (ticks >= 50) {
volcanoPhase.put(coord, VolcanoPhase.DORMANT);
volcanoTicks.put(coord, 0);
}
}
}
}
}
/** Returns the current volcano phase name for the given region, or null if not volcanic. */
public String getVolcanoPhaseAt(RegionCoordinate coord) {
if (coord == null) return null;
VolcanoPhase phase = volcanoPhase.get(coord);
return phase != null ? phase.name() : null;
}
/**
* Returns a formatted string listing all tracked volcanic regions and their current phase.
* Used by {@code /lw volcanoes}.
*/
public String getVolcanoStatusString() {
if (volcanicRegions.isEmpty()) return "[LW] No volcanic regions detected yet (requires high-elevation terrain).";
StringBuilder sb = new StringBuilder("[LW] Volcanic regions: ").append(volcanicRegions.size()).append("\n");
for (RegionCoordinate coord : volcanicRegions) {
VolcanoPhase phase = volcanoPhase.getOrDefault(coord, VolcanoPhase.DORMANT);
int ticks = volcanoTicks.getOrDefault(coord, 0);
Double elev = regionElevations.get(coord);
sb.append(" (").append(coord.x()).append(",").append(coord.z()).append(") ")
.append(phase.name())
.append(" — ticks: ").append(ticks);
if (elev != null) sb.append(", elev: ").append(String.format("%.0f", elev));
sb.append("\n");
}
return sb.toString().trim();
}
/**
* Returns a snapshot of all active climate events for the {@code /lw events} command.
*/
public List<ClimateEvent> getActiveClimateEventSnapshot() {
return List.copyOf(activeClimateEvents);
}
/**
* Stores the sampled average surface elevation for a region so that
* {@link #applyWaterRunoff()} can model water flowing downhill between neighbours.
@@ -1228,7 +1521,26 @@ public final class LivingWorldBootstrap {
this::getAtmosphereStatusFor,
this::getClimateStatusFor,
this::setSimSpeedMultiplier,
this::getWindInfo);
this::getWindInfo,
css -> getEventsStatusFor(),
this::getVolcanoStatusString);
}
/** Returns a formatted string of all active climate events for {@code /lw events}. */
public String getEventsStatusFor() {
List<ClimateEvent> events = getActiveClimateEventSnapshot();
if (events.isEmpty()) return "[LW] No active climate events.";
StringBuilder sb = new StringBuilder("[LW] Active Climate Events: ")
.append(events.size()).append("\n");
for (ClimateEvent ev : events) {
sb.append(" ").append(ev.getType().displayName())
.append(" at (").append(ev.getEpicenter().x()).append(",").append(ev.getEpicenter().z()).append(")")
.append(" | ticks: ").append(ev.getTicksActive())
.append(" | severity: ").append(String.format("%.0f%%", ev.getSeverity() * 100))
.append(" | regions: ").append(ev.getAffectedRegions().size())
.append("\n");
}
return sb.toString().trim();
}
public Path getWorldSaveDirectory() {
@@ -4,7 +4,8 @@ public enum ClimateEventType {
DROUGHT("Drought", "Prolonged dry spell spreading across adjacent regions"),
WILDFIRE("Wildfire", "Fire spreading through drought-stressed forest regions"),
FLOOD("Flood", "Heavy rainfall overwhelming low-lying terrain");
FLOOD("Flood", "Heavy rainfall overwhelming low-lying terrain"),
VOLCANIC_ERUPTION("Volcanic Eruption", "Lava flows and ash clouds from an active volcano");
private final String displayName;
private final String description;
@@ -44,7 +44,9 @@ public final class LivingWorldCommandRoot {
css -> "Atmosphere not available.",
css -> "Climate not available.",
n -> n,
() -> "Wind not available.");
() -> "Wind not available.",
css -> "Events not available.",
() -> "No volcanic regions.");
}
public static void registerDeferred(
@@ -56,7 +58,9 @@ public final class LivingWorldCommandRoot {
Function<CommandSourceStack, String> atmosphereStatus,
Function<CommandSourceStack, String> climateStatus,
IntUnaryOperator speedSetter,
Supplier<String> windStatus) {
Supplier<String> windStatus,
Function<CommandSourceStack, String> eventsStatus,
Supplier<String> volcanoStatus) {
if (dispatcher == null) {
throw new IllegalArgumentException("dispatcher must not be null");
}
@@ -154,7 +158,19 @@ public final class LivingWorldCommandRoot {
})))
.then(LivingWorldMapCommand.build(regionManager))
.then(RegionSetCommand.build(regionManager))
.then(EcoDemoCommand.build(regionManager)));
.then(EcoDemoCommand.build(regionManager))
.then(Commands.literal("events")
.executes(context -> {
String info = eventsStatus.apply(context.getSource());
context.getSource().sendSuccess(() -> Component.literal(info), false);
return 1;
}))
.then(Commands.literal("volcanoes")
.executes(context -> {
String info = volcanoStatus.get();
context.getSource().sendSuccess(() -> Component.literal(info), false);
return 1;
})));
}
private static int toggleHud(CommandSourceStack source, Function<UUID, Boolean> hudToggle) {
@@ -67,4 +67,39 @@ public enum WorldEffectType {
* through the region, gradually carving a visible riverbed over many cycles.
*/
RIVER_CARVE,
/**
* A groundwater spring emerges at the valley floor: a water source block is placed
* at the lowest natural terrain point in the region. The water then flows downhill
* under Minecraft's own fluid physics, forming a permanent river without relying on rain.
*/
GROUND_SPRING_EMERGES,
/**
* Flowing water adjacent to soft terrain gradually softens and removes it: stone
* becomes gravel, gravel becomes sand, sand and dirt are removed. Widens river
* valleys and carves gorges independently of rainfall.
*/
HYDRAULIC_EROSION,
/**
* Active volcanic eruption: lava source blocks are placed at the summit and on
* natural rock surfaces. Lava flows downhill under Minecraft physics, permanently
* reshaping the terrain around the volcano.
*/
LAVA_FLOW,
/**
* Volcanic ash clouds deposit a grey layer over the surrounding landscape: grass
* and soil surfaces are covered with tuff and gravel, surface plants are killed.
* Spreads outward from the eruption epicentre.
*/
ASH_DEPOSIT,
/**
* Cooling lava solidifies into new land: lava adjacent to water converts to
* cobblestone, lava exposed to air converts to basalt. Creates permanent new
* terrain that was not there before the eruption.
*/
COBBLESTONE_FORMS,
}
@@ -17,6 +17,7 @@ import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.tags.BlockTags;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
@@ -76,6 +77,16 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
formWaterPool(level, baseX, baseZ, request.intensity());
case RIVER_CARVE ->
carveRiverChannel(level, baseX, baseZ, request.intensity());
case GROUND_SPRING_EMERGES ->
groundSpringEmerges(level, baseX, baseZ, request.intensity());
case HYDRAULIC_EROSION ->
hydraulicErosion(level, baseX, baseZ, request.intensity());
case LAVA_FLOW ->
lavaFlow(level, baseX, baseZ, request.intensity());
case ASH_DEPOSIT ->
ashDeposit(level, baseX, baseZ, request.intensity());
case COBBLESTONE_FORMS ->
cobblestoneFromLava(level, baseX, baseZ, request.intensity());
}
}
@@ -344,6 +355,168 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
}
}
/**
* Places a permanent water source at the lowest natural terrain point found in the
* region. The source then flows downhill under vanilla water physics, forming a river
* without any further simulation involvement.
*/
private void groundSpringEmerges(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.30) return;
int samples = 20;
BlockPos lowestPos = null;
for (int i = 0; i < samples; i++) {
BlockPos candidate = surfaceAt(level,
baseX + random.nextInt(REGION_BLOCKS),
baseZ + random.nextInt(REGION_BLOCKS));
if (candidate != null && (lowestPos == null || candidate.getY() < lowestPos.getY())) {
lowestPos = candidate;
}
}
if (lowestPos == null) return;
var surface = level.getBlockState(lowestPos);
if (!surface.is(Blocks.DIRT) && !surface.is(Blocks.GRASS_BLOCK)
&& !surface.is(Blocks.SAND) && !surface.is(Blocks.GRAVEL)
&& !surface.is(Blocks.COARSE_DIRT)) return;
BlockPos springPos = lowestPos.above();
if (!level.isLoaded(springPos)) return;
if (!level.getBlockState(springPos).isAir()) return;
level.setBlock(springPos, Blocks.WATER.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect GROUND_SPRING_EMERGES at " + springPos);
}
/**
* Flowing water (level 17) adjacent to soft rock gradually softens the terrain:
* stone → gravel → sand, sand/dirt → air. Widens river valleys over time without
* requiring rainfall; only fires ~15% of the time per call.
*/
private void hydraulicErosion(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.15) return;
int attempts = Math.max(1, (int) (intensity * 6));
for (int i = 0; i < attempts; i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
int surfaceY = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z) - 1;
if (surfaceY < level.getMinBuildHeight()) continue;
// Scan downward for flowing water
for (int y = surfaceY; y > surfaceY - 6; y--) {
BlockPos pos = new BlockPos(x, y, z);
if (!level.isLoaded(pos)) break;
var fluidState = level.getFluidState(pos);
if (!fluidState.is(FluidTags.WATER) || fluidState.isSource()) continue;
// Found flowing water — soften adjacent horizontal blocks
for (Direction dir : Direction.Plane.HORIZONTAL) {
BlockPos adj = pos.relative(dir);
if (!level.isLoaded(adj)) continue;
var adjState = level.getBlockState(adj);
if (adjState.is(Blocks.STONE) || adjState.is(Blocks.DEEPSLATE)) {
if (intensity > 0.65) level.setBlock(adj, Blocks.GRAVEL.defaultBlockState(), Block.UPDATE_ALL);
} else if (adjState.is(Blocks.GRAVEL) && intensity > 0.45) {
level.setBlock(adj, Blocks.SAND.defaultBlockState(), Block.UPDATE_ALL);
} else if ((adjState.is(Blocks.SAND) || adjState.is(Blocks.DIRT)
|| adjState.is(Blocks.COARSE_DIRT)) && intensity > 0.75) {
level.setBlock(adj, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
}
break;
}
}
}
/**
* Active volcanic eruption: places lava source blocks at random high-surface positions.
* Lava flows downhill under vanilla physics, permanently reshaping terrain.
* Fires ~20% of the time per call so flows build gradually.
*/
private void lavaFlow(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.20) return;
int attempts = Math.max(1, (int) (intensity * 3));
for (int i = 0; i < attempts; i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z) - 1;
if (y < level.getMinBuildHeight()) continue;
BlockPos pos = new BlockPos(x, y, z);
if (!level.isLoaded(pos)) continue;
var state = level.getBlockState(pos);
boolean naturalRock = state.is(Blocks.STONE) || state.is(Blocks.DEEPSLATE)
|| state.is(Blocks.ANDESITE) || state.is(Blocks.DIORITE)
|| state.is(Blocks.GRANITE) || state.is(Blocks.GRAVEL)
|| state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.DIRT)
|| state.is(Blocks.TUFF);
if (!naturalRock) continue;
BlockPos above = pos.above();
if (!level.isLoaded(above) || !level.getBlockState(above).isAir()) continue;
level.setBlock(above, Blocks.LAVA.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect LAVA_FLOW at " + above);
}
}
/**
* Deposits volcanic ash: grass → tuff, loose soil → gravel; surface plants are killed.
* Fires ~25% of the time; applied over wide areas during eruptions.
*/
private void ashDeposit(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.25) return;
int attempts = Math.max(1, (int) (intensity * BLOCK_ATTEMPTS));
for (int i = 0; i < attempts; i++) {
BlockPos pos = surfaceAt(level,
baseX + random.nextInt(REGION_BLOCKS),
baseZ + random.nextInt(REGION_BLOCKS));
if (pos == null) continue;
var state = level.getBlockState(pos);
if (state.is(Blocks.GRASS_BLOCK)) {
level.setBlock(pos, Blocks.TUFF.defaultBlockState(), Block.UPDATE_ALL);
} else if (state.is(Blocks.DIRT) || state.is(Blocks.COARSE_DIRT)) {
level.setBlock(pos, Blocks.GRAVEL.defaultBlockState(), Block.UPDATE_ALL);
}
BlockPos above = pos.above();
if (!level.isLoaded(above)) continue;
var aboveState = level.getBlockState(above);
if (aboveState.is(Blocks.SHORT_GRASS) || aboveState.is(Blocks.TALL_GRASS)
|| aboveState.is(Blocks.FERN) || aboveState.is(BlockTags.SAPLINGS)
|| aboveState.is(Blocks.DANDELION) || aboveState.is(Blocks.POPPY)) {
level.setBlock(above, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
}
}
/**
* Solidifies cooling lava into new permanent land: lava adjacent to water → cobblestone;
* lava exposed to air → basalt. This creates terrain that did not exist before the eruption.
* Fires ~35% of the time per call.
*/
private void cobblestoneFromLava(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.35) return;
int attempts = Math.max(2, (int) (intensity * 8));
for (int i = 0; i < attempts; i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
int surfY = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z);
for (int y = surfY + 2; y >= Math.max(level.getMinBuildHeight(), surfY - 4); y--) {
BlockPos pos = new BlockPos(x, y, z);
if (!level.isLoaded(pos)) continue;
if (!level.getBlockState(pos).is(Blocks.LAVA)) continue;
boolean adjacentWater = false;
for (Direction dir : new Direction[]{
Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST, Direction.UP}) {
BlockPos adj = pos.relative(dir);
if (level.isLoaded(adj) && level.getFluidState(adj).is(FluidTags.WATER)) {
adjacentWater = true;
break;
}
}
if (adjacentWater) {
level.setBlock(pos, Blocks.COBBLESTONE.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect COBBLESTONE_FORMS at " + pos);
} else if (random.nextDouble() < 0.40) {
level.setBlock(pos, Blocks.BASALT.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect COBBLESTONE_FORMS basalt at " + pos);
}
break;
}
}
}
private BlockPos surfaceAt(ServerLevel level, int x, int z) {
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1;
if (y < level.getMinBuildHeight()) {