diff --git a/README.md b/README.md new file mode 100644 index 0000000..4235c49 --- /dev/null +++ b/README.md @@ -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 (0–100) 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 ` | Manually override a region metric (debug) | +| `/lw map [radius]` | ASCII coloured region map (1–7 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 <1–100>` | Accelerate simulation (100x = very fast testing) | +| `/lw speed reset` | Return to real-time speed (1x) | +| `/lw simulate ` | 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 0–100 (green >60, yellow >30, red ≤30) | +| `Poll` | Pollution score 0–100 (green <15, yellow <40, red ≥40) | +| `Soil` | Soil quality 0–100 | +| `Wat` | Water quality 0–100 | +| `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) diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 0148014..69dba3e 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -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 playerRegionCache = new HashMap<>(); private final Map playerRainState = new HashMap<>(); private final Set 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 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: + *
    + *
  1. Adjusts coastal region water-availability scores so droughts ease during high tide.
  2. + *
  3. Physically places/removes water source blocks at the shoreline so the tidal + * boundary moves visibly up and down by one block over the cycle.
  4. + *
+ */ + 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 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; } } diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index 22ddbee..a97fae5 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -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 accumulatedSeedRain = new HashMap<>(); private final Set hudEnabledPlayers = new HashSet<>(); + // --- Trend tracking (transient, resets on restart) --- + private final Map healthHistory = new HashMap<>(); + private final Map historyIndex = new HashMap<>(); + + // --- River erosion (transient) --- + private final Map riverFlowIntensity = new HashMap<>(); + + // --- Climate events --- + private final List activeClimateEvents = new ArrayList<>(); + private final List 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); + } + } + } } /** @@ -582,14 +673,34 @@ public final class LivingWorldBootstrap { .orElse(null); if (water == null || soil == null) continue; - SuccessionStage current = recovery.getMaxSuccessionStage(); + 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 active = regionManager.getActiveRegions(); + if (active.size() < 2) return; + + Map 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 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 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 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 pollClimateEventMessages() { + if (pendingEventMessages.isEmpty()) return List.of(); + List 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. */ diff --git a/src/main/java/com/livingworld/climate/ClimateEvent.java b/src/main/java/com/livingworld/climate/ClimateEvent.java new file mode 100644 index 0000000..fb4f007 --- /dev/null +++ b/src/main/java/com/livingworld/climate/ClimateEvent.java @@ -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 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 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() + ")"; + } +} diff --git a/src/main/java/com/livingworld/climate/ClimateEventType.java b/src/main/java/com/livingworld/climate/ClimateEventType.java new file mode 100644 index 0000000..9f5f7f2 --- /dev/null +++ b/src/main/java/com/livingworld/climate/ClimateEventType.java @@ -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; } +} diff --git a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java index 9746e7c..454503e 100644 --- a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java +++ b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java @@ -152,6 +152,7 @@ public final class LivingWorldCommandRoot { false); return 1; }))) + .then(LivingWorldMapCommand.build(regionManager)) .then(RegionSetCommand.build(regionManager)) .then(EcoDemoCommand.build(regionManager))); } diff --git a/src/main/java/com/livingworld/commands/LivingWorldMapCommand.java b/src/main/java/com/livingworld/commands/LivingWorldMapCommand.java new file mode 100644 index 0000000..ab3861f --- /dev/null +++ b/src/main/java/com/livingworld/commands/LivingWorldMapCommand.java @@ -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 } (1–7, giving 3×3 to 15×15). + */ +public final class LivingWorldMapCommand { + + private static final int DEFAULT_RADIUS = 3; + + private LivingWorldMapCommand() {} + + public static LiteralArgumentBuilder build(Supplier 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 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); + }; + } +} diff --git a/src/main/java/com/livingworld/modules/atmosphere/Season.java b/src/main/java/com/livingworld/modules/atmosphere/Season.java index 2d7fee2..c31c46d 100644 --- a/src/main/java/com/livingworld/modules/atmosphere/Season.java +++ b/src/main/java/com/livingworld/modules/atmosphere/Season.java @@ -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) { - this.rainModifier = 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 rainModifier() { return rainModifier; } + public double temperatureMod() { return temperatureMod; } + public double droughtMod() { return droughtMod; } + public double vegGrowthMod() { return vegGrowthMod; } public String displayName() { String n = name(); diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java index 0b062f8..7fcdf52 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -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, } diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java index 13f49d6..a46d55d 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java @@ -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; } diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index de4e539..b6b91a1 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -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()) {