Add seasons, climate events, river erosion, tides, /lw map, trend tracking, desertification permanence, biome init

New systems (all with real in-world effects):
- Seasonal variation: spring growth surge, summer evaporation, autumn leaf fall, winter die-back — all affect actual VegetationRegionData/WaterRegionData/SoilRegionData each cycle
- Multi-region climate events: drought spreads across dry neighbours; wildfire triggers WILDFIRE + VEGETATION_DIES block effects and air pollution; flood places WATER_POOL_FORMS in downstream regions — all broadcast to players
- River erosion: accumulated runoff intensity triggers RIVER_CARVE world effect — water source blocks placed on natural terrain, channel depth increases over time (actual block changes)
- Tidal simulation: two tidal cycles per Minecraft day; rising/falling tide physically places/removes water source blocks on sand/gravel/stone shoreline at sea level
- Biome-aware initialisation: on first region entry, biome temperature + downfall set realistic soil fertility/moisture and water availability starting values
- Desertification permanence: sustained damage > 65 + health < 20 lowers succession cap by one stage; players notified via chat
- Region trend tracking: last 5 health samples per region; ↑↓→ arrow in compass HUD and /lw atmosphere output
- Seed rain in HUD: shows accumulated seed rain when > 0.5
- /lw map [radius]: ASCII coloured region grid showing succession stages for all nearby loaded regions
- Season enum expanded: temperatureMod, droughtMod, vegGrowthMod fields used by enhanced applySeasonalEffects()
- WorldEffectsModule: queueEffect() public method for external callers (bootstrap hooks, climate events)
- WorldEffectType: RIVER_CARVE type added
- README.md: comprehensive feature list, command reference, pipeline diagram, succession table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George
2026-06-09 21:01:03 +01:00
parent b68390983c
commit 113741abd6
11 changed files with 1134 additions and 24 deletions
+326
View File
@@ -0,0 +1,326 @@
# Living World
A NeoForge 1.21.1 ecosystem simulation mod that turns every region of your world into a living, breathing environment. Pollution, soil quality, water cycles, vegetation succession, climate events, tidal systems, and river erosion interact in a fully simulated pipeline — all visible as real block changes and player effects in-game.
---
## Overview
The world is divided into **regions** (configurable chunk-grid cells, default 16×16 chunks). Each region is independently simulated through a 9-stage pipeline every tick cycle. Everything from a single furnace firing to a multi-region wildfire can alter the ecological trajectory of the land you stand on.
---
## Simulation Pipeline
Modules run in this fixed order each cycle so each stage reads fully updated data from the stage before it:
| # | Module | Role |
|---|--------|------|
| 1 | **Pollution** | Decays air/ground/water pollution; leaches ground → water |
| 2 | **Soil** | Updates fertility, moisture, contamination, compaction, erosion |
| 3 | **Water** | Tracks availability, purification, drought/flood risk |
| 4 | **Vegetation** | Succession: grass → flowers → shrubs → trees; die-off under stress |
| 5 | **Resource Depletion** | Tracks mining, logging, farming pressure |
| 6 | **Recovery** | Manages ecological succession stage (BARREN → MATURE_FOREST) |
| 7 | **Ecosystem** | Synthesises overall health, stress, resilience |
| 8 | **World Effects** | Translates simulation state into visible block changes |
| 9 | **Atmosphere** | Per-region weather (rain/thunder/acid rain) driven by health |
---
## Feature Status
### Core Ecosystem — **Complete**
- **Pollution system** — air, ground, and water pollution tracked independently; natural decay rates; tree canopy scrubs air pollution; ground leaches into water
- **Soil dynamics** — fertility, moisture, contamination, compaction, erosion; spring snowmelt and autumn leaf decomposition cycle
- **Water simulation** — availability, drought risk, flood risk, purification capacity; runoff flows downhill between regions carrying pollution downstream
- **Vegetation succession** — grass → flowers → shrubs → trees; each tier requires the tier below; severe stress kills trees before shrubs before grass
- **Recovery & succession** — six ecological stages (BARREN → SPARSE_GRASS → GRASSLAND → SCRUBLAND → YOUNG_WOODLAND → MATURE_FOREST); progress/damage system; biome-derived succession ceiling
- **Resource depletion** — logging, mining, farming each drain their respective region metrics
- **Ecosystem health** — synthesised score (0100) based on all module outputs; feeds spawn suppression, player effects, ambient sounds
### Visible World Effects — **Complete**
All effects are real block changes players can see:
| Effect | Trigger | What You See |
|--------|---------|--------------|
| Grass degrades to dirt | Pollution > 15, soil < 75 | Grass → dirt → coarse dirt |
| Vegetation spreads | Veg pressure > 60, soil > 50 | Dirt → grass, flowers, short grass |
| Vegetation dies | Soil < 20 OR pollution > 30 | Leaves and upper trunks removed; stumps left |
| Saplings boosted | Stage ≥ YOUNG_WOODLAND | Oak/birch/spruce/dark-oak/cherry saplings placed |
| 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 |
| Pollution particles | Pollution > 10 | Smoke particles in degraded regions |
### Player Feedback — **Complete**
- **Compass HUD** — hold a compass (or `/lw hud`) to see the action-bar HUD:
`[LW] (0,0)↑ Eco:72 Poll:3.2 Soil:61 Wat:58 Rain:42% Seeds:1.8`
- Trend arrow (↑↓→) shows whether the region is recovering or declining
- Seed rain displayed when active
- Colour-coded (green/yellow/red) for each metric
- **Pollution effects** — nausea > 40, slowness > 60, weakness > 80 pollution; smoke fog particles
- **Mob spawn feedback** — passive mobs suppressed in regions below 30 health; hostile mobs suppressed above 60 health
- **Regional weather** — per-player rain/thunder packets matching region atmosphere; storm sends lightning visuals
- **Ambient sounds** — forest: rustling azalea leaves; barren: shifting sand; heavy pollution: basalt deltas mood
- **Climate event broadcasts** — drought, wildfire, flood events announced to all players
### Seasons — **Complete**
32-day year (8 days per season). Seasons affect all simulation layers:
| Season | Soil | Water | Vegetation | Drought |
|--------|------|-------|------------|---------|
| **Spring** | Fertility +, moisture ++ | Availability ++ | Grass/flower surge | Eases |
| **Summer** | Moisture — | Availability — (evaporation) | Mild growth (if moist) | Worsens |
| **Autumn** | Fertility + (decomposition) | — | Senescence, dead litter | Neutral |
| **Winter** | Moisture — (frost) | Availability — (freeze) | Die-back, shrubs retreat | Creeps up |
### Ecological Corridors & Seed Dispersal — **Complete**
- Healthy regions (YOUNG_WOODLAND+) emit seed rain each cycle to adjacent regions
- **Corridor boost**: ≥2 healthy neighbours → 3.5× seed effectiveness
- Pollution in target region blocks germination (proportional)
- Sufficient accumulated seed rain pioneers the succession cap one stage ahead of physical conditions
- Seed rain accumulation visible in HUD and `/lw atmosphere`
### Pollution Spreading — **Complete**
- Air pollution spreads to adjacent regions each cycle based on wind direction
- Wind drifts realistically (±0.05 rad per cycle); `/lw wind` shows direction and spread rate
- Downwind spread is boosted; upwind transfer is reduced
- Water runoff also carries ground pollution downstream (15% of runoff × ground pollution)
### Climate Events — **Complete** (multi-region)
Automatically triggered when regional conditions reach critical thresholds. All have visible in-world effects.
#### Drought
- **Trigger**: water availability < 20, drought risk > 65, with 2+ dry neighbours
- **Effect**: further drains water availability and raises drought risk across affected regions; spreads to adjacent dry regions every 5 cycles
- **Resolution**: epicentre water availability recovers above 45
#### Wildfire
- **Trigger**: YOUNG_WOODLAND+ region, drought risk > 65, no rain, air pollution > 25 (stochastic)
- **Effect**: `VEGETATION_DIES` and `WILDFIRE` block effects (actual fire!) on affected regions; air pollution spike; spreads to adjacent drought-stressed forest
- **Resolution**: rain level > 0.4 in epicentre
#### Flood
- **Trigger**: water availability > 92, rain > 0.72, with a lower-elevation downstream neighbour
- **Effect**: `WATER_POOL_FORMS` in downstream regions; soil contamination from silt
- **Resolution**: water availability drops below 70 or rain eases
### Desertification Permanence — **Complete**
- Regions with sustained heavy damage (damage accumulation > 65, health < 20) have their succession cap **lowered** by one stage
- This means a desertified region can no longer recover to forest on its own — player intervention (removing pollution, adding water, bone meal, replanting) is required
- Players are notified via chat when desertification lowers a cap
### Region Trend Tracking — **Complete**
- Last 5 health samples per region stored each post-sim cycle
- Trend arrow (↑↓→) computed from recent vs older average
- Visible in the compass HUD next to the region coordinates
- Full trend detail in `/lw atmosphere`
### River 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
- Rivers form gradually over many sim cycles — permanent terrain changes
### Tidal Simulation — **Complete** (physical blocks)
- Two tidal cycles per Minecraft day (24 000 ticks) using a sine wave
- Every 1 200 ticks (~1 real minute):
- **Coastal regions** (elevation ≤ sea level + 6) receive water-availability adjustments
- **Physical block changes**: on rising tide, water sources are placed on natural shoreline blocks (sand/gravel/stone) at sea level; on falling tide, those sources are removed
- Result: visible waterline that rises and falls on beaches and ocean shores
### Biome-Aware Initialisation — **Complete**
- When a player first enters a region, the biome temperature and downfall (wetness) are read
- Soil fertility, soil moisture, water availability, and drought risk are initialised to biome-appropriate starting values
- Deserts start arid and nutrient-poor; jungles/swamps start moist and fertile; mountains start cool and dry
- Biome-derived succession ceiling set simultaneously (desert = SPARSE_GRASS cap; forest = MATURE_FOREST cap)
### Config File — **Complete**
Server-side TOML: `world/serverconfig/livingworld-server.toml`
Tunable at runtime without recompiling:
```toml
[pollution]
decay_rate = 0.002
ground_to_water_leach = 0.0005
spread_rate = 0.02
wind_boost = 0.5
[vegetation]
grass_growth_rate = 0.06
flower_growth_rate = 0.04
shrub_growth_rate = 0.03
tree_growth_rate = 0.02
# ... dieoff rates
[recovery]
base_progress_per_tick = 2.0
damage_per_bad_tick = 1.5
damage_decay_rate = 0.05
[seed_dispersal]
seed_emission_rate = 0.01
corridor_boost_multiplier = 3.5
seed_pollution_block = 0.015
```
### Persistence — **Complete**
All 8 data-bearing modules use a Properties-based codec system. Region data survives server restart correctly. Global climate tracker (carbon ppm, warming level, biodiversity index) saved separately to `living_world/global_climate.dat`.
### Region Border Visualiser — **Complete**
`/lw region borders` — sends cyan dust particles along all 4 edges of the region at surface height. Optional `[regionX regionZ]` args to visualise a specific region.
---
## Commands
All commands require permission level 2.
| Command | Description |
|---------|-------------|
| `/lw status` | Show active regions, enabled modules, simulation tick |
| `/lw region info [x z]` | Full diagnostics for current or specified region |
| `/lw region borders [rx rz]` | Visualise region boundary with particles |
| `/lw region force-update` | Force an immediate simulation cycle on current region |
| `/lw region set <stat> <value>` | Manually override a region metric (debug) |
| `/lw map [radius]` | ASCII coloured region map (17 radius, default 3) |
| `/lw atmosphere` | Region atmosphere: season, rain, storm, succession, trend, seeds |
| `/lw climate` | Global climate: CO₂ ppm, warming °C, biodiversity, rain penalty |
| `/lw wind` | Wind direction, angle, spread rate, speed multiplier |
| `/lw hud` | Toggle persistent HUD without holding compass |
| `/lw speed <1100>` | Accelerate simulation (100x = very fast testing) |
| `/lw speed reset` | Return to real-time speed (1x) |
| `/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 demo degrade` | One-command demo: degrade current region to barren |
| `/lw demo recover` | One-command demo: restore current region to mature forest |
### /lw map Output Example
```
[LW] Map 7×7 around (0,0) | [X]=you, ↑N ↓S ←W →E
F F W W s · ·
F F W s G G ·
F W [X] G G g ·
W G G G g B ·
s G g B B · ·
· · · · · · ·
· · · · · · ·
F=Forest W=Woodland s=Scrub G=Grass g=Sparse B=Barren ·=Unknown
```
---
## Compass HUD Reference
Hold a compass (or `/lw hud`) to see the action-bar HUD:
```
[LW] (0,0)↑ Eco:72 Poll:3.2 Soil:61 Wat:58 Rain:42% Storm:8% Seeds:1.8
```
| Field | Meaning |
|-------|---------|
| `(0,0)` | Region coordinates |
| `↑↓→` | Ecosystem health trend (recovering / declining / stable) |
| `Eco` | Ecosystem health 0100 (green >60, yellow >30, red ≤30) |
| `Poll` | Pollution score 0100 (green <15, yellow <40, red ≥40) |
| `Soil` | Soil quality 0100 |
| `Wat` | Water quality 0100 |
| `Rain` | Regional rain level % |
| `Storm` | Thunder/storm level % (shown only when >15%) |
| `Seeds` | Accumulated seed rain (shown only when active) |
---
## Succession Stages
| Stage | Symbol | Conditions Required |
|-------|--------|---------------------|
| BARREN | `B` | — |
| SPARSE_GRASS | `g` | Soil ≥ 10, pollution ≤ 80, veg ≥ 10 |
| GRASSLAND | `G` | Soil ≥ 25, pollution ≤ 70, veg ≥ 20 |
| SCRUBLAND | `s` | Soil ≥ 40, pollution ≤ 60, veg ≥ 35 |
| YOUNG_WOODLAND | `W` | Soil ≥ 50, pollution ≤ 50, veg ≥ 45 |
| MATURE_FOREST | `F` | Soil ≥ 60, pollution ≤ 40, veg ≥ 55 |
Biome caps prevent a desert from reaching MATURE_FOREST naturally. Sustained damage (damage accumulation > 65, health < 20) can lower the cap permanently — **desertification**. Player intervention is required to restore the potential.
---
## Player Effects from Pollution
| Pollution Level | Effect |
|----------------|--------|
| > 40 | Nausea (Confusion) + smoke fog particles |
| > 60 | Slowness |
| > 80 | Weakness |
---
## Architecture
```
LivingWorldMod (NeoForge event wiring)
└─ LivingWorldBootstrap (lifecycle, post-sim hooks)
├─ SimulationManager → SimulationScheduler → RegionUpdateJob
├─ RegionManager → RegionStorage → FileRegionPersistenceService
├─ ModuleRegistry (9 modules, pipeline order)
├─ GlobalClimateTracker (CO₂, warming, biodiversity)
├─ EcosystemTuning (config-loaded tuning constants)
└─ Post-sim hooks:
spreadPollutionAcrossRegions() [wind-driven]
applySeasonalEffects() [soil/water/veg seasonal]
applyClimateWarmingEffects() [drought pressure from CO₂]
applyWaterRunoff() [elevation-based; river erosion]
applyDynamicCapUpdate() [raise/lower succession ceiling]
applySeedDispersal() [corridor-boosted recolonisation]
recordHealthTrend() [↑↓→ trend tracking]
applyClimateEvents() [drought/wildfire/flood events]
LivingWorldMod platform hooks:
updateTidalEffects() [block-level tidal simulation]
initializeRegionFromBiome() [biome-aware starting values]
checkPlayerRegions() [HUD, effects, ambient sounds]
scanAndRecordFurnaceActivity() [pollution from lit furnaces]
```
---
## Building
```bash
./gradlew build
```
Output: `build/libs/living_world-*.jar`
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
- Multiplayer region ownership / notification system
- More succession stages (wetland, alpine, volcanic)
@@ -95,11 +95,15 @@ public class LivingWorldMod {
/** Hostile mobs suppressed in regions with ecosystem health above this. */
private static final double HOSTILE_SUPPRESS_HEALTH = 60.0;
private static final int TIDE_CHECK_INTERVAL = 1200; // ~1 real minute
private final LivingWorldBootstrap bootstrap;
private final Random random = new Random();
private MinecraftServer minecraftServer;
private int furnaceScanTick = 0;
private int playerCheckTick = 0;
private int tideTick = 0;
private double lastTideLevel = 0.0;
private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>();
private final Map<UUID, Boolean> playerRainState = new HashMap<>();
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
@@ -151,6 +155,8 @@ public class LivingWorldMod {
waterBodyLastScan.clear();
elevationInitialized.clear();
regionSoundLastTick.clear();
tideTick = 0;
lastTideLevel = 0.0;
this.minecraftServer = null;
});
@@ -162,6 +168,19 @@ public class LivingWorldMod {
if (++playerCheckTick % PLAYER_CHECK_INTERVAL == 0) {
checkPlayerRegions();
}
if (++tideTick >= TIDE_CHECK_INTERVAL) {
tideTick = 0;
updateTidalEffects();
}
// Broadcast any climate event messages queued by the bootstrap
java.util.List<String> eventMsgs = bootstrap.pollClimateEventMessages();
if (!eventMsgs.isEmpty()) {
for (String msg : eventMsgs) {
minecraftServer.getPlayerList().broadcastSystemMessage(
net.minecraft.network.chat.Component.literal(msg)
.withStyle(ChatFormatting.YELLOW), false);
}
}
});
// Step 1: Mob spawn feedback — passive mobs suppressed in degraded regions,
@@ -270,10 +289,12 @@ public class LivingWorldMod {
bootstrap.notifyPlayerInRegion(coord);
if (player.level() instanceof ServerLevel serverLevel) {
// Biome-aware succession cap — derived once per region on first entry.
// Biome-aware succession cap and initial soil/water values —
// derived once per region on first player entry.
if (!biomeInitialized.contains(coord)) {
SuccessionStage cap = deriveBiomeCap(serverLevel, coord);
bootstrap.setRegionBiomeCap(coord, cap);
initializeRegionFromBiome(serverLevel, coord);
biomeInitialized.add(coord);
}
// Elevation sampling — derived once per region; feeds water runoff physics.
@@ -407,6 +428,107 @@ public class LivingWorldMod {
vol, pitch, player.getRandom().nextLong()));
}
// -----------------------------------------------------------------------
// Tidal simulation
// -----------------------------------------------------------------------
/**
* Runs every {@link #TIDE_CHECK_INTERVAL} ticks. Computes the current tidal level
* from the in-game time (two full tidal cycles per Minecraft day) and:
* <ol>
* <li>Adjusts coastal region water-availability scores so droughts ease during high tide.</li>
* <li>Physically places/removes water source blocks at the shoreline so the tidal
* boundary moves visibly up and down by one block over the cycle.</li>
* </ol>
*/
private void updateTidalEffects() {
if (minecraftServer == null) return;
ServerLevel overworld = minecraftServer.overworld();
long dayTime = overworld.getDayTime();
// Two complete tidal cycles per Minecraft day (24 000 ticks)
double tideLevel = Math.sin(dayTime / 12000.0 * Math.PI); // -1 low … +1 high
double delta = tideLevel - lastTideLevel;
lastTideLevel = tideLevel;
int seaLevel = overworld.getSeaLevel();
boolean risingTide = delta > 0.05;
boolean fallingTide = delta < -0.05;
for (Region region : bootstrap.getActiveRegions()) {
if (!region.getCoordinate().dimensionId().equals("minecraft:overworld")) continue;
Double elev = bootstrap.getRegionElevation(region.getCoordinate());
if (elev == null || elev > seaLevel + 6) continue; // only coastal/ocean
// Update data model (water availability)
bootstrap.applyTidalEffect(region.getCoordinate(), delta * 7.0);
// Physical block changes at the shoreline
if ((risingTide || fallingTide) && Math.abs(delta) > 0.15) {
applyTideBlocks(overworld, region.getCoordinate(), seaLevel, risingTide);
}
}
}
/**
* Scans sample positions in the region at sea level and places/removes water blocks
* to simulate the tidal boundary moving by one block.
*/
private void applyTideBlocks(ServerLevel level, RegionCoordinate coord, int seaLevel, boolean rising) {
int baseX = coord.x() * REGION_BLOCKS;
int baseZ = coord.z() * REGION_BLOCKS;
int scans = 12;
for (int i = 0; i < scans; i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
BlockPos tidePos = new BlockPos(x, seaLevel, z);
if (!level.isLoaded(tidePos)) continue;
var stateAtTide = level.getBlockState(tidePos);
var stateBelow = level.getBlockState(tidePos.below());
if (rising) {
// Rising tide: fill air at sea-level above natural shoreline blocks with water
if (stateAtTide.isAir()
&& (stateBelow.is(net.minecraft.world.level.block.Blocks.SAND)
|| stateBelow.is(net.minecraft.world.level.block.Blocks.GRAVEL)
|| stateBelow.is(net.minecraft.world.level.block.Blocks.STONE)
|| stateBelow.is(net.minecraft.world.level.block.Blocks.SANDSTONE))) {
level.setBlock(tidePos, net.minecraft.world.level.block.Blocks.WATER.defaultBlockState(),
net.minecraft.world.level.block.Block.UPDATE_ALL);
}
} else {
// Falling tide: remove water source blocks sitting on natural terrain at sea-level
if (stateAtTide.is(net.minecraft.world.level.block.Blocks.WATER)
&& stateAtTide.getFluidState().isSource()
&& (stateBelow.is(net.minecraft.world.level.block.Blocks.SAND)
|| stateBelow.is(net.minecraft.world.level.block.Blocks.GRAVEL)
|| stateBelow.is(net.minecraft.world.level.block.Blocks.STONE))) {
level.setBlock(tidePos, net.minecraft.world.level.block.Blocks.AIR.defaultBlockState(),
net.minecraft.world.level.block.Block.UPDATE_ALL);
}
}
}
}
// -----------------------------------------------------------------------
// Biome-aware region initialisation
// -----------------------------------------------------------------------
/**
* Reads biome temperature and downfall from the region centre block and passes them to
* the bootstrap so it can set realistic starting soil/water values for the region.
*/
private void initializeRegionFromBiome(ServerLevel level, RegionCoordinate coord) {
int cx = coord.x() * REGION_BLOCKS + REGION_BLOCKS / 2;
int cz = coord.z() * REGION_BLOCKS + REGION_BLOCKS / 2;
int cy = level.getHeight(net.minecraft.world.level.levelgen.Heightmap.Types.WORLD_SURFACE, cx, cz);
net.minecraft.core.Holder<net.minecraft.world.level.biome.Biome> biome =
level.getBiome(new BlockPos(cx, cy, cz));
float temp = biome.value().getBaseTemperature();
float downfall = biome.value().getModifiedClimateSettings().downfall();
bootstrap.initializeRegionBiomeValues(coord, temp, downfall);
}
private static final int REGION_BLOCKS =
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16;
@@ -480,9 +602,15 @@ public class LivingWorldMod {
ChatFormatting soilCol = soil > 50 ? ChatFormatting.GREEN : soil > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED;
ChatFormatting watCol = wat > 50 ? ChatFormatting.GREEN : wat > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED;
String trendArrow = bootstrap.getTrendArrow(coord);
ChatFormatting trendCol = "".equals(trendArrow) ? ChatFormatting.GREEN
: "".equals(trendArrow) ? ChatFormatting.RED
: ChatFormatting.GRAY;
var line = Component.empty()
.append(Component.literal("[LW] ").withStyle(ChatFormatting.GOLD))
.append(Component.literal(String.format("(%d,%d) ", coord.x(), coord.z())).withStyle(ChatFormatting.GRAY))
.append(Component.literal(String.format("(%d,%d)", coord.x(), coord.z())).withStyle(ChatFormatting.GRAY))
.append(Component.literal(trendArrow + " ").withStyle(trendCol))
.append(Component.literal("Eco:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.0f ", eco)).withStyle(ecoCol))
.append(Component.literal("Poll:").withStyle(ChatFormatting.WHITE))
@@ -504,6 +632,13 @@ public class LivingWorldMod {
.append(Component.literal(String.format("%.0f%%", thunder)).withStyle(stormCol));
}
}
double seedRain = bootstrap.getSeedRainAt(coord);
if (seedRain > 0.5) {
line.append(Component.literal(" Seeds:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.1f", seedRain)).withStyle(ChatFormatting.GREEN));
}
return line;
}
}
@@ -1,5 +1,7 @@
package com.livingworld.bootstrap;
import com.livingworld.climate.ClimateEvent;
import com.livingworld.climate.ClimateEventType;
import com.livingworld.commands.LivingWorldCommandRoot;
import com.livingworld.config.DefaultConfigService;
import com.livingworld.config.EcosystemTuning;
@@ -39,6 +41,8 @@ import com.livingworld.climate.GlobalClimateTracker;
import com.livingworld.modules.atmosphere.AtmosphereModule;
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
import com.livingworld.modules.atmosphere.Season;
import com.livingworld.modules.worldeffects.WorldEffectRequest;
import com.livingworld.modules.worldeffects.WorldEffectType;
import com.livingworld.modules.worldeffects.WorldEffectsModule;
import com.livingworld.platform.BlockBreakInfo;
import com.livingworld.platform.PlatformAdapter;
@@ -52,9 +56,11 @@ import com.livingworld.regions.cache.RegionCache;
import com.livingworld.regions.query.RegionQueryEngine;
import com.mojang.brigadier.CommandDispatcher;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.function.LongSupplier;
import java.util.Map;
@@ -71,6 +77,12 @@ import net.minecraft.commands.CommandSourceStack;
public final class LivingWorldBootstrap {
private static final double WIND_DRIFT_MAX = 0.05; // radians per sim cycle
/** Number of health samples retained per region for trend calculation. */
private static final int TREND_WINDOW = 5;
/** River erosion: accumulated runoff intensity before a RIVER_CARVE effect fires. */
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;
private double windAngle = 0.0;
private final Random windRandom = new Random();
@@ -84,6 +96,18 @@ public final class LivingWorldBootstrap {
private final Map<RegionCoordinate, Double> accumulatedSeedRain = new HashMap<>();
private final Set<UUID> hudEnabledPlayers = new HashSet<>();
// --- Trend tracking (transient, resets on restart) ---
private final Map<RegionCoordinate, double[]> healthHistory = new HashMap<>();
private final Map<RegionCoordinate, Integer> historyIndex = new HashMap<>();
// --- River erosion (transient) ---
private final Map<RegionCoordinate, Double> riverFlowIntensity = new HashMap<>();
// --- Climate events ---
private final List<ClimateEvent> activeClimateEvents = new ArrayList<>();
private final List<String> pendingEventMessages = new ArrayList<>();
private int climateEventTick = 0;
private PlatformAdapter platformAdapter;
private Path worldSaveDirectory;
private ServiceRegistry services;
@@ -229,6 +253,12 @@ public final class LivingWorldBootstrap {
hudEnabledPlayers.clear();
regionElevations.clear();
accumulatedSeedRain.clear();
healthHistory.clear();
historyIndex.clear();
riverFlowIntensity.clear();
activeClimateEvents.clear();
pendingEventMessages.clear();
climateEventTick = 0;
simSpeedMultiplier = 1;
serverReady = false;
LivingWorldLogger.info(
@@ -382,6 +412,10 @@ public final class LivingWorldBootstrap {
applyWaterRunoff();
applyDynamicCapUpdate();
applySeedDispersal();
recordHealthTrend();
if (++climateEventTick % CLIMATE_CHECK_INTERVAL == 0) {
applyClimateEvents();
}
}
/** Sets the simulation speed multiplier (1 = real-time, max 100). */
@@ -459,19 +493,56 @@ public final class LivingWorldBootstrap {
Season season = getCurrentSeason();
for (Region region : regionManager.getActiveRegions()) {
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (soil == null) continue;
.get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null);
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
VegetationRegionData veg = region.getModuleData()
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElse(null);
if (soil == null || water == null || veg == null) continue;
double droughtDelta = season.droughtMod();
double vegDelta = season.vegGrowthMod();
switch (season) {
case SPRING ->
// Snowmelt nutrients give soil a mild fertility boost.
soil.setFertility(Math.min(100, soil.getFertility() + 0.003));
case WINTER ->
// Frost draws moisture out of the topsoil.
soil.setMoisture(Math.max(0, soil.getMoisture() - 0.002));
default -> { /* SUMMER and AUTUMN have no direct soil effect. */ }
case SPRING -> {
// Snowmelt: fertility boost, moisture recovery, drought eases, growth surge
soil.setFertility(Math.min(100, soil.getFertility() + 0.005));
soil.setMoisture(Math.min(100, soil.getMoisture() + 0.08));
veg.setGrassPressure(Math.min(100, veg.getGrassPressure() + vegDelta));
veg.setFlowerPressure(Math.min(100, veg.getFlowerPressure() + vegDelta * 0.8));
}
case SUMMER -> {
// Heat: evaporation reduces moisture and water availability
soil.setMoisture(Math.max(0, soil.getMoisture() - 0.05));
water.setWaterAvailability(Math.max(0, water.getWaterAvailability() - 0.04));
// Mild growth boost from warmth, only if moisture is adequate
if (soil.getMoisture() > 30) {
veg.setGrassPressure(Math.min(100, veg.getGrassPressure() + vegDelta));
}
}
case AUTUMN -> {
// Senescence: dead litter accumulates, soil gains a little from decomposition
veg.setDeadVegetation(Math.min(100, veg.getDeadVegetation() + 0.08));
soil.setFertility(Math.min(100, soil.getFertility() + 0.002));
veg.setFlowerPressure(Math.max(0, veg.getFlowerPressure() + vegDelta));
veg.setGrassPressure(Math.max(0, veg.getGrassPressure() + vegDelta));
}
case WINTER -> {
// Frost: moisture freezes, drought risk creeps up, vegetation retreats
soil.setMoisture(Math.max(0, soil.getMoisture() - 0.004));
water.setWaterAvailability(Math.max(0, water.getWaterAvailability() - 0.06));
veg.setGrassPressure(Math.max(0, veg.getGrassPressure() + vegDelta));
veg.setFlowerPressure(Math.max(0, veg.getFlowerPressure() + vegDelta));
veg.setShrubPressure(Math.max(0, veg.getShrubPressure() + vegDelta * 0.5));
}
}
// Universal drought drift based on season
water.setDroughtRisk(Math.min(100, Math.max(0, water.getDroughtRisk() + droughtDelta)));
region.getModuleData().put(SoilModule.MODULE_ID, soil);
region.getModuleData().put(WaterModule.MODULE_ID, water);
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
regionManager.markDirty(region);
}
}
@@ -530,6 +601,13 @@ public final class LivingWorldBootstrap {
double runoff = Math.min(heightDiff / 200.0, 0.2) * myAtm.getRainLevel();
myWater.setWaterAvailability(Math.max(0, myWater.getWaterAvailability() - runoff * 0.4));
// Accumulate river flow intensity toward the downhill neighbour.
// Over time this triggers RIVER_CARVE effects that slowly carve channels.
if (runoff > 0.04 && heightDiff > 5.0) {
double flowContrib = runoff * heightDiff / 50.0;
riverFlowIntensity.merge(nCoord, flowContrib, Double::sum);
}
WaterRegionData nWater = neighbour.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElse(null);
@@ -556,6 +634,19 @@ public final class LivingWorldBootstrap {
region.getModuleData().put(WaterModule.MODULE_ID, myWater);
regionManager.markDirty(region);
}
// Dispatch RIVER_CARVE effects for regions with high accumulated flow.
if (worldEffectsModule != null) {
for (var entry : riverFlowIntensity.entrySet()) {
if (entry.getValue() >= RIVER_CARVE_THRESHOLD) {
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.RIVER_CARVE, entry.getKey(),
Math.min(1.0, entry.getValue() / (RIVER_CARVE_THRESHOLD * 2))));
riverFlowIntensity.put(entry.getKey(),
entry.getValue() - RIVER_CARVE_THRESHOLD);
}
}
}
}
/**
@@ -584,12 +675,32 @@ public final class LivingWorldBootstrap {
SuccessionStage current = recovery.getMaxSuccessionStage();
SuccessionStage computed = computeDynamicCap(water, soil);
// Cap can rise when conditions genuinely support a higher stage.
if (computed.ordinal() > current.ordinal()) {
recovery.setMaxSuccessionStage(computed);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
regionManager.markDirty(region);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"Region " + region.getCoordinate() + " succession cap raised to " + computed);
// Desertification permanence: prolonged damage under very bad conditions
// lowers the cap by one stage so the region can no longer recover as high
// without player intervention (re-watering, removing pollution, etc.).
} else if (current.ordinal() > computed.ordinal() + 1
&& current.hasPrev()
&& recovery.getDamageAccumulation() > 65
&& region.getMetrics().getEcosystemHealth() < 20) {
SuccessionStage lowered = current.prev();
recovery.setMaxSuccessionStage(lowered);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
regionManager.markDirty(region);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"Region " + region.getCoordinate()
+ " DESERTIFICATION: cap lowered to " + lowered);
pendingEventMessages.add("[LW] Desertification: region ("
+ region.getCoordinate().x() + "," + region.getCoordinate().z()
+ ") degraded — cap now " + lowered.name());
}
}
}
@@ -709,6 +820,304 @@ public final class LivingWorldBootstrap {
}
}
// ---------------------------------------------------------------
// Trend tracking
// ---------------------------------------------------------------
private void recordHealthTrend() {
for (Region region : regionManager.getActiveRegions()) {
RegionCoordinate coord = region.getCoordinate();
double health = region.getMetrics().getEcosystemHealth();
double[] history = healthHistory.computeIfAbsent(coord, k -> new double[TREND_WINDOW]);
int idx = historyIndex.getOrDefault(coord, 0);
history[idx % TREND_WINDOW] = health;
historyIndex.put(coord, idx + 1);
}
}
public double getHealthTrend(RegionCoordinate coord) {
double[] history = healthHistory.get(coord);
int count = historyIndex.getOrDefault(coord, 0);
if (history == null || count < TREND_WINDOW) return 0.0;
// Compare the two most-recent values against the three oldest in the window
double recent = (history[(count - 1) % TREND_WINDOW] + history[(count - 2) % TREND_WINDOW]) / 2.0;
double older = (history[(count - 3) % TREND_WINDOW]
+ history[(count - 4) % TREND_WINDOW]
+ history[(count - 5) % TREND_WINDOW]) / 3.0;
return recent - older;
}
public String getTrendArrow(RegionCoordinate coord) {
double trend = getHealthTrend(coord);
if (trend > 2.0) return "";
if (trend < -2.0) return "";
return "";
}
public double getSeedRainAt(RegionCoordinate coord) {
return accumulatedSeedRain.getOrDefault(coord, 0.0);
}
/** Returns the sampled average surface elevation for the region, or null if not yet sampled. */
public Double getRegionElevation(RegionCoordinate coord) {
return coord != null ? regionElevations.get(coord) : null;
}
// ---------------------------------------------------------------
// Climate events
// ---------------------------------------------------------------
/**
* Checks for new multi-region climate events (drought, wildfire, flood) and
* advances existing active events each {@link #CLIMATE_CHECK_INTERVAL} cycles.
*/
private void applyClimateEvents() {
Collection<Region> active = regionManager.getActiveRegions();
if (active.size() < 2) 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}};
long simTick = simulationManager.getSimulationTickCounter();
// Advance existing events
Iterator<ClimateEvent> iter = activeClimateEvents.iterator();
while (iter.hasNext()) {
ClimateEvent ev = iter.next();
ev.incrementTick();
advanceClimateEvent(ev, byCoord, offsets);
if (ev.isResolved()) {
pendingEventMessages.add("[LW] " + ev.getType().displayName()
+ " resolved after " + ev.getTicksActive() + " cycles.");
iter.remove();
}
}
// Check if any active event already covers these regions (avoid duplicates)
Set<RegionCoordinate> coveredByEvent = new HashSet<>();
for (ClimateEvent ev : activeClimateEvents) coveredByEvent.addAll(ev.getAffectedRegions());
// Try to trigger new events
for (Region region : active) {
if (coveredByEvent.contains(region.getCoordinate())) continue;
RegionCoordinate coord = region.getCoordinate();
WaterRegionData water = region.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
AtmosphereRegionData atm = region.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null);
RecoveryRegionData recovery = region.getModuleData().get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElse(null);
PollutionRegionData poll = region.getModuleData().get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null);
if (water == null || atm == null || recovery == null) continue;
// --- DROUGHT trigger ---
if (water.getWaterAvailability() < 20 && water.getDroughtRisk() > 65) {
int droughtNeighbours = 0;
for (int[] off : offsets) {
Region nb = byCoord.get(new RegionCoordinate(coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]));
if (nb == null) continue;
WaterRegionData nbW = nb.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
if (nbW != null && nbW.getWaterAvailability() < 25 && nbW.getDroughtRisk() > 55) droughtNeighbours++;
}
if (droughtNeighbours >= 2) {
double severity = (65.0 - water.getWaterAvailability()) / 65.0;
ClimateEvent drought = new ClimateEvent(ClimateEventType.DROUGHT, coord, simTick, severity);
activeClimateEvents.add(drought);
coveredByEvent.add(coord);
pendingEventMessages.add("[LW] ⚠ Drought spreading! Epicentre region ("
+ coord.x() + "," + coord.z() + ") — " + droughtNeighbours + " dry neighbours.");
}
}
// --- WILDFIRE trigger ---
if (recovery.getSuccessionStage().ordinal() >= SuccessionStage.YOUNG_WOODLAND.ordinal()
&& water.getDroughtRisk() > 65
&& atm.getRainLevel() < 0.1
&& poll != null && poll.getAirPollution() > 25
&& windRandom.nextDouble() < 0.12) {
double severity = Math.min(1.0, (water.getDroughtRisk() - 65) / 35.0 + 0.2);
ClimateEvent fire = new ClimateEvent(ClimateEventType.WILDFIRE, coord, simTick, severity);
activeClimateEvents.add(fire);
coveredByEvent.add(coord);
pendingEventMessages.add("[LW] 🔥 Wildfire ignited at region ("
+ coord.x() + "," + coord.z() + ")! Drought risk: "
+ String.format("%.0f%%", water.getDroughtRisk()));
}
// --- FLOOD trigger ---
if (water.getWaterAvailability() > 92 && atm.getRainLevel() > 0.72) {
boolean hasLowNeighbour = false;
for (int[] off : offsets) {
RegionCoordinate nCoord = new RegionCoordinate(coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]);
Double myElev = regionElevations.get(coord);
Double nElev = regionElevations.get(nCoord);
if (myElev != null && nElev != null && myElev > nElev + 3) { hasLowNeighbour = true; break; }
}
if (hasLowNeighbour) {
ClimateEvent flood = new ClimateEvent(ClimateEventType.FLOOD, coord, simTick, 0.6);
activeClimateEvents.add(flood);
coveredByEvent.add(coord);
pendingEventMessages.add("[LW] 🌊 Flood event! Region ("
+ coord.x() + "," + coord.z() + ") overflowing into lowlands.");
}
}
}
}
private void advanceClimateEvent(ClimateEvent ev, Map<RegionCoordinate, Region> byCoord, int[][] offsets) {
boolean shouldResolve = false;
for (RegionCoordinate coord : new HashSet<>(ev.getAffectedRegions())) {
Region region = byCoord.get(coord);
if (region == null) continue;
switch (ev.getType()) {
case DROUGHT -> {
WaterRegionData water = region.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
if (water == null) break;
water.setDroughtRisk(Math.min(100, water.getDroughtRisk() + 0.02));
water.setWaterAvailability(Math.max(0, water.getWaterAvailability() - 0.03));
region.getModuleData().put(WaterModule.MODULE_ID, water);
regionManager.markDirty(region);
// Resolution: epicentre water availability recovers
if (coord.equals(ev.getEpicenter()) && water.getWaterAvailability() > 45) shouldResolve = true;
// Spread to dry neighbours
if (ev.getTicksActive() % 5 == 0) {
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;
WaterRegionData nbW = nb.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
if (nbW != null && nbW.getWaterAvailability() < 35) ev.addAffectedRegion(nCoord);
}
}
}
case WILDFIRE -> {
WaterRegionData water = region.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
AtmosphereRegionData atm = region.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null);
PollutionRegionData poll = region.getModuleData().get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null);
if (poll != null) {
poll.addPollution(0.5 * ev.getSeverity(), 0.2 * ev.getSeverity(), 0);
region.getModuleData().put(PollutionModule.MODULE_ID, poll);
}
if (worldEffectsModule != null) {
worldEffectsModule.queueEffect(new WorldEffectRequest(WorldEffectType.VEGETATION_DIES, coord, ev.getSeverity()));
if (windRandom.nextDouble() < 0.3) {
worldEffectsModule.queueEffect(new WorldEffectRequest(WorldEffectType.WILDFIRE, coord, ev.getSeverity()));
}
}
regionManager.markDirty(region);
// Resolution: rain extinguishes fire
if (coord.equals(ev.getEpicenter()) && atm != null && atm.getRainLevel() > 0.4) shouldResolve = true;
// Spread to adjacent forest regions
if (ev.getTicksActive() % 3 == 0) {
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;
RecoveryRegionData nRec = nb.getModuleData().get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElse(null);
WaterRegionData nW = nb.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
if (nRec != null && nW != null
&& nRec.getSuccessionStage().ordinal() >= SuccessionStage.SCRUBLAND.ordinal()
&& nW.getDroughtRisk() > 50) {
ev.addAffectedRegion(nCoord);
}
}
}
}
case FLOOD -> {
SoilRegionData soil = region.getModuleData().get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null);
WaterRegionData water = region.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
AtmosphereRegionData atm = region.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null);
if (soil != null) {
soil.setContamination(Math.min(100, soil.getContamination() + 0.05));
region.getModuleData().put(SoilModule.MODULE_ID, soil);
}
if (worldEffectsModule != null) {
worldEffectsModule.queueEffect(new WorldEffectRequest(WorldEffectType.WATER_POOL_FORMS, coord, 0.8));
}
regionManager.markDirty(region);
if (coord.equals(ev.getEpicenter()) && water != null && atm != null
&& (water.getWaterAvailability() < 70 || atm.getRainLevel() < 0.25)) {
shouldResolve = true;
}
}
}
}
if (shouldResolve) ev.resolve();
// Safety timeout: resolve after 200 ticks regardless
if (ev.getTicksActive() > 200) ev.resolve();
}
/**
* Drains and returns any pending climate event notification messages.
* Called by the platform layer to broadcast these to all players.
*/
public List<String> pollClimateEventMessages() {
if (pendingEventMessages.isEmpty()) return List.of();
List<String> copy = new ArrayList<>(pendingEventMessages);
pendingEventMessages.clear();
return copy;
}
// ---------------------------------------------------------------
// Tidal effect + biome initialisation
// ---------------------------------------------------------------
/**
* Applies a tidal water-availability delta to a coastal region.
* Positive delta = rising tide (more water), negative = falling tide.
*/
public void applyTidalEffect(RegionCoordinate coord, double waterDelta) {
if (!serverReady || coord == null) return;
regionManager.resolve(coord).ifPresent(region -> {
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
if (water == null) return;
water.setWaterAvailability(Math.min(100, Math.max(0,
water.getWaterAvailability() + waterDelta)));
if (waterDelta > 0) {
water.setDroughtRisk(Math.max(0, water.getDroughtRisk() - waterDelta * 0.4));
}
region.getModuleData().put(WaterModule.MODULE_ID, water);
regionManager.markDirty(region);
});
}
/**
* Initialises soil and water values for a newly loaded region based on biome
* temperature and downfall, giving each biome-type region a realistic starting state.
*/
public void initializeRegionBiomeValues(RegionCoordinate coord, float temp, float downfall) {
if (!serverReady || coord == null) return;
regionManager.resolve(coord).ifPresent(region -> {
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null);
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null);
if (soil == null || water == null) return;
// downfall: 0=arid, 1=wet | temp: 0.15=cold, 2.0=desert hot
double wetness = Math.min(1.0, Math.max(0.0, downfall));
double heat = Math.min(1.0, Math.max(0.0, (temp - 0.15) / 1.85));
double soilFertility = clampD(25 + wetness * 55 - heat * 10);
double soilMoisture = clampD(20 + wetness * 60 - heat * 15);
double droughtRisk = clampD(70 - wetness * 55 + heat * 20);
double waterAvail = clampD(20 + wetness * 60 - heat * 15);
soil.setFertility(soilFertility);
soil.setMoisture(soilMoisture);
water.setWaterAvailability(waterAvail);
water.setDroughtRisk(droughtRisk);
region.getModuleData().put(SoilModule.MODULE_ID, soil);
region.getModuleData().put(WaterModule.MODULE_ID, water);
regionManager.markDirty(region);
});
}
private static double clampD(double v) { return Math.min(100, Math.max(0, v)); }
/** Returns a formatted wind status string for the {@code /lw wind} command. */
public String getWindInfo() {
// Map angle to 8-point compass (N=0°, E=90°, S=180°, W=270°).
@@ -783,14 +1192,18 @@ public final class LivingWorldBootstrap {
.orElse("");
Double elev = regionElevations.get(coord);
String elevLine = elev != null ? String.format(" | Elev: %.0f", elev) : "";
String trend = getTrendArrow(coord);
double seedRain = getSeedRainAt(coord);
String seedLine = seedRain > 0.1 ? String.format(" | Seeds: %.1f", seedRain) : "";
String trendLine = String.format(" | Trend: %s (%.1f)", trend, getHealthTrend(coord));
return regionOpt.flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class))
.map(atm -> String.format(
"Region (%d,%d) | Season: %s | Rain: %.0f%% | Storm: %.0f%%%s%s",
"Region (%d,%d) | Season: %s | Rain: %.0f%% | Storm: %.0f%%%s%s%s%s",
coord.x(), coord.z(), season.displayName(),
atm.getRainLevel() * 100, atm.getThunderLevel() * 100,
successionLine, elevLine))
.orElse(String.format("Region (%d,%d) — atmosphere not yet computed. Season: %s",
coord.x(), coord.z(), season.displayName()));
successionLine, elevLine, trendLine, seedLine))
.orElse(String.format("Region (%d,%d) — atmosphere not yet computed. Season: %s%s",
coord.x(), coord.z(), season.displayName(), trendLine));
}
/** Toggles the debug HUD for the given player. Returns true if HUD is now enabled. */
@@ -0,0 +1,50 @@
package com.livingworld.climate;
import com.livingworld.regions.RegionCoordinate;
import java.util.HashSet;
import java.util.Set;
/**
* An active multi-region climate event (drought, wildfire, or flood).
* Events spread to adjacent regions over time and resolve when conditions improve.
*/
public final class ClimateEvent {
private final ClimateEventType type;
private final RegionCoordinate epicenter;
private final long startTick;
private double severity;
private final Set<RegionCoordinate> affectedRegions = new HashSet<>();
private boolean resolved;
private int ticksActive;
public ClimateEvent(ClimateEventType type, RegionCoordinate epicenter, long startTick, double severity) {
this.type = type;
this.epicenter = epicenter;
this.startTick = startTick;
this.severity = severity;
this.resolved = false;
this.ticksActive = 0;
this.affectedRegions.add(epicenter);
}
public ClimateEventType getType() { return type; }
public RegionCoordinate getEpicenter() { return epicenter; }
public long getStartTick() { return startTick; }
public double getSeverity() { return severity; }
public Set<RegionCoordinate> getAffectedRegions() { return affectedRegions; }
public boolean isResolved() { return resolved; }
public int getTicksActive() { return ticksActive; }
public void setSeverity(double severity) { this.severity = Math.max(0, Math.min(1, severity)); }
public void resolve() { this.resolved = true; }
public void addAffectedRegion(RegionCoordinate coord) { affectedRegions.add(coord); }
public void incrementTick() { ticksActive++; }
@Override
public String toString() {
return type.displayName() + " at " + epicenter + " (tick " + ticksActive
+ ", severity=" + String.format("%.2f", severity)
+ ", regions=" + affectedRegions.size() + ")";
}
}
@@ -0,0 +1,19 @@
package com.livingworld.climate;
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");
private final String displayName;
private final String description;
ClimateEventType(String displayName, String description) {
this.displayName = displayName;
this.description = description;
}
public String displayName() { return displayName; }
public String description() { return description; }
}
@@ -152,6 +152,7 @@ public final class LivingWorldCommandRoot {
false);
return 1;
})))
.then(LivingWorldMapCommand.build(regionManager))
.then(RegionSetCommand.build(regionManager))
.then(EcoDemoCommand.build(regionManager)));
}
@@ -0,0 +1,93 @@
package com.livingworld.commands;
import com.livingworld.core.LivingWorldConstants;
import com.livingworld.modules.recovery.RecoveryModule;
import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionManager;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import java.util.function.Supplier;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
/**
* Renders an ASCII coloured grid of nearby regions showing their succession stage.
* Usage: {@code /lw map} (7×7 grid) or {@code /lw map <radius>} (17, giving 3×3 to 15×15).
*/
public final class LivingWorldMapCommand {
private static final int DEFAULT_RADIUS = 3;
private LivingWorldMapCommand() {}
public static LiteralArgumentBuilder<CommandSourceStack> build(Supplier<RegionManager> regionManager) {
return Commands.literal("map")
.executes(ctx -> executeMap(ctx.getSource(), regionManager, DEFAULT_RADIUS))
.then(Commands.argument("radius", IntegerArgumentType.integer(1, 7))
.executes(ctx -> executeMap(ctx.getSource(), regionManager,
IntegerArgumentType.getInteger(ctx, "radius"))));
}
private static int executeMap(
CommandSourceStack source, Supplier<RegionManager> rmSupplier, int radius) {
RegionManager rm = rmSupplier.get();
if (rm == null) {
source.sendFailure(Component.literal("Region manager not ready."));
return 0;
}
String dimId = source.getLevel().dimension().location().toString();
var pos = source.getPosition();
int prx = (int) Math.floor(pos.x / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
int prz = (int) Math.floor(pos.z / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
int size = radius * 2 + 1;
source.sendSuccess(() -> Component.literal(
"[LW] Map " + size + "×" + size
+ " around (" + prx + "," + prz + ") | [X]=you, ↑N ↓S ←W →E")
.withStyle(ChatFormatting.GOLD), false);
// dz goes from -radius (north) to +radius (south) visually top-to-bottom
for (int dz = -radius; dz <= radius; dz++) {
MutableComponent row = Component.empty();
for (int dx = -radius; dx <= radius; dx++) {
RegionCoordinate coord = new RegionCoordinate(dimId, prx + dx, prz + dz);
row.append(renderCell(rm, coord, dx == 0 && dz == 0));
}
final MutableComponent finalRow = row;
source.sendSuccess(() -> finalRow, false);
}
source.sendSuccess(() -> Component.literal(
"§2F§r=Forest §aW§r=Woodland §es§r=Scrub §fG§r=Grass §6g§r=Sparse §cB§r=Barren §8·§r=Unknown")
.withStyle(ChatFormatting.DARK_GRAY), false);
return 1;
}
private static MutableComponent renderCell(RegionManager rm, RegionCoordinate coord, boolean isPlayer) {
if (isPlayer) {
return Component.literal("[X]").withStyle(ChatFormatting.AQUA, ChatFormatting.BOLD);
}
SuccessionStage stage = rm.resolve(coord)
.flatMap(r -> r.getModuleData().get(RecoveryModule.MODULE_ID, RecoveryRegionData.class))
.map(RecoveryRegionData::getSuccessionStage)
.orElse(null);
if (stage == null) {
return Component.literal(" · ").withStyle(ChatFormatting.DARK_GRAY);
}
return switch (stage) {
case MATURE_FOREST -> Component.literal(" F ").withStyle(ChatFormatting.DARK_GREEN);
case YOUNG_WOODLAND -> Component.literal(" W ").withStyle(ChatFormatting.GREEN);
case SCRUBLAND -> Component.literal(" s ").withStyle(ChatFormatting.YELLOW);
case GRASSLAND -> Component.literal(" G ").withStyle(ChatFormatting.WHITE);
case SPARSE_GRASS -> Component.literal(" g ").withStyle(ChatFormatting.GOLD);
case BARREN -> Component.literal(" B ").withStyle(ChatFormatting.RED);
};
}
}
@@ -9,21 +9,34 @@ package com.livingworld.modules.atmosphere;
*/
public enum Season {
SPRING(+0.10),
SUMMER(-0.15),
AUTUMN( 0.0),
WINTER(-0.25);
// rain temp drought vegGrowth
SPRING( +0.10, +0.02, -0.05, +0.04),
SUMMER( -0.15, +0.05, +0.03, +0.02),
AUTUMN( 0.0, -0.01, 0.00, -0.02),
WINTER( -0.25, -0.05, +0.02, -0.06);
private static final int SEASON_LENGTH_DAYS = 8;
/** Additive modifier applied to the atmosphere rain target this season. */
private final double rainModifier;
/** Per-cycle soil-temperature effect (positive = warming, negative = cooling). */
private final double temperatureMod;
/** Per-cycle drought risk delta (positive = drought worsens, negative = eases). */
private final double droughtMod;
/** Per-cycle vegetation growth delta (positive = growth, negative = senescence). */
private final double vegGrowthMod;
Season(double rainModifier) {
Season(double rainModifier, double temperatureMod, double droughtMod, double vegGrowthMod) {
this.rainModifier = rainModifier;
this.temperatureMod = temperatureMod;
this.droughtMod = droughtMod;
this.vegGrowthMod = vegGrowthMod;
}
public double rainModifier() { return rainModifier; }
public double temperatureMod() { return temperatureMod; }
public double droughtMod() { return droughtMod; }
public double vegGrowthMod() { return vegGrowthMod; }
public String displayName() {
String n = name();
@@ -60,4 +60,11 @@ public enum WorldEffectType {
* back a hydration boost to the region, enabling succession toward fertile land.
*/
WATER_POOL_FORMS,
/**
* Sustained high-intensity water runoff erodes the river channel: the platform
* adapter replaces natural surface blocks with water along the low-lying path
* through the region, gradually carving a visible riverbed over many cycles.
*/
RIVER_CARVE,
}
@@ -106,6 +106,14 @@ public final class WorldEffectsModule implements SimulationModule {
return Collections.unmodifiableList(consumers);
}
/**
* Directly emits an effect request to all registered consumers from outside the
* normal module update cycle (e.g. from the bootstrap post-sim hooks).
*/
public void queueEffect(WorldEffectRequest request) {
emit(request);
}
@Override
public String getModuleId() { return MODULE_ID; }
@@ -74,6 +74,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
killVegetation(level, baseX, baseZ, request.intensity());
case WATER_POOL_FORMS ->
formWaterPool(level, baseX, baseZ, request.intensity());
case RIVER_CARVE ->
carveRiverChannel(level, baseX, baseZ, request.intensity());
}
}
@@ -299,6 +301,49 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
}
}
/**
* Gradually carves a river channel by replacing natural surface blocks with water
* and optionally exposing the layer beneath (deepening the channel over time).
* Only fires ~10% of the time per call so rivers form over many sim cycles.
*/
private void carveRiverChannel(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.10) return; // throttle: gradual formation
int attempts = Math.max(1, (int) (intensity * 4));
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() + 4) continue;
BlockPos surfacePos = new BlockPos(x, surfaceY, z);
if (!level.isLoaded(surfacePos)) continue;
var state = level.getBlockState(surfacePos);
// Only carve natural soft terrain — never touch player-placed blocks
boolean softNatural = state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.DIRT)
|| state.is(Blocks.COARSE_DIRT) || state.is(Blocks.SAND)
|| state.is(Blocks.GRAVEL);
if (!softNatural) continue;
// Replace surface with a water source
level.setBlock(surfacePos, Blocks.WATER.defaultBlockState(), Block.UPDATE_ALL);
// At high intensity also expose the layer below (deepens channel slowly)
if (intensity > 0.6) {
BlockPos below = surfacePos.below();
if (level.isLoaded(below)) {
var belowState = level.getBlockState(below);
if (belowState.is(Blocks.STONE) || belowState.is(Blocks.GRAVEL)
|| belowState.is(Blocks.DIRT)) {
level.setBlock(below, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
}
}
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"WorldEffect RIVER_CARVE at " + surfacePos);
}
}
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()) {