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:
@@ -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 <stat> <value>` | 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 <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 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)
|
||||||
@@ -95,11 +95,15 @@ public class LivingWorldMod {
|
|||||||
/** Hostile mobs suppressed in regions with ecosystem health above this. */
|
/** Hostile mobs suppressed in regions with ecosystem health above this. */
|
||||||
private static final double HOSTILE_SUPPRESS_HEALTH = 60.0;
|
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 LivingWorldBootstrap bootstrap;
|
||||||
private final Random random = new Random();
|
private final Random random = new Random();
|
||||||
private MinecraftServer minecraftServer;
|
private MinecraftServer minecraftServer;
|
||||||
private int furnaceScanTick = 0;
|
private int furnaceScanTick = 0;
|
||||||
private int playerCheckTick = 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, RegionCoordinate> playerRegionCache = new HashMap<>();
|
||||||
private final Map<UUID, Boolean> playerRainState = new HashMap<>();
|
private final Map<UUID, Boolean> playerRainState = new HashMap<>();
|
||||||
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
|
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
|
||||||
@@ -151,6 +155,8 @@ public class LivingWorldMod {
|
|||||||
waterBodyLastScan.clear();
|
waterBodyLastScan.clear();
|
||||||
elevationInitialized.clear();
|
elevationInitialized.clear();
|
||||||
regionSoundLastTick.clear();
|
regionSoundLastTick.clear();
|
||||||
|
tideTick = 0;
|
||||||
|
lastTideLevel = 0.0;
|
||||||
this.minecraftServer = null;
|
this.minecraftServer = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,6 +168,19 @@ public class LivingWorldMod {
|
|||||||
if (++playerCheckTick % PLAYER_CHECK_INTERVAL == 0) {
|
if (++playerCheckTick % PLAYER_CHECK_INTERVAL == 0) {
|
||||||
checkPlayerRegions();
|
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,
|
// Step 1: Mob spawn feedback — passive mobs suppressed in degraded regions,
|
||||||
@@ -270,10 +289,12 @@ public class LivingWorldMod {
|
|||||||
bootstrap.notifyPlayerInRegion(coord);
|
bootstrap.notifyPlayerInRegion(coord);
|
||||||
|
|
||||||
if (player.level() instanceof ServerLevel serverLevel) {
|
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)) {
|
if (!biomeInitialized.contains(coord)) {
|
||||||
SuccessionStage cap = deriveBiomeCap(serverLevel, coord);
|
SuccessionStage cap = deriveBiomeCap(serverLevel, coord);
|
||||||
bootstrap.setRegionBiomeCap(coord, cap);
|
bootstrap.setRegionBiomeCap(coord, cap);
|
||||||
|
initializeRegionFromBiome(serverLevel, coord);
|
||||||
biomeInitialized.add(coord);
|
biomeInitialized.add(coord);
|
||||||
}
|
}
|
||||||
// Elevation sampling — derived once per region; feeds water runoff physics.
|
// Elevation sampling — derived once per region; feeds water runoff physics.
|
||||||
@@ -407,6 +428,107 @@ public class LivingWorldMod {
|
|||||||
vol, pitch, player.getRandom().nextLong()));
|
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 =
|
private static final int REGION_BLOCKS =
|
||||||
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16;
|
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 soilCol = soil > 50 ? ChatFormatting.GREEN : soil > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED;
|
||||||
ChatFormatting watCol = wat > 50 ? ChatFormatting.GREEN : wat > 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()
|
var line = Component.empty()
|
||||||
.append(Component.literal("[LW] ").withStyle(ChatFormatting.GOLD))
|
.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("Eco:").withStyle(ChatFormatting.WHITE))
|
||||||
.append(Component.literal(String.format("%.0f ", eco)).withStyle(ecoCol))
|
.append(Component.literal(String.format("%.0f ", eco)).withStyle(ecoCol))
|
||||||
.append(Component.literal("Poll:").withStyle(ChatFormatting.WHITE))
|
.append(Component.literal("Poll:").withStyle(ChatFormatting.WHITE))
|
||||||
@@ -504,6 +632,13 @@ public class LivingWorldMod {
|
|||||||
.append(Component.literal(String.format("%.0f%%", thunder)).withStyle(stormCol));
|
.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;
|
return line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.livingworld.bootstrap;
|
package com.livingworld.bootstrap;
|
||||||
|
|
||||||
|
import com.livingworld.climate.ClimateEvent;
|
||||||
|
import com.livingworld.climate.ClimateEventType;
|
||||||
import com.livingworld.commands.LivingWorldCommandRoot;
|
import com.livingworld.commands.LivingWorldCommandRoot;
|
||||||
import com.livingworld.config.DefaultConfigService;
|
import com.livingworld.config.DefaultConfigService;
|
||||||
import com.livingworld.config.EcosystemTuning;
|
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.AtmosphereModule;
|
||||||
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
|
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
|
||||||
import com.livingworld.modules.atmosphere.Season;
|
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.modules.worldeffects.WorldEffectsModule;
|
||||||
import com.livingworld.platform.BlockBreakInfo;
|
import com.livingworld.platform.BlockBreakInfo;
|
||||||
import com.livingworld.platform.PlatformAdapter;
|
import com.livingworld.platform.PlatformAdapter;
|
||||||
@@ -52,9 +56,11 @@ import com.livingworld.regions.cache.RegionCache;
|
|||||||
import com.livingworld.regions.query.RegionQueryEngine;
|
import com.livingworld.regions.query.RegionQueryEngine;
|
||||||
import com.mojang.brigadier.CommandDispatcher;
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.LongSupplier;
|
import java.util.function.LongSupplier;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -71,6 +77,12 @@ import net.minecraft.commands.CommandSourceStack;
|
|||||||
public final class LivingWorldBootstrap {
|
public final class LivingWorldBootstrap {
|
||||||
|
|
||||||
private static final double WIND_DRIFT_MAX = 0.05; // radians per sim cycle
|
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 double windAngle = 0.0;
|
||||||
private final Random windRandom = new Random();
|
private final Random windRandom = new Random();
|
||||||
@@ -84,6 +96,18 @@ public final class LivingWorldBootstrap {
|
|||||||
private final Map<RegionCoordinate, Double> accumulatedSeedRain = new HashMap<>();
|
private final Map<RegionCoordinate, Double> accumulatedSeedRain = new HashMap<>();
|
||||||
private final Set<UUID> hudEnabledPlayers = new HashSet<>();
|
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 PlatformAdapter platformAdapter;
|
||||||
private Path worldSaveDirectory;
|
private Path worldSaveDirectory;
|
||||||
private ServiceRegistry services;
|
private ServiceRegistry services;
|
||||||
@@ -229,6 +253,12 @@ public final class LivingWorldBootstrap {
|
|||||||
hudEnabledPlayers.clear();
|
hudEnabledPlayers.clear();
|
||||||
regionElevations.clear();
|
regionElevations.clear();
|
||||||
accumulatedSeedRain.clear();
|
accumulatedSeedRain.clear();
|
||||||
|
healthHistory.clear();
|
||||||
|
historyIndex.clear();
|
||||||
|
riverFlowIntensity.clear();
|
||||||
|
activeClimateEvents.clear();
|
||||||
|
pendingEventMessages.clear();
|
||||||
|
climateEventTick = 0;
|
||||||
simSpeedMultiplier = 1;
|
simSpeedMultiplier = 1;
|
||||||
serverReady = false;
|
serverReady = false;
|
||||||
LivingWorldLogger.info(
|
LivingWorldLogger.info(
|
||||||
@@ -382,6 +412,10 @@ public final class LivingWorldBootstrap {
|
|||||||
applyWaterRunoff();
|
applyWaterRunoff();
|
||||||
applyDynamicCapUpdate();
|
applyDynamicCapUpdate();
|
||||||
applySeedDispersal();
|
applySeedDispersal();
|
||||||
|
recordHealthTrend();
|
||||||
|
if (++climateEventTick % CLIMATE_CHECK_INTERVAL == 0) {
|
||||||
|
applyClimateEvents();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets the simulation speed multiplier (1 = real-time, max 100). */
|
/** Sets the simulation speed multiplier (1 = real-time, max 100). */
|
||||||
@@ -459,19 +493,56 @@ public final class LivingWorldBootstrap {
|
|||||||
Season season = getCurrentSeason();
|
Season season = getCurrentSeason();
|
||||||
for (Region region : regionManager.getActiveRegions()) {
|
for (Region region : regionManager.getActiveRegions()) {
|
||||||
SoilRegionData soil = region.getModuleData()
|
SoilRegionData soil = region.getModuleData()
|
||||||
.get(SoilModule.MODULE_ID, SoilRegionData.class)
|
.get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null);
|
||||||
.orElse(null);
|
WaterRegionData water = region.getModuleData()
|
||||||
if (soil == null) continue;
|
.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) {
|
switch (season) {
|
||||||
case SPRING ->
|
case SPRING -> {
|
||||||
// Snowmelt nutrients give soil a mild fertility boost.
|
// Snowmelt: fertility boost, moisture recovery, drought eases, growth surge
|
||||||
soil.setFertility(Math.min(100, soil.getFertility() + 0.003));
|
soil.setFertility(Math.min(100, soil.getFertility() + 0.005));
|
||||||
case WINTER ->
|
soil.setMoisture(Math.min(100, soil.getMoisture() + 0.08));
|
||||||
// Frost draws moisture out of the topsoil.
|
veg.setGrassPressure(Math.min(100, veg.getGrassPressure() + vegDelta));
|
||||||
soil.setMoisture(Math.max(0, soil.getMoisture() - 0.002));
|
veg.setFlowerPressure(Math.min(100, veg.getFlowerPressure() + vegDelta * 0.8));
|
||||||
default -> { /* SUMMER and AUTUMN have no direct soil effect. */ }
|
|
||||||
}
|
}
|
||||||
|
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(SoilModule.MODULE_ID, soil);
|
||||||
|
region.getModuleData().put(WaterModule.MODULE_ID, water);
|
||||||
|
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
|
||||||
regionManager.markDirty(region);
|
regionManager.markDirty(region);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,6 +601,13 @@ public final class LivingWorldBootstrap {
|
|||||||
double runoff = Math.min(heightDiff / 200.0, 0.2) * myAtm.getRainLevel();
|
double runoff = Math.min(heightDiff / 200.0, 0.2) * myAtm.getRainLevel();
|
||||||
myWater.setWaterAvailability(Math.max(0, myWater.getWaterAvailability() - runoff * 0.4));
|
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()
|
WaterRegionData nWater = neighbour.getModuleData()
|
||||||
.get(WaterModule.MODULE_ID, WaterRegionData.class)
|
.get(WaterModule.MODULE_ID, WaterRegionData.class)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
@@ -556,6 +634,19 @@ public final class LivingWorldBootstrap {
|
|||||||
region.getModuleData().put(WaterModule.MODULE_ID, myWater);
|
region.getModuleData().put(WaterModule.MODULE_ID, myWater);
|
||||||
regionManager.markDirty(region);
|
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 current = recovery.getMaxSuccessionStage();
|
||||||
SuccessionStage computed = computeDynamicCap(water, soil);
|
SuccessionStage computed = computeDynamicCap(water, soil);
|
||||||
|
|
||||||
|
// Cap can rise when conditions genuinely support a higher stage.
|
||||||
if (computed.ordinal() > current.ordinal()) {
|
if (computed.ordinal() > current.ordinal()) {
|
||||||
recovery.setMaxSuccessionStage(computed);
|
recovery.setMaxSuccessionStage(computed);
|
||||||
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||||
regionManager.markDirty(region);
|
regionManager.markDirty(region);
|
||||||
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
|
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
|
||||||
"Region " + region.getCoordinate() + " succession cap raised to " + computed);
|
"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. */
|
/** Returns a formatted wind status string for the {@code /lw wind} command. */
|
||||||
public String getWindInfo() {
|
public String getWindInfo() {
|
||||||
// Map angle to 8-point compass (N=0°, E=90°, S=180°, W=270°).
|
// Map angle to 8-point compass (N=0°, E=90°, S=180°, W=270°).
|
||||||
@@ -783,14 +1192,18 @@ public final class LivingWorldBootstrap {
|
|||||||
.orElse("");
|
.orElse("");
|
||||||
Double elev = regionElevations.get(coord);
|
Double elev = regionElevations.get(coord);
|
||||||
String elevLine = elev != null ? String.format(" | Elev: %.0f", elev) : "";
|
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))
|
return regionOpt.flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class))
|
||||||
.map(atm -> String.format(
|
.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(),
|
coord.x(), coord.z(), season.displayName(),
|
||||||
atm.getRainLevel() * 100, atm.getThunderLevel() * 100,
|
atm.getRainLevel() * 100, atm.getThunderLevel() * 100,
|
||||||
successionLine, elevLine))
|
successionLine, elevLine, trendLine, seedLine))
|
||||||
.orElse(String.format("Region (%d,%d) — atmosphere not yet computed. Season: %s",
|
.orElse(String.format("Region (%d,%d) — atmosphere not yet computed. Season: %s%s",
|
||||||
coord.x(), coord.z(), season.displayName()));
|
coord.x(), coord.z(), season.displayName(), trendLine));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggles the debug HUD for the given player. Returns true if HUD is now enabled. */
|
/** 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);
|
false);
|
||||||
return 1;
|
return 1;
|
||||||
})))
|
})))
|
||||||
|
.then(LivingWorldMapCommand.build(regionManager))
|
||||||
.then(RegionSetCommand.build(regionManager))
|
.then(RegionSetCommand.build(regionManager))
|
||||||
.then(EcoDemoCommand.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>} (1–7, 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 {
|
public enum Season {
|
||||||
|
|
||||||
SPRING(+0.10),
|
// rain temp drought vegGrowth
|
||||||
SUMMER(-0.15),
|
SPRING( +0.10, +0.02, -0.05, +0.04),
|
||||||
AUTUMN( 0.0),
|
SUMMER( -0.15, +0.05, +0.03, +0.02),
|
||||||
WINTER(-0.25);
|
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;
|
private static final int SEASON_LENGTH_DAYS = 8;
|
||||||
|
|
||||||
/** Additive modifier applied to the atmosphere rain target this season. */
|
/** Additive modifier applied to the atmosphere rain target this season. */
|
||||||
private final double rainModifier;
|
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.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() {
|
public String displayName() {
|
||||||
String n = name();
|
String n = name();
|
||||||
|
|||||||
@@ -60,4 +60,11 @@ public enum WorldEffectType {
|
|||||||
* back a hydration boost to the region, enabling succession toward fertile land.
|
* back a hydration boost to the region, enabling succession toward fertile land.
|
||||||
*/
|
*/
|
||||||
WATER_POOL_FORMS,
|
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);
|
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
|
@Override
|
||||||
public String getModuleId() { return MODULE_ID; }
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
|
|||||||
killVegetation(level, baseX, baseZ, request.intensity());
|
killVegetation(level, baseX, baseZ, request.intensity());
|
||||||
case WATER_POOL_FORMS ->
|
case WATER_POOL_FORMS ->
|
||||||
formWaterPool(level, baseX, baseZ, request.intensity());
|
formWaterPool(level, baseX, baseZ, request.intensity());
|
||||||
|
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) {
|
private BlockPos surfaceAt(ServerLevel level, int x, int z) {
|
||||||
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1;
|
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1;
|
||||||
if (y < level.getMinBuildHeight()) {
|
if (y < level.getMinBuildHeight()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user