Compare commits
36 Commits
ae2a8db3ce
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0799d4d4c9 | |||
| d7f8e3ea36 | |||
| d9158bc8e9 | |||
| 48643b6ef6 | |||
| eaf5db723b | |||
| 98f97bfbc4 | |||
| 2304266544 | |||
| 38ee028651 | |||
| 5331d5b207 | |||
| f8995392eb | |||
| 7802e4b603 | |||
| 54a6be91de | |||
| fde7264815 | |||
| 113741abd6 | |||
| b68390983c | |||
| 72d94b9f28 | |||
| 30e680b650 | |||
| 725ac6b33f | |||
| 1727c3a406 | |||
| 66dc2f336a | |||
| b17a8990b0 | |||
| 0f2a265c61 | |||
| 32e413cc9f | |||
| 178d50883e | |||
| c82a2afc4f | |||
| 61abff52dc | |||
| 67a1e07b82 | |||
| c9f927b265 | |||
| 2350c27374 | |||
| 773fb0223f | |||
| 577c14b6ea | |||
| dfa84a9347 | |||
| 881f716115 | |||
| 6e6de00f0d | |||
| 4fd9bb97aa | |||
| 6427677db5 |
@@ -0,0 +1,372 @@
|
||||
# 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 |
|
||||
| **Ground spring** | Saturated aquifer in valley floor | Permanent water source placed; river flows downhill naturally |
|
||||
| **Hydraulic erosion** | Flowing water adjacent to rock | Stone → gravel → sand → air; valley walls slowly widen |
|
||||
| **Lava flow** | Volcanic eruption | Lava source blocks placed at summit; flows and reshapes terrain |
|
||||
| **Ash deposit** | Active eruption or nearby blast | Grass → tuff, soil → gravel; surface plants killed over wide area |
|
||||
| **Cobblestone forms** | Lava cooling | Lava + water → cobblestone; lava in air → basalt; new permanent land |
|
||||
| Pollution particles | Pollution > 10 | Smoke particles in degraded regions |
|
||||
|
||||
### Player Feedback — **Complete**
|
||||
|
||||
- **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`
|
||||
|
||||
### Groundwater & Springs — **Complete**
|
||||
|
||||
- Every region tracks a subsurface aquifer level that recharges slowly from rainfall
|
||||
- Underground seepage: high-elevation neighbours drain laterally into the aquifer of lower regions (water flows underground even between sim cycles)
|
||||
- When the aquifer is saturated in a **valley floor** (≥2 higher neighbours), a `GROUND_SPRING_EMERGES` effect fires — a water source block is placed at the lowest natural terrain point
|
||||
- The spring flows downhill under Minecraft's own fluid physics, forming a permanent river without further simulation involvement
|
||||
- Active springs contribute to river erosion intensity even without rain
|
||||
|
||||
### River Erosion & Hydraulic Erosion — **Complete**
|
||||
|
||||
- Accumulated water runoff intensity tracked per region (flow from higher → lower elevation)
|
||||
- When flow intensity crosses the threshold (80 units), a `RIVER_CARVE` world effect fires
|
||||
- Block effect: water source placed on natural terrain; at high intensity the block below is removed (deepening channel)
|
||||
- **Hydraulic erosion** fires independently of rainfall whenever significant flow intensity is present: flowing water (level 1–7) adjacent to soft rock softens it — stone → gravel → sand → air — progressively widening river valleys
|
||||
- Rivers form gradually over many sim cycles — permanent terrain changes
|
||||
|
||||
### River Current Physics — **Complete** (real player forces)
|
||||
|
||||
- Every 4 ticks, all players standing in **flowing** water (level 1–7) receive a directional velocity push
|
||||
- Force direction: `FluidState.getFlow()` — the exact vector Minecraft already computes for its own flow rendering; currents always point the same way the water visually flows
|
||||
- Force magnitude: scales with flow level — level 7 (one block from a spring, fastest) pushes hard; level 1 (distant, slow lowland river) barely nudges
|
||||
- Mountain rivers are genuinely dangerous to wade against; lowland rivers are gentle enough to cross
|
||||
- Boats are naturally carried by vanilla water physics; swimming players and mobs now feel the additional push
|
||||
|
||||
### Volcanic System — **Complete** (new land formation)
|
||||
|
||||
Four-phase lifecycle per high-elevation region (average surface Y ≥ 85):
|
||||
|
||||
| Phase | What Happens |
|
||||
|-------|-------------|
|
||||
| **DORMANT** | Normal state; rare stochastic chance to awaken (~0.2% per check) |
|
||||
| **BUILDING** | Seismic venting — mild pollution spike; player broadcast warns of imminent eruption |
|
||||
| **ERUPTING** | `LAVA_FLOW` places source blocks at the summit; `ASH_DEPOSIT` covers surrounding terrain; ash clouds spread to adjacent regions; vegetation dies in the eruption zone; air pollution spikes; lasts ~40 sim cycles |
|
||||
| **COOLING** | `COBBLESTONE_FORMS` converts lava adjacent to water → cobblestone; lava in air → basalt; permanent new terrain created where none existed before |
|
||||
| **FERTILE** | Volcanic minerals boost soil fertility +30, contamination −20; pollution decays rapidly; succession cap can rise; then returns DORMANT |
|
||||
|
||||
Visible effects:
|
||||
- Lava flows downhill under vanilla physics, permanently reshaping terrain
|
||||
- Ash converts grass → tuff, loose soil → gravel; kills surface plants across a wide area
|
||||
- New cobblestone and basalt islands form where lava met water
|
||||
- Players in active eruption zones hear fire crackle ambience; building-phase rumble in BUILDING regions
|
||||
|
||||
### Tidal Simulation — **Complete** (physical blocks)
|
||||
|
||||
- Two tidal cycles per Minecraft day (24 000 ticks) using a sine wave
|
||||
- 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 events` | List all active climate events (drought/wildfire/flood/eruption) with epicentre, severity, affected regions |
|
||||
| `/lw volcanoes` | List all tracked volcanic regions and their current lifecycle phase |
|
||||
| `/lw demo degrade` | One-command demo: degrade current region to barren |
|
||||
| `/lw demo recover` | One-command demo: restore current region to mature forest |
|
||||
|
||||
### /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]
|
||||
applyGroundwaterAndSprings() [aquifer recharge; valley springs]
|
||||
applyDynamicCapUpdate() [raise/lower succession ceiling]
|
||||
applySeedDispersal() [corridor-boosted recolonisation]
|
||||
recordHealthTrend() [↑↓→ trend tracking]
|
||||
applyClimateEvents() [drought/wildfire/flood events]
|
||||
applyVolcanicActivity() [volcano lifecycle; lava/ash/new land]
|
||||
|
||||
LivingWorldMod platform hooks:
|
||||
updateTidalEffects() [block-level tidal simulation]
|
||||
applyRiverCurrents() [player/entity velocity in flowing water]
|
||||
initializeRegionFromBiome() [biome-aware starting values]
|
||||
checkPlayerRegions() [HUD, effects, ambient sounds]
|
||||
scanAndRecordFurnaceActivity() [pollution from lit furnaces]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- Persistence for river flow intensity, groundwater level, and volcanic state across restarts
|
||||
- Multiplayer region ownership / notification system
|
||||
- More succession stages (wetland, alpine, volcanic basalt plain)
|
||||
- Snowmelt rivers: winter snow accumulation releases as meltwater in spring
|
||||
- Player damage in active eruption zones (lava proximity heat)
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
# Living World — Big Plan
|
||||
|
||||
A complete roadmap of every natural phenomenon to implement. Organised into phases by theme and dependency order. Each phase builds on the systems already in place without requiring the previous phase to be 100% complete.
|
||||
|
||||
---
|
||||
|
||||
## Current State (implemented)
|
||||
|
||||
- 9-module ecosystem pipeline: Pollution → Soil → Water → Vegetation → ResourceDepletion → Recovery → Ecosystem → WorldEffects → Atmosphere
|
||||
- Succession stages: BARREN → SPARSE_GRASS → GRASSLAND → SCRUBLAND → YOUNG_WOODLAND → MATURE_FOREST
|
||||
- Tides with moon-phase amplitude (spring/neap cycle)
|
||||
- River currents + hydraulic erosion
|
||||
- Groundwater aquifer + springs
|
||||
- Mountain volcanoes (lava flow, ash deposit, fertile phase)
|
||||
- Ocean/submarine volcanoes (seafloor buildup → island formation)
|
||||
- World-wide simulation via ChunkEvent.Load (all loaded chunks, not just player-adjacent)
|
||||
- `/lw` command suite: status, region, atmosphere, climate, wind, speed, events, volcanoes
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Ocean Ecosystem
|
||||
|
||||
*Completes the ocean as a living system. Builds directly on submarine volcanism already implemented.*
|
||||
|
||||
### 1.1 Coral Reefs
|
||||
- **Trigger:** Clean ocean region (pollution < 20) with shallow floor (OCEAN_FLOOR heightmap y > 48)
|
||||
- **Growth:** Place coral blocks and coral fans on seafloor over many cycles; species diversity scales with ecosystem health
|
||||
- **Bleaching:** Pollution 20–50 turns coral to dead coral; pollution > 50 removes coral entirely
|
||||
- **New WorldEffectType:** `CORAL_GROWTH`, `CORAL_BLEACHING`
|
||||
- **Feedback:** Healthy reef boosts hydration and water quality scores for the region
|
||||
|
||||
### 1.2 Kelp Forests
|
||||
- **Trigger:** Clean, cold ocean region (temp < 0.5, pollution < 30)
|
||||
- **Growth:** Place kelp column from OCEAN_FLOOR upward, max 8 blocks high; grows one block per fertile cycle
|
||||
- **Death:** High pollution converts kelp to dead bush / removes it
|
||||
- **New WorldEffectType:** `KELP_GROWTH`, `KELP_DIE`
|
||||
|
||||
### 1.3 Algae Blooms & Dead Zones
|
||||
- **Trigger:** Ocean pollution > 60 sustained for 10+ cycles
|
||||
- **Bloom:** Green-tinted water (place green stained glass? or particles), kelp overgrowth forced at high rate
|
||||
- **Die-off:** After bloom peaks, mass die-off deposits dirt/gravel on seafloor, spikes pollution further
|
||||
- **Dead zone:** Suppress all aquatic mob spawning (Cod, Salmon, Squid, Glow Squid, Dolphin) while active
|
||||
- **New WorldEffectType:** `ALGAE_BLOOM`, `DEAD_ZONE`
|
||||
- **New ClimateEventType:** `ALGAE_BLOOM`
|
||||
|
||||
### 1.4 Bioluminescence
|
||||
- **Trigger:** Clean deep ocean region (pollution < 15, depth > 32 below sea level)
|
||||
- **Effect:** Glow squid particle bursts at night only (check `dayTime > 12000`)
|
||||
- **No new WorldEffectType needed** — purely particle, handled in LivingWorldMod tick
|
||||
|
||||
### 1.5 Hydrothermal Vent Fields
|
||||
- **Trigger:** Oceanic volcanic regions in COOLING or FERTILE phase
|
||||
- **Effect:** Permanent seafloor feature: magma blocks and soul sand form around vent opening; continuous bubble/smoke particles; small pollution spike kept constant
|
||||
- **New WorldEffectType:** `HYDROTHERMAL_VENT`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Atmospheric Events
|
||||
|
||||
*Weather phenomena driven by the existing Atmosphere module. Most are purely additive — new WorldEffectTypes using existing block change infrastructure.*
|
||||
|
||||
### 2.1 Acid Rain
|
||||
- **Trigger:** Regional air pollution > 70 during a rain event
|
||||
- **Effect:** Stone/cobblestone on exposed surface → gravel; grass → dirt; surface plants die; soil contamination +5 per cycle during event
|
||||
- **Duration:** Lasts as long as raining + pollution remains high
|
||||
- **New WorldEffectType:** `ACID_RAIN_DAMAGE`
|
||||
- **New ClimateEventType:** `ACID_RAIN`
|
||||
|
||||
### 2.2 Blizzards
|
||||
- **Trigger:** Cold biome (temp < 0.15), high precipitation, wind speed above threshold
|
||||
- **Effect:** Snow layers accumulate on exposed surfaces each cycle (up to depth 4); standing water freezes to ice; mobs and players get slowness-equivalent push-back from wind
|
||||
- **Melt:** When temperature rises above 0 in same region, snow layers reduce one per cycle
|
||||
- **New WorldEffectType:** `SNOW_ACCUMULATION`, `WATER_FREEZES`
|
||||
- **New ClimateEventType:** `BLIZZARD`
|
||||
|
||||
### 2.3 Sandstorms
|
||||
- **Trigger:** Desert biome, low humidity, high wind
|
||||
- **Effect:** Sand deposits on exposed surfaces (place sand one block above lowest surrounding blocks); surface plants stripped; minor soil contamination (airborne particles)
|
||||
- **New WorldEffectType:** `SAND_DEPOSIT`
|
||||
- **New ClimateEventType:** `SANDSTORM`
|
||||
|
||||
### 2.4 Lightning Strikes
|
||||
- **Trigger:** Storm climate event, dry region (humidity < 30)
|
||||
- **Effect:** Single ignition point (one fire block) placed on a flammable surface block; spreads naturally via Minecraft fire tick — distinct from the existing area-wide WILDFIRE
|
||||
- **Implementation:** Use `ServerLevel.lightning()` or place fire directly; 1–3 strikes per storm cycle
|
||||
- **New ClimateEventType:** `LIGHTNING_STORM` (already partially covered by WILDFIRE; make it a separate trigger)
|
||||
|
||||
### 2.5 Fog Banks
|
||||
- **Trigger:** Coastal or wetland biome, humidity > 70, calm wind (low speed)
|
||||
- **Effect:** Dense particle fog at ground level (CLOUD particles in a 64×64 area); no block changes; purely visual + ambience
|
||||
- **Implementation:** LivingWorldMod tick, no new WorldEffectType needed
|
||||
|
||||
### 2.6 Heat Shimmer
|
||||
- **Trigger:** Desert biome OR pollution > 60, daytime only
|
||||
- **Effect:** Flame particles at very low count (0.01 speed) near ground; no block changes; visual indicator of heat stress
|
||||
- **Implementation:** LivingWorldMod tick, no new WorldEffectType needed
|
||||
|
||||
### 2.7 Auroras
|
||||
- **Trigger:** Cold/polar biome, clear night (no rain, time > 13000), atmosphere pollution < 10
|
||||
- **Effect:** Coloured enchantment-glyph or end rod particles high in the sky (y > 180); purely cosmetic
|
||||
- **Implementation:** LivingWorldMod tick, no new WorldEffectType needed
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Geological Activity
|
||||
|
||||
*Ground-rupturing events. Require careful throttling — these permanently reshape terrain.*
|
||||
|
||||
### 3.1 Geysers
|
||||
- **Trigger:** Volcanic-adjacent region OR high geothermal elevation OR dripstone-cave biome below
|
||||
- **Registration:** Like volcanoes, geyser sites are registered on first encounter and persist
|
||||
- **Cycle:** 5-minute dormant → 30-second active → 5-minute dormant
|
||||
- **Effect (active):** Column of water source blocks shoots upward (place 3–5 blocks of water at geyser X,Z at increasing Y each tick, then remove after 2 seconds); BUBBLE_COLUMN particles; travertine ring (calcite/tuff) grows 1 block wider each active cycle
|
||||
- **New WorldEffectType:** `GEYSER_ERUPT`, `TRAVERTINE_DEPOSIT`
|
||||
- **New field in bootstrap:** `Map<RegionCoordinate, GeyserSite>` (location + cooldown)
|
||||
|
||||
### 3.2 Earthquakes
|
||||
- **Trigger:** Volcanic region in BUILDING/ERUPTING phase (3× frequency), or any high-elevation region (1× base)
|
||||
- **Magnitude:** Low (1–3) = surface cracks only; High (4–5) = fissures + landslide trigger
|
||||
- **Effect:** Low magnitude — remove 3–5 random surface blocks in a line (creates crack pattern); replace with air + expose layer beneath. High magnitude — carve a fissure 2 blocks wide, 8–15 blocks deep in a random direction from region centre
|
||||
- **Player feedback:** Screen shake analogue — apply velocity impulse to nearby players/mobs
|
||||
- **New WorldEffectType:** `GROUND_CRACK`, `FISSURE_OPENS`
|
||||
- **New ClimateEventType:** `EARTHQUAKE`
|
||||
|
||||
### 3.3 Sinkholes
|
||||
- **Trigger:** Sustained high groundwater (> 80) + shallow cave system below surface (detected via downward scan for air pockets)
|
||||
- **Effect:** Surface collapses — remove a 3×3 to 5×5 area of blocks at surface, creating a sudden pit 5–15 blocks deep; water may flood the pit if groundwater is at max
|
||||
- **Rarity:** Very rare, ~0.05% chance per cycle when conditions met
|
||||
- **New WorldEffectType:** `SINKHOLE_COLLAPSES`
|
||||
- **New ClimateEventType:** `SINKHOLE`
|
||||
|
||||
### 3.4 Landslides
|
||||
- **Trigger:** Steep slope (elevation delta > 15 between adjacent regions) + high precipitation OR earthquake
|
||||
- **Effect:** Gravel/sand/dirt on steep surface "falls" — remove top 2 blocks of highest surface point in the region, place them at the lowest adjacent surface point (gravity simulation)
|
||||
- **New WorldEffectType:** `LANDSLIDE`
|
||||
|
||||
### 3.5 Lava Tubes
|
||||
- **Trigger:** Volcanic region COOLING phase, stochastic (~0.1% per cycle)
|
||||
- **Effect:** Scan downward from surface to find a lava-adjacent air pocket; remove the ceiling block, creating a visible opening to the lava below
|
||||
- **New WorldEffectType:** `LAVA_TUBE_COLLAPSE`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Hydrology Expansion
|
||||
|
||||
*Extends the existing river/groundwater/tidal systems with longer-timescale water dynamics.*
|
||||
|
||||
### 4.1 Flash Floods
|
||||
- **Trigger:** High precipitation (> 80) on barren/low-succession region (BARREN or SPARSE_GRASS) with high slope
|
||||
- **Effect:** Temporary surge of water source blocks placed along lowest terrain path through region; blocks removed after 5 minutes (track flood blocks in a `Map<RegionCoordinate, List<BlockPos>>`)
|
||||
- **New WorldEffectType:** `FLASH_FLOOD`
|
||||
- **New ClimateEventType:** `FLASH_FLOOD`
|
||||
|
||||
### 4.2 Spring Melt
|
||||
- **Trigger:** Cold region that transitions from freezing to thawing (temperature crosses 0.15 threshold)
|
||||
- **Effect:** Ice blocks → water, snow layers removed top-down one per cycle; groundwater spikes; briefly triggers GROUND_SPRING_EMERGES in adjacent lower-elevation regions
|
||||
- **New WorldEffectType:** `ICE_MELTS`, `SNOW_MELTS`
|
||||
|
||||
### 4.3 Drought Cycles
|
||||
- **Trigger:** Atmosphere module humidity < 15 sustained for 20+ cycles
|
||||
- **Effect:** River water sources removed (RIVER_CARVE in reverse); exposed riverbeds turn to coarse dirt / cracked stone (using existing GRASS_DEGRADES chain); groundwater level drops 2 per cycle
|
||||
- **New WorldEffectType:** `RIVERBED_DRIES`
|
||||
- **New ClimateEventType:** `DROUGHT` (probably already exists — extend it)
|
||||
|
||||
### 4.4 Sedimentation & Delta Formation
|
||||
- **Trigger:** River-carrying region (high riverFlowIntensity) meets an ocean-adjacent region
|
||||
- **Effect:** Each cycle, place sand or gravel blocks at the mouth of the river just below sea level; over dozens of cycles these accumulate into a visible delta fan
|
||||
- **Elevation resample:** Queue `pendingElevationResample` after delta grows to trigger island-check logic
|
||||
- **New WorldEffectType:** `SEDIMENT_DEPOSIT`
|
||||
|
||||
### 4.5 Sea Level Change
|
||||
- **Mechanism:** New `seaLevel` config value (default 62) that can drift ±3 blocks over very long timeframes
|
||||
- **Trigger:** Global sustained rainfall (average precipitation across all active regions > 65) raises sea level by 1 block every 500 cycles; sustained drought lowers it
|
||||
- **Effect:** Use existing `applyTideBlocks` logic as the block-change driver, but shift the baseline permanently
|
||||
- **Broadcast:** Server message when sea level changes by 1 block
|
||||
|
||||
### 4.6 Glaciers
|
||||
- **Trigger:** Cold high-elevation region (temp < 0, elev > 90) with sustained blizzard history
|
||||
- **Effect:** Glacier "toe" (lowest point of snowfield) pushes gravel/stone blocks one position downhill per 50 cycles; leaves a trail of polished andesite (glacier-polished rock) behind
|
||||
- **New WorldEffectType:** `GLACIER_ADVANCE`, `GLACIER_POLISH`
|
||||
|
||||
### 4.7 pH Cascade
|
||||
- **Trigger:** Acid rain event in region with river flowing to ocean
|
||||
- **Effect:** Ocean pollution in downstream region spikes; if dripstone cave biome detected below region, neutralisation bonus applied (limestone buffer)
|
||||
- **Salmon/fish spawning** in affected regions suppressed while pH is acidic (pollution > 50)
|
||||
- **No new WorldEffectType** — uses existing pollution data
|
||||
|
||||
### 4.8 Waterfalls Carving Plunge Pools
|
||||
- **Trigger:** River-carve region with large elevation drop to adjacent region (delta > 10)
|
||||
- **Effect:** At the drop point, scan the base of the waterfall for a small depression; deepen it one block per cycle (remove stone/gravel at base, replace with water)
|
||||
- **New WorldEffectType:** `PLUNGE_POOL_DEEPENS`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Ecology
|
||||
|
||||
*Biological systems — mobs responding to ecosystem state. Most are spawn suppression/boost mechanics using existing NeoForge spawn events.*
|
||||
|
||||
### 5.1 Migration Patterns
|
||||
- **Mechanism:** Track `Map<RegionCoordinate, Integer> passiveMobPressure` — incremented each time a passive mob spawns in a region, decremented each time one is killed or despawns
|
||||
- **Effect:** High-vegetation, low-pollution regions attract passive mobs (boost spawn rate 50%); barren/polluted regions suppress them (same logic as existing health-based suppression, but add directional bias toward healthy neighbours)
|
||||
- **Visible result:** Herds naturally congregate in meadows and forests; wastelands are empty of animals
|
||||
|
||||
### 5.2 Predator–Prey Pressure
|
||||
- **Mechanism:** If passive mob pressure in a region is high, hostile mob spawn rate is boosted (prey attracts predators); if passive mob pressure is very low, hostile mobs also suppress (no prey = predators leave)
|
||||
- **Uses existing** FinalizeSpawnEvent hook — add predator-prey calculation alongside ecosystem health check
|
||||
|
||||
### 5.3 Local Extinction
|
||||
- **Mechanism:** Track `Set<RegionCoordinate> extinctRegions`; a region enters this set if passive mob pressure = 0 for 100+ consecutive cycles
|
||||
- **Effect:** No passive mob spawning allowed until succession reaches YOUNG_WOODLAND or higher
|
||||
- **Recovery:** On reaching threshold, remove from set and broadcast "Wildlife returning to region (x,z)"
|
||||
|
||||
### 5.4 Pollinator Collapse
|
||||
- **Trigger:** Pollution > 50 in a region that contains bee nests or has flowering vegetation
|
||||
- **Effect:** Bee spawning suppressed; flower spread (VEGETATION_SPREADS placing flowers) disabled; crop growth rate feedback reduces fertility gain by 50%
|
||||
- **Recovery:** Pollution drops below 20 → bee spawning resumes, flower spread re-enables
|
||||
|
||||
### 5.5 Rewilding
|
||||
- **Trigger:** Player plants 10+ saplings in a BARREN or SPARSE_GRASS region within a single game day
|
||||
- **Effect:** Bootstrap detects this via a new sapling-placement counter; if threshold met, grant a one-time succession boost (+2 stages over next 20 cycles) and rapid pollution decay
|
||||
- **Broadcast:** "Rewilding effort detected in region (x,z) — ecosystem recovering!"
|
||||
|
||||
### 5.6 Peat Bogs
|
||||
- **Trigger:** Wetland/swamp biome, waterlogged for 30+ consecutive cycles, low succession pressure
|
||||
- **Effect:** Grass/dirt on waterlogged surface slowly converts to mud blocks; over time mud → packed mud → a distinct "peat" layer (use brown terracotta or existing mud); permanently lowers succession cap for the region (bogs stay boggy)
|
||||
- **New WorldEffectType:** `PEAT_FORMS`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Seasons & Long Cycles
|
||||
|
||||
*Multi-cycle temporal patterns that give the world a rhythm players notice over days of play.*
|
||||
|
||||
### 6.1 Leaf Colour Change
|
||||
- **Trigger:** Temperature crossing cold threshold (< 0.2) after a period of warmth; occurs once per seasonal cycle
|
||||
- **Effect:** Oak and birch leaves on surface trees swap to orange/brown terracotta for 5–10 cycles, then leaves are removed (leaf fall); particles simulate leaf scatter
|
||||
- **New WorldEffectType:** `LEAVES_CHANGE_COLOUR`, `LEAVES_FALL`
|
||||
|
||||
### 6.2 Soil Crust
|
||||
- **Trigger:** Drought cycle + bare exposed dirt (no plant cover) for 15+ consecutive cycles
|
||||
- **Effect:** Exposed dirt/coarse dirt → terracotta (baked clay); water no longer pools on it (hydration score penalised); crack visual (particles)
|
||||
- **Breaking:** Heavy rain event removes terracotta → reverts to gravel first, then dirt over time
|
||||
- **New WorldEffectType:** `SOIL_CRUSTS`
|
||||
|
||||
### 6.3 Permafrost Thaw
|
||||
- **Trigger:** Cold region (temp < 0) that experiences sustained temperature rise (climate warming event or neighbouring pollution heat)
|
||||
- **Effect:** Subsurface stone/gravel → mud; surface depression forms; groundwater spikes (permafrost melt adds water); region permanently shifts to higher succession cap
|
||||
- **New WorldEffectType:** `PERMAFROST_THAWS`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Player Feedback Loops
|
||||
|
||||
*Systems that make player actions have visible, lasting consequences on the world.*
|
||||
|
||||
### 7.1 Deforestation Cascade
|
||||
- **Mechanism:** When tree pressure drops below 5 for 10+ consecutive cycles (logging pressure), trigger cascade:
|
||||
- Soil erosion rate ×2 (faster contamination rise)
|
||||
- River silt (gravel replaces sand blocks in river channel)
|
||||
- Flash flood risk doubles (lower threshold for trigger)
|
||||
- Succession cap locked at SPARSE_GRASS until tree pressure recovers
|
||||
- **Broadcast:** "Deforestation detected at (x,z) — soil erosion accelerating"
|
||||
|
||||
### 7.2 Crop Exhaustion
|
||||
- **Trigger:** Farmland blocks detected in region (scan during water body scan cycle) with soil fertility < 20 AND contamination > 40
|
||||
- **Effect:** Farmland → coarse dirt over time; crop growth suppressed; broadcast warning to nearby players
|
||||
- **Recovery:** Player uses bone meal OR region is left fallow (no farming activity for 50 cycles) → fertility slowly recovers
|
||||
|
||||
### 7.3 Soundscape Degradation
|
||||
- **Mechanism:** Existing `tryPlayRegionAmbience()` is already in place — extend it:
|
||||
- MATURE_FOREST + low pollution → full bird/insect ambience (BIRD_AMBIENT or custom sound)
|
||||
- GRASSLAND → lighter ambience
|
||||
- BARREN + high pollution → dead silence (suppress all ambient sounds)
|
||||
- Transition is gradual — blend based on health score
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Underground Systems
|
||||
|
||||
*Block changes below the surface — rare but dramatic.*
|
||||
|
||||
### 8.1 Cave Flooding
|
||||
- **Trigger:** Groundwater level > 80 AND a natural cave opening (air block) detected within 10 blocks below surface
|
||||
- **Effect:** Place water source at the cave opening; water spreads naturally via Minecraft physics into the cave system
|
||||
- **New WorldEffectType:** `CAVE_FLOODS`
|
||||
|
||||
### 8.2 Mineral Vein Shifting
|
||||
- **Trigger:** Volcanic FERTILE phase
|
||||
- **Effect:** Scan 5–20 blocks below surface for ore blocks adjacent to stone; convert surrounding stone → basalt/tuff (burying the ore); simultaneously, scan shallow depth (5–10 blocks) for stone with no adjacent ore and convert some to raw gold block or copper ore (new vein exposure)
|
||||
- **New WorldEffectType:** `VEIN_SHIFTS`
|
||||
|
||||
### 8.3 Stalactite Growth
|
||||
- **Trigger:** Wet cave biome (dripstone caves) in a high-groundwater region
|
||||
- **Effect:** Scan cave ceilings (air block with solid block above); place one pointed dripstone block hanging downward per many cycles; extremely slow — one block per 100 cycles
|
||||
- **New WorldEffectType:** `STALACTITE_GROWS`
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Sound & Ambience Polish
|
||||
|
||||
*No block changes — purely audio-visual reinforcement of existing systems.*
|
||||
|
||||
### 9.1 Eruption Rumble
|
||||
- Play `BLAZE_AMBIENT` or `BASALT_DELTAS_MOOD` looped for players within 3 regions of a BUILDING or ERUPTING volcano — already partially implemented; extend range and vary pitch by phase
|
||||
|
||||
### 9.2 Storm Pressure Drop
|
||||
- When a ClimateEventType storm is incoming (1–2 cycles before trigger), play `WEATHER_RAIN` at low volume as a warning cue
|
||||
|
||||
### 9.3 Ocean Current Drift
|
||||
- Items dropped in ocean source blocks get a velocity nudge matching the existing tidal current direction; uses the `ItemEntity` tick event
|
||||
- Boats and floating players already affected by current system; extend to dropped item entities
|
||||
|
||||
### 9.4 Soundscape Biome Blending
|
||||
- Extend soundscape degradation (7.3) to blend between multiple ambient tracks rather than binary on/off; use a weighted selection based on health + succession stage
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Fantastical / Minecraft-Specific Extensions
|
||||
|
||||
*Not real-world but fits Minecraft's universe and makes each dimension feel alive.*
|
||||
|
||||
### 10.1 Nether Bleed
|
||||
- **Trigger:** Region within 3 chunks of an active Nether portal for 50+ cycles
|
||||
- **Effect:** Netherrack patches appear on surface; crimson fungi / warped fungi spread; soul sand appears in depressions; ambient fire sound increases
|
||||
- **New WorldEffectType:** `NETHER_BLEED`
|
||||
|
||||
### 10.2 End Corruption
|
||||
- **Trigger:** Region within 3 chunks of an End portal frame
|
||||
- **Effect:** Chorus plant shoots appear on high ground; end stone patches replace stone on surface; obsidian pillars slowly grow (place one block per many cycles)
|
||||
- **New WorldEffectType:** `END_CORRUPTION`
|
||||
|
||||
### 10.3 Ancient Site Exposure
|
||||
- **Trigger:** Sustained hydraulic erosion (HYDRAULIC_EROSION WorldEffect, high intensity) OR earthquake (magnitude 4+) in a region with buried structure blocks detected below surface (scan for mossy cobblestone, chiselled stone bricks, deepslate tiles within 10 blocks of surface)
|
||||
- **Effect:** Remove the surface blocks above the buried structure to expose it; place torch/lantern inside to signal the discovery; broadcast "Ancient ruins uncovered at (x,z)!"
|
||||
- **New WorldEffectType:** `RUINS_EXPOSED`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Adding a new WorldEffectType
|
||||
1. Add enum constant + Javadoc to `WorldEffectType.java`
|
||||
2. Add switch case in `NeoForgeWorldEffectExecutor.consume()`
|
||||
3. Write the private handler method
|
||||
|
||||
### Adding a new ClimateEventType
|
||||
1. Add enum constant to `ClimateEventType.java`
|
||||
2. Add trigger logic in `LivingWorldBootstrap` (usually in `runPostSimHooks()`)
|
||||
3. Add to `getEventsStatusFor()` display if needed
|
||||
|
||||
### Adding a new per-region data field
|
||||
- Store in `LivingWorldBootstrap` as `Map<RegionCoordinate, T>`
|
||||
- Clear in `onServerStopping()`
|
||||
- Expose via public getter if needed by `LivingWorldMod`
|
||||
|
||||
### Throttling rules
|
||||
- Block-placing effects: always gate with `random.nextDouble() > threshold` to prevent performance spikes
|
||||
- Scan loops: cap at `REGION_BLOCKS` samples, never unbounded
|
||||
- Elevation resamples: use `pendingElevationResample` pattern — don't re-read heightmaps on every tick
|
||||
|
||||
### Performance budget
|
||||
- Each new phase should add no more than ~0.1ms per tick to the simulation cycle
|
||||
- Particle effects: max 5 per region per tick
|
||||
- Block writes: max 10 per region per WorldEffectRequest execution
|
||||
|
||||
---
|
||||
|
||||
## Rough Priority / Effort Matrix
|
||||
|
||||
| Feature | Player Impact | Effort | Dependency |
|
||||
|---|---|---|---|
|
||||
| Coral reefs | High | Low | Phase 1 |
|
||||
| Kelp forests | Medium | Low | Phase 1 |
|
||||
| Algae blooms | High | Medium | Phase 1 |
|
||||
| Acid rain | High | Low | Phase 2 |
|
||||
| Blizzards | High | Medium | Phase 2 |
|
||||
| Geysers | High | Medium | Phase 3 |
|
||||
| Earthquakes | High | Medium | Phase 3 |
|
||||
| Sinkholes | High | Low | Phase 3 |
|
||||
| Landslides | Medium | Low | Phase 3 |
|
||||
| Flash floods | High | Medium | Phase 4 |
|
||||
| Drought cycles | High | Low | Phase 4 |
|
||||
| Migration patterns | Very High | Medium | Phase 5 |
|
||||
| Pollinator collapse | High | Low | Phase 5 |
|
||||
| Deforestation cascade | Very High | Low | Phase 7 |
|
||||
| Leaf colour change | High | Low | Phase 6 |
|
||||
| Sea level change | Very High | High | Phase 4 |
|
||||
| Delta formation | High | Medium | Phase 4 |
|
||||
| Cave flooding | High | Low | Phase 8 |
|
||||
| Nether bleed | Medium | Low | Phase 10 |
|
||||
| Ancient site exposure | High | Medium | Phase 10 |
|
||||
@@ -45,4 +45,7 @@ dependencies {
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
scanForTestClasses = false
|
||||
include '**/*Test.class'
|
||||
include '**/*Test$*.class'
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,28 @@
|
||||
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"),
|
||||
VOLCANIC_ERUPTION("Volcanic Eruption", "Lava flows and ash clouds from an active volcano"),
|
||||
ALGAE_BLOOM("Algae Bloom", "Nutrient pollution causing a bloom and aquatic dead zone"),
|
||||
ACID_RAIN("Acid Rain", "Polluted rainfall damaging soil, stone and vegetation"),
|
||||
BLIZZARD("Blizzard", "Wind-driven snow accumulation and freezing water"),
|
||||
SANDSTORM("Sandstorm", "Dry high winds stripping plants and depositing sand"),
|
||||
LIGHTNING_STORM("Lightning Storm", "Dry thunderstorm producing isolated ignition strikes"),
|
||||
EARTHQUAKE("Earthquake", "Seismic rupture opening cracks and unstable slopes"),
|
||||
SINKHOLE("Sinkhole", "Groundwater-driven collapse into a shallow cave"),
|
||||
FLASH_FLOOD("Flash Flood", "Rapid runoff surging across barren steep 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; }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.livingworld.climate;
|
||||
|
||||
import com.livingworld.debug.DiagnosticCategory;
|
||||
import com.livingworld.debug.LivingWorldLogger;
|
||||
import com.livingworld.modules.vegetation.VegetationModule;
|
||||
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Tracks planetary-scale atmospheric carbon and its derived climate effects.
|
||||
*
|
||||
* <h3>Carbon cycle</h3>
|
||||
* Each simulation cycle, the tracker aggregates:
|
||||
* <ul>
|
||||
* <li>Emissions — proportional to total air pollution across all active regions.
|
||||
* <li>Sink — proportional to total tree canopy pressure across all active regions.
|
||||
* </ul>
|
||||
* The net delta is applied to the running {@code carbonPpm} value which drifts
|
||||
* slowly — meaningful climate change requires sustained industrial activity or
|
||||
* sustained reforestation to reverse.
|
||||
*
|
||||
* <h3>Derived effects</h3>
|
||||
* <ul>
|
||||
* <li>{@link #getWarmingLevel()} — 0.0 (pre-industrial) to 1.0 (catastrophic, +5°C).
|
||||
* <li>{@link #getRainPenalty()} — subtracted from every region's atmospheric rain target.
|
||||
* <li>{@link #getBiodiversityIndex()} — average ecosystem health across all active regions.
|
||||
* </ul>
|
||||
*
|
||||
* <h3>Persistence</h3>
|
||||
* Carbon state is saved to {@code living_world/global_climate.dat} on server stop and
|
||||
* restored on server start, so climate change accumulates across play sessions.
|
||||
*/
|
||||
public final class GlobalClimateTracker {
|
||||
|
||||
// Pre-industrial CO₂ baseline (ppm).
|
||||
private static final double CARBON_BASELINE = 280.0;
|
||||
// Level at which warming reaches +5°C equivalent (arbitrary but feels right for gameplay).
|
||||
private static final double CARBON_CRITICAL = 800.0;
|
||||
|
||||
// Per sim cycle: each unit of total regional air-pollution score → ppm added.
|
||||
private static final double EMISSION_RATE = 0.05;
|
||||
// Per sim cycle: each unit of total tree pressure → ppm removed.
|
||||
private static final double SINK_RATE = 0.002;
|
||||
|
||||
// Maximum rain-target reduction at full warming (applied globally per region).
|
||||
private static final double MAX_RAIN_PENALTY = 0.15;
|
||||
|
||||
private double carbonPpm = CARBON_BASELINE;
|
||||
private double warmingLevel = 0.0; // 0–1
|
||||
private double biodiversityIndex = 60.0; // 0–100
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Simulation update
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Updates the global climate state from the current state of all active regions.
|
||||
* Must be called once per simulation cycle after modules have run.
|
||||
*/
|
||||
public void update(Collection<Region> regions) {
|
||||
if (regions.isEmpty()) return;
|
||||
|
||||
double totalPollution = 0.0;
|
||||
double totalTreePressure = 0.0;
|
||||
double totalHealth = 0.0;
|
||||
int count = 0;
|
||||
|
||||
for (Region region : regions) {
|
||||
totalPollution += region.getMetrics().getPollutionScore();
|
||||
VegetationRegionData veg = region.getModuleData()
|
||||
.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
|
||||
.orElse(null);
|
||||
if (veg != null) totalTreePressure += veg.getTreePressure();
|
||||
totalHealth += region.getMetrics().getEcosystemHealth();
|
||||
count++;
|
||||
}
|
||||
|
||||
double emissions = totalPollution * EMISSION_RATE;
|
||||
double sink = totalTreePressure * SINK_RATE;
|
||||
carbonPpm = Math.min(CARBON_CRITICAL, Math.max(CARBON_BASELINE, carbonPpm + emissions - sink));
|
||||
warmingLevel = (carbonPpm - CARBON_BASELINE) / (CARBON_CRITICAL - CARBON_BASELINE);
|
||||
biodiversityIndex = totalHealth / count;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Accessors
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Raw atmospheric carbon in ppm (280 = pre-industrial baseline, 800 = critical). */
|
||||
public double getCarbonPpm() { return carbonPpm; }
|
||||
|
||||
/** Warming level 0.0–1.0 (0 = baseline, 1 = +5°C equivalent). */
|
||||
public double getWarmingLevel() { return warmingLevel; }
|
||||
|
||||
/** Temperature anomaly in degrees Celsius equivalent (0–5°C). */
|
||||
public double getTemperatureCelsius(){ return warmingLevel * 5.0; }
|
||||
|
||||
/** Average ecosystem health across all active regions (0–100). */
|
||||
public double getBiodiversityIndex() { return biodiversityIndex; }
|
||||
|
||||
/**
|
||||
* Rain-target penalty applied globally in AtmosphereModule.
|
||||
* Increases as warming rises; max {@value MAX_RAIN_PENALTY} at full warming.
|
||||
*/
|
||||
public double getRainPenalty() { return warmingLevel * MAX_RAIN_PENALTY; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Persistence
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public void save(Path path) {
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Properties props = new Properties();
|
||||
props.setProperty("carbonPpm", String.valueOf(carbonPpm));
|
||||
try (OutputStream out = Files.newOutputStream(path)) {
|
||||
props.store(out, "Living World global climate state — do not edit manually");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LivingWorldLogger.warn(DiagnosticCategory.BOOTSTRAP,
|
||||
"Failed to save global climate state: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void load(Path path) {
|
||||
if (!Files.exists(path)) return;
|
||||
try {
|
||||
Properties props = new Properties();
|
||||
try (InputStream in = Files.newInputStream(path)) {
|
||||
props.load(in);
|
||||
}
|
||||
carbonPpm = Double.parseDouble(props.getProperty("carbonPpm",
|
||||
String.valueOf(CARBON_BASELINE)));
|
||||
warmingLevel = (carbonPpm - CARBON_BASELINE) / (CARBON_CRITICAL - CARBON_BASELINE);
|
||||
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP,
|
||||
String.format("Loaded global climate: %.1f ppm, warming +%.2f°C",
|
||||
carbonPpm, getTemperatureCelsius()));
|
||||
} catch (IOException | NumberFormatException e) {
|
||||
LivingWorldLogger.warn(DiagnosticCategory.BOOTSTRAP,
|
||||
"Failed to load global climate state — resetting to baseline: " + e.getMessage());
|
||||
carbonPpm = CARBON_BASELINE;
|
||||
warmingLevel = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import com.livingworld.core.LivingWorldConstants;
|
||||
import com.livingworld.modules.atmosphere.AtmosphereModule;
|
||||
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
|
||||
import com.livingworld.modules.pollution.PollutionModule;
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryModule;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import com.livingworld.modules.soil.SoilModule;
|
||||
import com.livingworld.modules.soil.SoilRegionData;
|
||||
import com.livingworld.modules.vegetation.VegetationModule;
|
||||
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||
import com.livingworld.modules.water.WaterModule;
|
||||
import com.livingworld.modules.water.WaterRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionManager;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/**
|
||||
* One-command demo presets for watching ecosystem transformation.
|
||||
*
|
||||
* <pre>
|
||||
* /lw demo degrade — forces MATURE_FOREST then applies severe drought + dead soil
|
||||
* so the region visibly regresses toward BARREN.
|
||||
* Recommended speed: /lw speed 20
|
||||
*
|
||||
* /lw demo recover — resets soil and water to lush conditions so the region
|
||||
* advances from wherever it is back toward MATURE_FOREST.
|
||||
* Recommended speed: /lw speed 50
|
||||
* </pre>
|
||||
*/
|
||||
public final class EcoDemoCommand {
|
||||
|
||||
private EcoDemoCommand() {}
|
||||
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> build(Supplier<RegionManager> rm) {
|
||||
return Commands.literal("demo")
|
||||
.then(Commands.literal("degrade")
|
||||
.executes(ctx -> runDegrade(ctx.getSource(), rm.get())))
|
||||
.then(Commands.literal("recover")
|
||||
.executes(ctx -> runRecover(ctx.getSource(), rm.get())));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Presets
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static int runDegrade(CommandSourceStack src, RegionManager rm) {
|
||||
return applyPreset(src, rm, "DEGRADE", region -> {
|
||||
// Force starting point: peak forest.
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID,
|
||||
new RecoveryRegionData(SuccessionStage.MATURE_FOREST, 0.0, 0.0,
|
||||
SuccessionStage.MATURE_FOREST));
|
||||
|
||||
// Lush vegetation so it visibly dies rather than starting at zero.
|
||||
region.getModuleData().put(VegetationModule.MODULE_ID,
|
||||
new VegetationRegionData(100.0, 80.0, 80.0, 70.0, 0.0));
|
||||
|
||||
// Dead soil: near-zero fertility, high erosion and contamination.
|
||||
// soilQuality ≈ 5 − 8×0.4 − 8×0.3 = 5 − 3.2 − 2.4 = −0.6 → 0
|
||||
// That floor triggers badConditions in VegetationModule and
|
||||
// conditionsMissedForRegression in RecoveryModule every cycle.
|
||||
SoilRegionData soil = SoilRegionData.defaults();
|
||||
soil.setFertility(5.0);
|
||||
soil.setMoisture(5.0);
|
||||
soil.setContamination(8.0);
|
||||
soil.setErosion(8.0);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
|
||||
// Severe drought — water availability near zero.
|
||||
WaterRegionData water = WaterRegionData.defaults();
|
||||
water.setWaterAvailability(5.0);
|
||||
water.setDroughtRisk(90.0);
|
||||
water.setPurificationCapacity(5.0);
|
||||
region.getModuleData().put(WaterModule.MODULE_ID, water);
|
||||
|
||||
// Clear pollution so it doesn't confuse the read-out (soil drives the decay here).
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
|
||||
|
||||
// Dry sky: low rain so per-player weather reflects the drought.
|
||||
AtmosphereRegionData atm = new AtmosphereRegionData(0.05, 0.0);
|
||||
region.getModuleData().put(AtmosphereModule.MODULE_ID, atm);
|
||||
}, "Stage forced to MATURE_FOREST | Soil destroyed | Drought=90 | Use /lw speed 20 to watch");
|
||||
}
|
||||
|
||||
private static int runRecover(CommandSourceStack src, RegionManager rm) {
|
||||
return applyPreset(src, rm, "RECOVER", region -> {
|
||||
// Raise cap — region must be allowed to reach MATURE_FOREST again.
|
||||
RecoveryRegionData rec = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
|
||||
.orElseGet(RecoveryRegionData::defaults);
|
||||
rec.setMaxSuccessionStage(SuccessionStage.MATURE_FOREST);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, rec);
|
||||
|
||||
// Rich, clean soil — contamination and erosion both zeroed.
|
||||
SoilRegionData soil = SoilRegionData.defaults();
|
||||
soil.setFertility(85.0);
|
||||
soil.setMoisture(75.0);
|
||||
soil.setContamination(0.0);
|
||||
soil.setErosion(0.0);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
|
||||
// Plentiful water, no drought.
|
||||
WaterRegionData water = WaterRegionData.defaults();
|
||||
water.setWaterAvailability(85.0);
|
||||
water.setDroughtRisk(5.0);
|
||||
water.setPurificationCapacity(80.0);
|
||||
region.getModuleData().put(WaterModule.MODULE_ID, water);
|
||||
|
||||
// Clean air.
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
|
||||
|
||||
// Good rain signal in the atmosphere.
|
||||
AtmosphereRegionData atm = new AtmosphereRegionData(0.75, 0.0);
|
||||
region.getModuleData().put(AtmosphereModule.MODULE_ID, atm);
|
||||
|
||||
// Seed minimal grass so vegetation succession has something to start from.
|
||||
// (Shrubs and trees grow naturally from here.)
|
||||
VegetationRegionData veg = region.getModuleData()
|
||||
.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
|
||||
.orElseGet(VegetationRegionData::defaults);
|
||||
veg.setGrassPressure(Math.max(veg.getGrassPressure(), 5.0));
|
||||
veg.setDeadVegetation(0.0);
|
||||
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
|
||||
}, "Cap raised to MATURE_FOREST | Soil+water restored | Use /lw speed 50 to watch");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static int applyPreset(CommandSourceStack src, RegionManager rm,
|
||||
String label, RegionMutation mutation, String hint) {
|
||||
if (rm == null) { src.sendFailure(Component.literal("Region manager not ready.")); return 0; }
|
||||
ServerPlayer player;
|
||||
try { player = src.getPlayerOrException(); }
|
||||
catch (CommandSyntaxException e) {
|
||||
src.sendFailure(Component.literal("/lw demo must be run by a player."));
|
||||
return 0;
|
||||
}
|
||||
|
||||
String dimId = player.level().dimension().location().toString();
|
||||
int rx = (int) Math.floor(player.getX() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
|
||||
int rz = (int) Math.floor(player.getZ() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
|
||||
RegionCoordinate coord = new RegionCoordinate(dimId, rx, rz);
|
||||
|
||||
Region region = rm.getOrCreateRegion(coord);
|
||||
mutation.apply(region);
|
||||
rm.markDirty(region);
|
||||
|
||||
src.sendSuccess(() -> Component.literal(
|
||||
"[LW] Demo:" + label + " applied to (" + rx + "," + rz + ") — " + hint), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface RegionMutation { void apply(Region region); }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import com.livingworld.core.simulation.SimulationManager;
|
||||
import com.livingworld.regions.RegionManager;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* Forces the full module pipeline to run on a single region immediately,
|
||||
* bypassing the scheduler. Useful for observing simulation effects without
|
||||
* waiting for the next scheduled tick.
|
||||
*/
|
||||
public final class ForceUpdateCommand {
|
||||
|
||||
private ForceUpdateCommand() {
|
||||
}
|
||||
|
||||
public static int executeAtSelf(
|
||||
CommandSourceStack source,
|
||||
RegionManager regionManager,
|
||||
SimulationManager simulationManager) {
|
||||
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
|
||||
if (simulationManager == null) throw new IllegalArgumentException("simulationManager must not be null");
|
||||
|
||||
Vec3 pos = source.getPosition();
|
||||
String dimensionId = source.getLevel().dimension().location().toString();
|
||||
// Ensure the region is loaded/created before asking the simulation manager to update it.
|
||||
var region = regionManager.getOrCreateRegionAtBlock(
|
||||
dimensionId, (int) Math.floor(pos.x), (int) Math.floor(pos.z));
|
||||
simulationManager.forceUpdateRegion(region.getCoordinate());
|
||||
|
||||
String msg = "Forced update on region " + region.getCoordinate().stableId()
|
||||
+ " — run '/lw region info' to see the result.";
|
||||
source.sendSuccess(() -> Component.literal(msg), true);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.IntUnaryOperator;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.arguments.DoubleArgumentType;
|
||||
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
import com.livingworld.core.simulation.SimulationManager;
|
||||
import com.livingworld.modules.ModuleRegistry;
|
||||
@@ -33,14 +39,28 @@ public final class LivingWorldCommandRoot {
|
||||
dispatcher,
|
||||
() -> regionManager,
|
||||
() -> moduleRegistry,
|
||||
() -> simulationManager);
|
||||
() -> simulationManager,
|
||||
uuid -> false,
|
||||
css -> "Atmosphere not available.",
|
||||
css -> "Climate not available.",
|
||||
n -> n,
|
||||
() -> "Wind not available.",
|
||||
css -> "Events not available.",
|
||||
() -> "No volcanic regions.");
|
||||
}
|
||||
|
||||
public static void registerDeferred(
|
||||
CommandDispatcher<CommandSourceStack> dispatcher,
|
||||
Supplier<RegionManager> regionManager,
|
||||
Supplier<ModuleRegistry> moduleRegistry,
|
||||
Supplier<SimulationManager> simulationManager) {
|
||||
Supplier<SimulationManager> simulationManager,
|
||||
Function<UUID, Boolean> hudToggle,
|
||||
Function<CommandSourceStack, String> atmosphereStatus,
|
||||
Function<CommandSourceStack, String> climateStatus,
|
||||
IntUnaryOperator speedSetter,
|
||||
Supplier<String> windStatus,
|
||||
Function<CommandSourceStack, String> eventsStatus,
|
||||
Supplier<String> volcanoStatus) {
|
||||
if (dispatcher == null) {
|
||||
throw new IllegalArgumentException("dispatcher must not be null");
|
||||
}
|
||||
@@ -64,14 +84,31 @@ public final class LivingWorldCommandRoot {
|
||||
requireService(simulationManager, "simulationManager"))))
|
||||
.then(Commands.literal("region")
|
||||
.then(Commands.literal("info")
|
||||
.executes(context -> RegionInfoCommand.execute(
|
||||
.executes(context -> RegionInfoCommand.executeAtSelf(
|
||||
context.getSource(),
|
||||
requireService(regionManager, "regionManager")))))
|
||||
requireService(regionManager, "regionManager")))
|
||||
.then(Commands.argument("x", IntegerArgumentType.integer())
|
||||
.then(Commands.argument("z", IntegerArgumentType.integer())
|
||||
.executes(context -> RegionInfoCommand.executeAt(
|
||||
context.getSource(),
|
||||
requireService(regionManager, "regionManager"),
|
||||
IntegerArgumentType.getInteger(context, "x"),
|
||||
IntegerArgumentType.getInteger(context, "z"))))))
|
||||
.then(Commands.literal("force-update")
|
||||
.executes(context -> ForceUpdateCommand.executeAtSelf(
|
||||
context.getSource(),
|
||||
requireService(regionManager, "regionManager"),
|
||||
requireService(simulationManager, "simulationManager"))))
|
||||
.then(RegionBordersCommand.build()))
|
||||
.then(Commands.literal("modules")
|
||||
.then(Commands.literal("list")
|
||||
.executes(context -> listModules(
|
||||
context.getSource(),
|
||||
requireService(moduleRegistry, "moduleRegistry")))))
|
||||
.then(Commands.literal("stats")
|
||||
.executes(context -> StatsCommand.execute(
|
||||
context.getSource(),
|
||||
requireService(simulationManager, "simulationManager"))))
|
||||
.then(Commands.literal("simulate")
|
||||
.then(Commands.argument(
|
||||
"ticks",
|
||||
@@ -80,7 +117,75 @@ public final class LivingWorldCommandRoot {
|
||||
.executes(context -> SimulateCommand.execute(
|
||||
context.getSource(),
|
||||
requireService(simulationManager, "simulationManager"),
|
||||
IntegerArgumentType.getInteger(context, "ticks"))))));
|
||||
IntegerArgumentType.getInteger(context, "ticks")))))
|
||||
.then(Commands.literal("hud")
|
||||
.executes(context -> toggleHud(context.getSource(), hudToggle)))
|
||||
.then(Commands.literal("atmosphere")
|
||||
.executes(context -> {
|
||||
String info = atmosphereStatus.apply(context.getSource());
|
||||
context.getSource().sendSuccess(() -> Component.literal(info), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("climate")
|
||||
.executes(context -> {
|
||||
String info = climateStatus.apply(context.getSource());
|
||||
context.getSource().sendSuccess(() -> Component.literal(info), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("wind")
|
||||
.executes(context -> {
|
||||
String info = windStatus.get();
|
||||
context.getSource().sendSuccess(() -> Component.literal(info), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("speed")
|
||||
.then(Commands.argument("multiplier", IntegerArgumentType.integer(1, 100))
|
||||
.executes(context -> {
|
||||
int n = IntegerArgumentType.getInteger(context, "multiplier");
|
||||
int actual = speedSetter.applyAsInt(n);
|
||||
context.getSource().sendSuccess(
|
||||
() -> Component.literal("[LW] Simulation speed: " + actual + "x"),
|
||||
false);
|
||||
return actual;
|
||||
}))
|
||||
.then(Commands.literal("reset")
|
||||
.executes(context -> {
|
||||
speedSetter.applyAsInt(1);
|
||||
context.getSource().sendSuccess(
|
||||
() -> Component.literal("[LW] Simulation speed reset to 1x"),
|
||||
false);
|
||||
return 1;
|
||||
})))
|
||||
.then(LivingWorldMapCommand.build(regionManager))
|
||||
.then(RegionSetCommand.build(regionManager))
|
||||
.then(EcoDemoCommand.build(regionManager))
|
||||
.then(Commands.literal("events")
|
||||
.executes(context -> {
|
||||
String info = eventsStatus.apply(context.getSource());
|
||||
context.getSource().sendSuccess(() -> Component.literal(info), false);
|
||||
return 1;
|
||||
}))
|
||||
.then(Commands.literal("volcanoes")
|
||||
.executes(context -> {
|
||||
String info = volcanoStatus.get();
|
||||
context.getSource().sendSuccess(() -> Component.literal(info), false);
|
||||
return 1;
|
||||
})));
|
||||
}
|
||||
|
||||
private static int toggleHud(CommandSourceStack source, Function<UUID, Boolean> hudToggle) {
|
||||
ServerPlayer player;
|
||||
try {
|
||||
player = source.getPlayerOrException();
|
||||
} catch (CommandSyntaxException e) {
|
||||
source.sendFailure(Component.literal("/lw hud can only be used by a player"));
|
||||
return 0;
|
||||
}
|
||||
boolean nowEnabled = hudToggle.apply(player.getUUID());
|
||||
source.sendSuccess(
|
||||
() -> Component.literal("Region HUD " + (nowEnabled ? "enabled" : "disabled")),
|
||||
false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static <T> T requireService(Supplier<T> supplier, String name) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import com.livingworld.core.LivingWorldConstants;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.particles.DustParticleOptions;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
/**
|
||||
* Implements {@code /lw region borders [regionX regionZ]}.
|
||||
*
|
||||
* <p>Draws temporary cyan dust-particle lines along all four edges of the
|
||||
* specified region (or the player's current region if no coordinates are given).
|
||||
* Each edge is sampled at the world surface so borders follow terrain.</p>
|
||||
*/
|
||||
public final class RegionBordersCommand {
|
||||
|
||||
private static final int REGION_BLOCKS = LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16;
|
||||
private static final int EDGE_SAMPLES = 32; // particles per edge (~4 blocks apart on 128-block edge)
|
||||
|
||||
// Cyan-teal colour for region borders.
|
||||
private static final DustParticleOptions BORDER_PARTICLE =
|
||||
new DustParticleOptions(new Vector3f(0.0f, 0.95f, 0.85f), 1.5f);
|
||||
|
||||
private RegionBordersCommand() {}
|
||||
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> build() {
|
||||
return Commands.literal("borders")
|
||||
.executes(ctx -> execute(ctx.getSource(), null, null))
|
||||
.then(Commands.argument("regionX", IntegerArgumentType.integer())
|
||||
.then(Commands.argument("regionZ", IntegerArgumentType.integer())
|
||||
.executes(ctx -> execute(
|
||||
ctx.getSource(),
|
||||
IntegerArgumentType.getInteger(ctx, "regionX"),
|
||||
IntegerArgumentType.getInteger(ctx, "regionZ")))));
|
||||
}
|
||||
|
||||
private static int execute(CommandSourceStack source, Integer regionX, Integer regionZ) {
|
||||
ServerPlayer player;
|
||||
try {
|
||||
player = source.getPlayerOrException();
|
||||
} catch (CommandSyntaxException e) {
|
||||
source.sendFailure(Component.literal("/lw region borders requires a player"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int rx = regionX != null ? regionX
|
||||
: (int) Math.floor(player.getX() / REGION_BLOCKS);
|
||||
int rz = regionZ != null ? regionZ
|
||||
: (int) Math.floor(player.getZ() / REGION_BLOCKS);
|
||||
|
||||
ServerLevel level = player.serverLevel();
|
||||
int baseX = rx * REGION_BLOCKS;
|
||||
int baseZ = rz * REGION_BLOCKS;
|
||||
|
||||
for (int i = 0; i <= EDGE_SAMPLES; i++) {
|
||||
int offset = (int) ((double) i / EDGE_SAMPLES * REGION_BLOCKS);
|
||||
// North edge: z = baseZ, vary x
|
||||
spawnEdgeParticles(level, baseX + offset, baseZ);
|
||||
// South edge: z = baseZ + REGION_BLOCKS, vary x
|
||||
spawnEdgeParticles(level, baseX + offset, baseZ + REGION_BLOCKS);
|
||||
// West edge: x = baseX, vary z
|
||||
spawnEdgeParticles(level, baseX, baseZ + offset);
|
||||
// East edge: x = baseX + REGION_BLOCKS, vary z
|
||||
spawnEdgeParticles(level, baseX + REGION_BLOCKS, baseZ + offset);
|
||||
}
|
||||
|
||||
source.sendSuccess(() -> Component.literal(String.format(
|
||||
"[LW] Region (%d,%d) borders — blocks X[%d..%d] Z[%d..%d]",
|
||||
rx, rz, baseX, baseX + REGION_BLOCKS - 1, baseZ, baseZ + REGION_BLOCKS - 1)),
|
||||
false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void spawnEdgeParticles(ServerLevel level, int x, int z) {
|
||||
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z) + 1;
|
||||
// Use count=3 so the vertical column is visible even in high terrain.
|
||||
level.sendParticles(BORDER_PARTICLE,
|
||||
x + 0.5, y + 0.5, z + 0.5,
|
||||
3, // count
|
||||
0.0, 0.8, 0.0, // spread x/y/z
|
||||
0.0); // speed (0 = stationary)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,55 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionManager;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionManager;
|
||||
|
||||
/**
|
||||
* Prints the Living World region state at the command source position.
|
||||
* Prints the Living World region state — either at the caller's position or
|
||||
* at explicit block coordinates.
|
||||
*/
|
||||
public final class RegionInfoCommand {
|
||||
|
||||
private RegionInfoCommand() {
|
||||
}
|
||||
|
||||
public static int execute(
|
||||
/** Uses the command-source position (player standing location). */
|
||||
public static int executeAtSelf(
|
||||
CommandSourceStack source,
|
||||
RegionManager regionManager) {
|
||||
if (source == null) {
|
||||
throw new IllegalArgumentException("source must not be null");
|
||||
}
|
||||
if (regionManager == null) {
|
||||
throw new IllegalArgumentException("regionManager must not be null");
|
||||
}
|
||||
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
|
||||
|
||||
Vec3 position = source.getPosition();
|
||||
String dimensionId = source.getLevel().dimension().location().toString();
|
||||
Region region = regionManager.getOrCreateRegionAtBlock(
|
||||
dimensionId,
|
||||
floorToBlock(position.x),
|
||||
floorToBlock(position.z));
|
||||
(int) Math.floor(position.x),
|
||||
(int) Math.floor(position.z));
|
||||
|
||||
return sendLines(source, region);
|
||||
}
|
||||
|
||||
/** Uses explicit block X/Z coordinates in the caller's current dimension. */
|
||||
public static int executeAt(
|
||||
CommandSourceStack source,
|
||||
RegionManager regionManager,
|
||||
int blockX,
|
||||
int blockZ) {
|
||||
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
|
||||
|
||||
String dimensionId = source.getLevel().dimension().location().toString();
|
||||
Region region = regionManager.getOrCreateRegionAtBlock(dimensionId, blockX, blockZ);
|
||||
return sendLines(source, region);
|
||||
}
|
||||
|
||||
private static int sendLines(CommandSourceStack source, Region region) {
|
||||
for (String line : RegionInfoFormatter.format(region)) {
|
||||
source.sendSuccess(() -> Component.literal(line), false);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int floorToBlock(double coordinate) {
|
||||
return (int) Math.floor(coordinate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||
import com.livingworld.modules.pollution.PollutionModule;
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryModule;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||
import com.livingworld.modules.resources.ResourceRegionData;
|
||||
import com.livingworld.modules.soil.SoilModule;
|
||||
import com.livingworld.modules.soil.SoilRegionData;
|
||||
import com.livingworld.modules.vegetation.VegetationModule;
|
||||
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||
import com.livingworld.modules.water.WaterModule;
|
||||
import com.livingworld.modules.water.WaterRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionFlags;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import com.livingworld.regions.RegionModuleData;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Formats region diagnostics without depending on Minecraft classes.
|
||||
@@ -21,26 +34,84 @@ public final class RegionInfoFormatter {
|
||||
throw new IllegalArgumentException("region must not be null");
|
||||
}
|
||||
|
||||
RegionMetrics metrics = region.getMetrics();
|
||||
RegionFlags flags = region.getFlags();
|
||||
Set<String> moduleIds = new TreeSet<>(region.getModuleData().moduleIds());
|
||||
RegionMetrics m = region.getMetrics();
|
||||
RegionFlags f = region.getFlags();
|
||||
RegionModuleData data = region.getModuleData();
|
||||
|
||||
return List.of(
|
||||
"Region: " + region.getCoordinate().stableId(),
|
||||
"Lifecycle: " + region.getLifecycleState() + ", dirty=" + region.isDirty(),
|
||||
"Metrics: ecosystemHealth=" + metrics.getEcosystemHealth()
|
||||
+ ", pollution=" + metrics.getPollutionScore()
|
||||
+ ", soilQuality=" + metrics.getSoilQuality()
|
||||
+ ", waterQuality=" + metrics.getWaterQuality()
|
||||
+ ", vegetationPressure=" + metrics.getVegetationPressure()
|
||||
+ ", resourceDepletion=" + metrics.getResourceDepletion()
|
||||
+ ", recoveryPressure=" + metrics.getRecoveryPressure(),
|
||||
"Flags: playerActivity=" + flags.isHasPlayerActivity()
|
||||
+ ", highPollution=" + flags.isHasHighPollution()
|
||||
+ ", lowSoilQuality=" + flags.isHasLowSoilQuality()
|
||||
+ ", activeEcosystemEvent=" + flags.isHasActiveEcosystemEvent()
|
||||
+ ", forceLoaded=" + flags.isForceLoadedBySimulation()
|
||||
+ ", corrupted=" + flags.isCorrupted(),
|
||||
"Module data: " + (moduleIds.isEmpty() ? "none" : String.join(", ", moduleIds)));
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("Region: " + region.getCoordinate().stableId()
|
||||
+ " lifecycle=" + region.getLifecycleState()
|
||||
+ " dirty=" + region.isDirty()
|
||||
+ " tick=" + region.getLastUpdatedSimulationTick());
|
||||
lines.add("Metrics:"
|
||||
+ " health=" + fmt(m.getEcosystemHealth())
|
||||
+ " poll=" + fmt(m.getPollutionScore())
|
||||
+ " soil=" + fmt(m.getSoilQuality())
|
||||
+ " water=" + fmt(m.getWaterQuality())
|
||||
+ " veg=" + fmt(m.getVegetationPressure())
|
||||
+ " res=" + fmt(m.getResourceDepletion())
|
||||
+ " recov=" + fmt(m.getRecoveryPressure()));
|
||||
lines.add("Flags:"
|
||||
+ " playerActivity=" + f.isHasPlayerActivity()
|
||||
+ " highPollution=" + f.isHasHighPollution()
|
||||
+ " lowSoil=" + f.isHasLowSoilQuality()
|
||||
+ " ecoEvent=" + f.isHasActiveEcosystemEvent()
|
||||
+ " forceLoaded=" + f.isForceLoadedBySimulation());
|
||||
|
||||
lines.add("--- Module Data ---");
|
||||
data.get(PollutionModule.MODULE_ID, PollutionRegionData.class).ifPresentOrElse(
|
||||
d -> lines.add(" pollution: air=" + fmt(d.getAirPollution())
|
||||
+ " ground=" + fmt(d.getGroundPollution())
|
||||
+ " water=" + fmt(d.getWaterPollution())
|
||||
+ " decay=" + fmt(d.getDecayResistance())),
|
||||
() -> lines.add(" pollution: (no data)"));
|
||||
|
||||
data.get(SoilModule.MODULE_ID, SoilRegionData.class).ifPresentOrElse(
|
||||
d -> lines.add(" soil: fertility=" + fmt(d.getFertility())
|
||||
+ " moisture=" + fmt(d.getMoisture())
|
||||
+ " contam=" + fmt(d.getContamination())
|
||||
+ " compact=" + fmt(d.getCompaction())
|
||||
+ " erosion=" + fmt(d.getErosion())),
|
||||
() -> lines.add(" soil: (no data)"));
|
||||
|
||||
data.get(WaterModule.MODULE_ID, WaterRegionData.class).ifPresentOrElse(
|
||||
d -> lines.add(" water: avail=" + fmt(d.getWaterAvailability())
|
||||
+ " purif=" + fmt(d.getPurificationCapacity())
|
||||
+ " drought=" + fmt(d.getDroughtRisk())
|
||||
+ " flood=" + fmt(d.getFloodRisk())),
|
||||
() -> lines.add(" water: (no data)"));
|
||||
|
||||
data.get(VegetationModule.MODULE_ID, VegetationRegionData.class).ifPresentOrElse(
|
||||
d -> lines.add(" vegetation: grass=" + fmt(d.getGrassPressure())
|
||||
+ " flower=" + fmt(d.getFlowerPressure())
|
||||
+ " shrub=" + fmt(d.getShrubPressure())
|
||||
+ " tree=" + fmt(d.getTreePressure())
|
||||
+ " dead=" + fmt(d.getDeadVegetation())),
|
||||
() -> lines.add(" vegetation: (no data)"));
|
||||
|
||||
data.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).ifPresentOrElse(
|
||||
d -> lines.add(" resources: mining=" + fmt(d.getMiningDepletion())
|
||||
+ " logging=" + fmt(d.getLoggingDepletion())
|
||||
+ " farming=" + fmt(d.getFarmingDepletion())),
|
||||
() -> lines.add(" resources: (no data)"));
|
||||
|
||||
data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).ifPresentOrElse(
|
||||
d -> lines.add(" recovery: stage=" + d.getSuccessionStage()
|
||||
+ " progress=" + fmt(d.getRecoveryProgress())
|
||||
+ " damage=" + fmt(d.getDamageAccumulation())),
|
||||
() -> lines.add(" recovery: (no data)"));
|
||||
|
||||
data.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class).ifPresentOrElse(
|
||||
d -> lines.add(" ecosystem: health=" + fmt(d.getEcosystemHealth())
|
||||
+ " stress=" + fmt(d.getStress())
|
||||
+ " resilience=" + fmt(d.getResilience())
|
||||
+ " rate=" + fmt(d.getRecoveryRate())),
|
||||
() -> lines.add(" ecosystem: (no data)"));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static String fmt(double v) {
|
||||
return String.format("%.1f", v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import com.livingworld.core.LivingWorldConstants;
|
||||
import com.livingworld.modules.atmosphere.AtmosphereModule;
|
||||
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
|
||||
import com.livingworld.modules.pollution.PollutionModule;
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryModule;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import com.livingworld.modules.soil.SoilModule;
|
||||
import com.livingworld.modules.soil.SoilRegionData;
|
||||
import com.livingworld.modules.water.WaterModule;
|
||||
import com.livingworld.modules.water.WaterRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionManager;
|
||||
import com.mojang.brigadier.arguments.DoubleArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.SuggestionProvider;
|
||||
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.commands.SharedSuggestionProvider;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
/**
|
||||
* Debug command branch: {@code /lw set <property> <value>}.
|
||||
*
|
||||
* <p>Directly mutates simulation data in the player's current region so that
|
||||
* ecosystem transformation systems (succession, water runoff, pool formation)
|
||||
* can be tested without waiting for natural accumulation.
|
||||
*
|
||||
* <h3>Sub-commands</h3>
|
||||
* <pre>
|
||||
* /lw set soil fertility <0-100>
|
||||
* /lw set soil moisture <0-100>
|
||||
* /lw set water availability <0-100>
|
||||
* /lw set water drought <0-100>
|
||||
* /lw set pollution <0-100> (sets all three pollution layers proportionally)
|
||||
* /lw set stage <STAGE_NAME> (forces succession stage; resets progress)
|
||||
* /lw set cap <STAGE_NAME> (forces succession ceiling)
|
||||
* /lw set rain <0.0-1.0> (overrides regional rain level for one sim cycle)
|
||||
* </pre>
|
||||
*/
|
||||
public final class RegionSetCommand {
|
||||
|
||||
private static final SuggestionProvider<CommandSourceStack> STAGE_SUGGESTIONS =
|
||||
(ctx, builder) -> SharedSuggestionProvider.suggest(
|
||||
Arrays.stream(SuccessionStage.values()).map(Enum::name), builder);
|
||||
|
||||
private RegionSetCommand() {}
|
||||
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> build(Supplier<RegionManager> rm) {
|
||||
return Commands.literal("set")
|
||||
// --- soil ---
|
||||
.then(Commands.literal("soil")
|
||||
.then(Commands.literal("fertility")
|
||||
.then(Commands.argument("value", DoubleArgumentType.doubleArg(0, 100))
|
||||
.executes(ctx -> setSoilFertility(ctx.getSource(), rm.get(),
|
||||
DoubleArgumentType.getDouble(ctx, "value")))))
|
||||
.then(Commands.literal("moisture")
|
||||
.then(Commands.argument("value", DoubleArgumentType.doubleArg(0, 100))
|
||||
.executes(ctx -> setSoilMoisture(ctx.getSource(), rm.get(),
|
||||
DoubleArgumentType.getDouble(ctx, "value"))))))
|
||||
// --- water ---
|
||||
.then(Commands.literal("water")
|
||||
.then(Commands.literal("availability")
|
||||
.then(Commands.argument("value", DoubleArgumentType.doubleArg(0, 100))
|
||||
.executes(ctx -> setWaterAvailability(ctx.getSource(), rm.get(),
|
||||
DoubleArgumentType.getDouble(ctx, "value")))))
|
||||
.then(Commands.literal("drought")
|
||||
.then(Commands.argument("value", DoubleArgumentType.doubleArg(0, 100))
|
||||
.executes(ctx -> setWaterDrought(ctx.getSource(), rm.get(),
|
||||
DoubleArgumentType.getDouble(ctx, "value"))))))
|
||||
// --- pollution ---
|
||||
.then(Commands.literal("pollution")
|
||||
.then(Commands.argument("value", DoubleArgumentType.doubleArg(0, 100))
|
||||
.executes(ctx -> setPollution(ctx.getSource(), rm.get(),
|
||||
DoubleArgumentType.getDouble(ctx, "value")))))
|
||||
// --- succession stage ---
|
||||
.then(Commands.literal("stage")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.suggests(STAGE_SUGGESTIONS)
|
||||
.executes(ctx -> setStage(ctx.getSource(), rm.get(),
|
||||
StringArgumentType.getString(ctx, "name")))))
|
||||
// --- succession cap ---
|
||||
.then(Commands.literal("cap")
|
||||
.then(Commands.argument("name", StringArgumentType.word())
|
||||
.suggests(STAGE_SUGGESTIONS)
|
||||
.executes(ctx -> setCap(ctx.getSource(), rm.get(),
|
||||
StringArgumentType.getString(ctx, "name")))))
|
||||
// --- regional rain level ---
|
||||
.then(Commands.literal("rain")
|
||||
.then(Commands.argument("value", DoubleArgumentType.doubleArg(0, 1))
|
||||
.executes(ctx -> setRain(ctx.getSource(), rm.get(),
|
||||
DoubleArgumentType.getDouble(ctx, "value")))));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Executors
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static int setSoilFertility(CommandSourceStack src, RegionManager rm, double value) {
|
||||
return modifyRegion(src, rm, "soil.fertility=" + value, region -> {
|
||||
SoilRegionData soil = getOrDefault(region, SoilModule.MODULE_ID, SoilRegionData.class,
|
||||
SoilRegionData.defaults());
|
||||
soil.setFertility(value);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
});
|
||||
}
|
||||
|
||||
private static int setSoilMoisture(CommandSourceStack src, RegionManager rm, double value) {
|
||||
return modifyRegion(src, rm, "soil.moisture=" + value, region -> {
|
||||
SoilRegionData soil = getOrDefault(region, SoilModule.MODULE_ID, SoilRegionData.class,
|
||||
SoilRegionData.defaults());
|
||||
soil.setMoisture(value);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
});
|
||||
}
|
||||
|
||||
private static int setWaterAvailability(CommandSourceStack src, RegionManager rm, double value) {
|
||||
return modifyRegion(src, rm, "water.availability=" + value, region -> {
|
||||
WaterRegionData water = getOrDefault(region, WaterModule.MODULE_ID, WaterRegionData.class,
|
||||
WaterRegionData.defaults());
|
||||
water.setWaterAvailability(value);
|
||||
region.getModuleData().put(WaterModule.MODULE_ID, water);
|
||||
});
|
||||
}
|
||||
|
||||
private static int setWaterDrought(CommandSourceStack src, RegionManager rm, double value) {
|
||||
return modifyRegion(src, rm, "water.drought=" + value, region -> {
|
||||
WaterRegionData water = getOrDefault(region, WaterModule.MODULE_ID, WaterRegionData.class,
|
||||
WaterRegionData.defaults());
|
||||
water.setDroughtRisk(value);
|
||||
region.getModuleData().put(WaterModule.MODULE_ID, water);
|
||||
});
|
||||
}
|
||||
|
||||
private static int setPollution(CommandSourceStack src, RegionManager rm, double value) {
|
||||
return modifyRegion(src, rm, "pollution=" + value, region -> {
|
||||
PollutionRegionData existing = getOrDefault(region, PollutionModule.MODULE_ID,
|
||||
PollutionRegionData.class, PollutionRegionData.defaults());
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID,
|
||||
new PollutionRegionData(value, value * 0.5, value * 0.3,
|
||||
existing.getDecayResistance()));
|
||||
});
|
||||
}
|
||||
|
||||
private static int setStage(CommandSourceStack src, RegionManager rm, String stageName) {
|
||||
SuccessionStage stage = parseStage(src, stageName);
|
||||
if (stage == null) return 0;
|
||||
return modifyRegion(src, rm, "stage=" + stage.name(), region -> {
|
||||
RecoveryRegionData rec = getOrDefault(region, RecoveryModule.MODULE_ID,
|
||||
RecoveryRegionData.class, RecoveryRegionData.defaults());
|
||||
// Construct a fresh data object at the desired stage, preserving cap.
|
||||
SuccessionStage cap = rec.getMaxSuccessionStage();
|
||||
if (stage.ordinal() > cap.ordinal()) {
|
||||
// Auto-raise cap if forcing to a higher stage.
|
||||
cap = stage;
|
||||
}
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID,
|
||||
new RecoveryRegionData(stage, 0.0, 0.0, cap));
|
||||
});
|
||||
}
|
||||
|
||||
private static int setCap(CommandSourceStack src, RegionManager rm, String stageName) {
|
||||
SuccessionStage stage = parseStage(src, stageName);
|
||||
if (stage == null) return 0;
|
||||
return modifyRegion(src, rm, "cap=" + stage.name(), region -> {
|
||||
RecoveryRegionData rec = getOrDefault(region, RecoveryModule.MODULE_ID,
|
||||
RecoveryRegionData.class, RecoveryRegionData.defaults());
|
||||
rec.setMaxSuccessionStage(stage);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, rec);
|
||||
});
|
||||
}
|
||||
|
||||
private static int setRain(CommandSourceStack src, RegionManager rm, double value) {
|
||||
return modifyRegion(src, rm, "rain=" + value, region -> {
|
||||
AtmosphereRegionData atm = getOrDefault(region, AtmosphereModule.MODULE_ID,
|
||||
AtmosphereRegionData.class, AtmosphereRegionData.defaults());
|
||||
atm.setRainLevel(value);
|
||||
region.getModuleData().put(AtmosphereModule.MODULE_ID, atm);
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Resolves the player's region, applies {@code mutation}, marks dirty, replies. */
|
||||
private static int modifyRegion(CommandSourceStack src, RegionManager rm,
|
||||
String change, RegionMutation mutation) {
|
||||
if (rm == null) {
|
||||
src.sendFailure(Component.literal("Region manager not ready."));
|
||||
return 0;
|
||||
}
|
||||
ServerPlayer player;
|
||||
try {
|
||||
player = src.getPlayerOrException();
|
||||
} catch (CommandSyntaxException e) {
|
||||
src.sendFailure(Component.literal("/lw set must be run by a player."));
|
||||
return 0;
|
||||
}
|
||||
|
||||
String dimId = player.level().dimension().location().toString();
|
||||
int rx = (int) Math.floor(player.getX() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
|
||||
int rz = (int) Math.floor(player.getZ() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
|
||||
RegionCoordinate coord = new RegionCoordinate(dimId, rx, rz);
|
||||
|
||||
Region region = rm.getOrCreateRegion(coord);
|
||||
mutation.apply(region);
|
||||
rm.markDirty(region);
|
||||
|
||||
src.sendSuccess(() -> Component.literal(
|
||||
String.format("[LW] Set %s in region (%d,%d)", change, rx, rz)), false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static <T> T getOrDefault(Region region, String moduleId, Class<T> type, T fallback) {
|
||||
return region.getModuleData().get(moduleId, type).orElse(fallback);
|
||||
}
|
||||
|
||||
private static SuccessionStage parseStage(CommandSourceStack src, String name) {
|
||||
try {
|
||||
return SuccessionStage.valueOf(name.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
src.sendFailure(Component.literal(
|
||||
"Unknown stage '" + name + "'. Valid: BARREN, SPARSE_GRASS, GRASSLAND,"
|
||||
+ " SCRUBLAND, YOUNG_WOODLAND, MATURE_FOREST"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface RegionMutation {
|
||||
void apply(Region region);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import com.livingworld.core.simulation.SimulationManager;
|
||||
import com.livingworld.debug.SimulationProfileSnapshot;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
/**
|
||||
* Shows the profiler snapshot from the last simulation cycle.
|
||||
*/
|
||||
public final class StatsCommand {
|
||||
|
||||
private StatsCommand() {
|
||||
}
|
||||
|
||||
public static int execute(CommandSourceStack source, SimulationManager simulationManager) {
|
||||
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||
if (simulationManager == null) throw new IllegalArgumentException("simulationManager must not be null");
|
||||
|
||||
SimulationProfileSnapshot snap = simulationManager.createProfileSnapshot();
|
||||
|
||||
String cycleMs = String.format("%.2f", snap.totalCycleNanos() / 1_000_000.0);
|
||||
String header = "LW stats:"
|
||||
+ " cycle=" + cycleMs + "ms"
|
||||
+ " events=" + snap.eventsPublished()
|
||||
+ " regions=" + snap.regionsUpdated()
|
||||
+ " saves=" + snap.savesPerformed()
|
||||
+ " budget_overrun=" + snap.budgetExceeded()
|
||||
+ " sim_tick=" + simulationManager.getSimulationTickCounter();
|
||||
source.sendSuccess(() -> Component.literal(header), false);
|
||||
|
||||
if (!snap.moduleTimings().isEmpty()) {
|
||||
StringJoiner timings = new StringJoiner(" ");
|
||||
snap.moduleTimings().forEach((id, nanos) ->
|
||||
timings.add(id + "=" + String.format("%.2f", nanos / 1_000_000.0) + "ms"));
|
||||
String moduleLine = "Modules: " + timings;
|
||||
source.sendSuccess(() -> Component.literal(moduleLine), false);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.livingworld.config;
|
||||
|
||||
/**
|
||||
* Tuning constants for the Living World ecosystem simulation.
|
||||
*
|
||||
* <p>Populated from {@code config/livingworld-server.toml} at server startup via
|
||||
* {@link com.livingworld.platform.neoforge.NeoForgeModConfig}. Modules access this
|
||||
* via {@link com.livingworld.core.services.CoreServices#TUNING} from the service
|
||||
* registry during {@code initialize()}.
|
||||
*
|
||||
* <p>Default values match the hardcoded constants they replaced, so a missing
|
||||
* config file produces identical simulation behaviour to earlier versions.
|
||||
*/
|
||||
public final class EcosystemTuning {
|
||||
|
||||
// -- Pollution --
|
||||
private double pollutionDecayRate = 0.002;
|
||||
private double groundToWaterLeach = 0.005;
|
||||
private double pollutionSpreadRate = 0.02;
|
||||
private double windBoost = 0.5;
|
||||
|
||||
// -- Vegetation growth (per soil-quality unit above threshold per tick) --
|
||||
private double grassGrowthRate = 0.06;
|
||||
private double flowerGrowthRate = 0.03;
|
||||
private double shrubGrowthRate = 0.02;
|
||||
private double treeGrowthRate = 0.008;
|
||||
|
||||
// -- Vegetation die-off (flat reduction per tick under bad conditions) --
|
||||
private double grassDieoffRate = 1.00;
|
||||
private double flowerDieoffRate = 0.50;
|
||||
private double shrubDieoffRate = 0.25;
|
||||
private double treeDieoffRate = 0.10;
|
||||
|
||||
// -- Ecological succession --
|
||||
private double baseProgressPerTick = 2.0;
|
||||
private double damagePerBadTick = 5.0;
|
||||
private double damageDecayRate = 0.03;
|
||||
|
||||
// -- Seed dispersal (new ecological corridors feature) --
|
||||
/** Fraction of vegetation pressure emitted as seeds to each neighbour per tick. */
|
||||
private double seedEmissionRate = 0.01;
|
||||
/** Seed multiplier when the target region has 2+ healthy neighbours (corridor effect). */
|
||||
private double corridorBoostMultiplier = 3.5;
|
||||
/** Seeds blocked per unit of pollution score in the target region. */
|
||||
private double seedPollutionBlock = 0.015;
|
||||
private int seaLevel = 62;
|
||||
|
||||
public EcosystemTuning() {}
|
||||
|
||||
// -- Pollution getters/setters --
|
||||
public double getPollutionDecayRate() { return pollutionDecayRate; }
|
||||
public double getGroundToWaterLeach() { return groundToWaterLeach; }
|
||||
public double getPollutionSpreadRate() { return pollutionSpreadRate; }
|
||||
public double getWindBoost() { return windBoost; }
|
||||
public void setPollutionDecayRate(double v) { pollutionDecayRate = v; }
|
||||
public void setGroundToWaterLeach(double v) { groundToWaterLeach = v; }
|
||||
public void setPollutionSpreadRate(double v) { pollutionSpreadRate = v; }
|
||||
public void setWindBoost(double v) { windBoost = v; }
|
||||
|
||||
// -- Vegetation growth getters/setters --
|
||||
public double getGrassGrowthRate() { return grassGrowthRate; }
|
||||
public double getFlowerGrowthRate() { return flowerGrowthRate; }
|
||||
public double getShrubGrowthRate() { return shrubGrowthRate; }
|
||||
public double getTreeGrowthRate() { return treeGrowthRate; }
|
||||
public void setGrassGrowthRate(double v) { grassGrowthRate = v; }
|
||||
public void setFlowerGrowthRate(double v) { flowerGrowthRate = v; }
|
||||
public void setShrubGrowthRate(double v) { shrubGrowthRate = v; }
|
||||
public void setTreeGrowthRate(double v) { treeGrowthRate = v; }
|
||||
|
||||
// -- Vegetation die-off getters/setters --
|
||||
public double getGrassDieoffRate() { return grassDieoffRate; }
|
||||
public double getFlowerDieoffRate() { return flowerDieoffRate; }
|
||||
public double getShrubDieoffRate() { return shrubDieoffRate; }
|
||||
public double getTreeDieoffRate() { return treeDieoffRate; }
|
||||
public void setGrassDieoffRate(double v) { grassDieoffRate = v; }
|
||||
public void setFlowerDieoffRate(double v) { flowerDieoffRate = v; }
|
||||
public void setShrubDieoffRate(double v) { shrubDieoffRate = v; }
|
||||
public void setTreeDieoffRate(double v) { treeDieoffRate = v; }
|
||||
|
||||
// -- Succession getters/setters --
|
||||
public double getBaseProgressPerTick() { return baseProgressPerTick; }
|
||||
public double getDamagePerBadTick() { return damagePerBadTick; }
|
||||
public double getDamageDecayRate() { return damageDecayRate; }
|
||||
public void setBaseProgressPerTick(double v) { baseProgressPerTick = v; }
|
||||
public void setDamagePerBadTick(double v) { damagePerBadTick = v; }
|
||||
public void setDamageDecayRate(double v) { damageDecayRate = v; }
|
||||
|
||||
// -- Seed dispersal getters/setters --
|
||||
public double getSeedEmissionRate() { return seedEmissionRate; }
|
||||
public double getCorridorBoostMultiplier() { return corridorBoostMultiplier; }
|
||||
public double getSeedPollutionBlock() { return seedPollutionBlock; }
|
||||
public void setSeedEmissionRate(double v) { seedEmissionRate = v; }
|
||||
public void setCorridorBoostMultiplier(double v) { corridorBoostMultiplier = v; }
|
||||
public void setSeedPollutionBlock(double v) { seedPollutionBlock = v; }
|
||||
|
||||
public int getSeaLevel() { return seaLevel; }
|
||||
public void setSeaLevel(int seaLevel) { this.seaLevel = Math.max(1, Math.min(320, seaLevel)); }
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public final class SimulationConfig {
|
||||
private int regionSizeChunks = 8;
|
||||
|
||||
/** Interval between simulation cycles, in game ticks (must be >= 1). */
|
||||
private int simulationIntervalTicks = 100;
|
||||
private int simulationIntervalTicks = 50;
|
||||
|
||||
/** Maximum number of regions processed per cycle (must be >= 1). */
|
||||
private int maxRegionsPerCycle = 50;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.livingworld.core.services;
|
||||
|
||||
import com.livingworld.config.EcosystemTuning;
|
||||
import com.livingworld.core.simulation.RegionManager;
|
||||
import com.livingworld.core.simulation.SimulationManager;
|
||||
import com.livingworld.events.LivingWorldEventBus;
|
||||
@@ -40,6 +41,9 @@ public final class CoreServices {
|
||||
/** ConfigService key — interface already exists. */
|
||||
public static final ServiceKey<ConfigService> CONFIG = new ServiceKey<>("config", ConfigService.class);
|
||||
|
||||
/** EcosystemTuning key — holds all tunable simulation constants (loaded from TOML config). */
|
||||
public static final ServiceKey<EcosystemTuning> TUNING = new ServiceKey<>("tuning", EcosystemTuning.class);
|
||||
|
||||
public static final ServiceKey<RegionManager> REGIONS =
|
||||
new ServiceKey<>("regions", RegionManager.class);
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.livingworld.core.services;
|
||||
|
||||
import com.livingworld.data.saved.SaveMetadata;
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.data.serialization.PropertiesPersistenceReader;
|
||||
import com.livingworld.data.serialization.PropertiesPersistenceWriter;
|
||||
import com.livingworld.debug.DiagnosticCategory;
|
||||
import com.livingworld.debug.LivingWorldLogger;
|
||||
import com.livingworld.regions.Region;
|
||||
@@ -18,12 +22,15 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/**
|
||||
* File-backed persistence for the Volume 1 region model.
|
||||
@@ -40,8 +47,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
private final String modVersion;
|
||||
private final Clock clock;
|
||||
private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
|
||||
private final List<ModuleCodecEntry> moduleCodecs = new ArrayList<>();
|
||||
private SaveMetadata metadata;
|
||||
|
||||
private record ModuleCodecEntry(
|
||||
String moduleId,
|
||||
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
|
||||
BiConsumer<PersistenceReader, RegionModuleData> decoder) {}
|
||||
|
||||
public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
|
||||
this(rootDirectory, modVersion, Clock.systemUTC());
|
||||
}
|
||||
@@ -136,6 +149,25 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
return dirtyRegions.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a codec that serialises one module's per-region data.
|
||||
*
|
||||
* <p>Must be called before any region is saved or loaded. Codecs are applied
|
||||
* in registration order during both encode and decode. Each codec writes to /
|
||||
* reads from keys prefixed with {@code "mod.<moduleId>."} so modules cannot
|
||||
* collide.</p>
|
||||
*
|
||||
* @param moduleId the unique module identifier (used as key namespace)
|
||||
* @param encoder writes module data from {@link RegionModuleData} to a writer
|
||||
* @param decoder reads module data from a reader and populates {@link RegionModuleData}
|
||||
*/
|
||||
public synchronized void registerModuleCodec(
|
||||
String moduleId,
|
||||
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
|
||||
BiConsumer<PersistenceReader, RegionModuleData> decoder) {
|
||||
moduleCodecs.add(new ModuleCodecEntry(moduleId, encoder, decoder));
|
||||
}
|
||||
|
||||
private void initializeStorage() {
|
||||
try {
|
||||
Files.createDirectories(regionsDirectory);
|
||||
@@ -242,6 +274,13 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
properties.setProperty(
|
||||
"metric.recoveryPressure",
|
||||
Double.toString(metrics.getRecoveryPressure()));
|
||||
|
||||
for (ModuleCodecEntry codec : moduleCodecs) {
|
||||
String prefix = "mod." + codec.moduleId() + ".";
|
||||
codec.encoder().accept(
|
||||
region.getModuleData(),
|
||||
new PropertiesPersistenceWriter(properties, prefix));
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
@@ -270,6 +309,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
|
||||
metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure"));
|
||||
|
||||
RegionModuleData moduleData = new RegionModuleData();
|
||||
for (ModuleCodecEntry codec : moduleCodecs) {
|
||||
String prefix = "mod." + codec.moduleId() + ".";
|
||||
codec.decoder().accept(
|
||||
new PropertiesPersistenceReader(properties, prefix),
|
||||
moduleData);
|
||||
}
|
||||
|
||||
Region region = new Region(
|
||||
UUID.fromString(required(properties, "id")),
|
||||
new RegionCoordinate(
|
||||
@@ -282,7 +329,7 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
false,
|
||||
flags,
|
||||
metrics,
|
||||
new RegionModuleData());
|
||||
moduleData);
|
||||
region.validate();
|
||||
return region;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.livingworld.core.simulation;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -30,6 +31,11 @@ public interface RegionManager {
|
||||
*/
|
||||
List<Region> resolveAll(List<RegionCoordinate> coordinates);
|
||||
|
||||
/**
|
||||
* Returns all currently active (cached/loaded) regions.
|
||||
*/
|
||||
Collection<Region> getActiveRegions();
|
||||
|
||||
/**
|
||||
* Marks a region as dirty, indicating unsaved changes.
|
||||
*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.livingworld.core.simulation;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -8,6 +9,8 @@ import com.livingworld.core.services.PersistenceService;
|
||||
import com.livingworld.core.services.TimeService;
|
||||
import com.livingworld.debug.DiagnosticCategory;
|
||||
import com.livingworld.debug.LivingWorldLogger;
|
||||
import com.livingworld.debug.SimulationProfileSnapshot;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.events.LivingWorldEventBus;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.ModuleRegistry;
|
||||
@@ -61,6 +64,11 @@ public final class SimulationManager {
|
||||
this.scheduler.onMinecraftTick();
|
||||
|
||||
if (this.scheduler.shouldRunSimulationCycle()) {
|
||||
long tick = this.timeService.getSimulationTick();
|
||||
for (Region r : this.regionManager.getActiveRegions()) {
|
||||
this.scheduler.queueRegion(new RegionUpdateJob(
|
||||
r.getCoordinate(), 0, tick, null, UpdateReason.NORMAL_ROLLING_UPDATE));
|
||||
}
|
||||
runSimulationCycle();
|
||||
}
|
||||
}
|
||||
@@ -192,6 +200,49 @@ public final class SimulationManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the full module pipeline to run on a single region immediately,
|
||||
* bypassing the scheduler. Intended for debug commands only.
|
||||
*/
|
||||
public void forceUpdateRegion(RegionCoordinate coordinate) {
|
||||
if (coordinate == null) {
|
||||
throw new IllegalArgumentException("coordinate must not be null");
|
||||
}
|
||||
Optional<Region> region = regionManager.resolve(coordinate);
|
||||
if (region.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long tick = getSimulationTickCounter();
|
||||
RegionUpdateJob job = new RegionUpdateJob(
|
||||
coordinate, 100, tick, null, UpdateReason.FORCED_DEBUG_COMMAND);
|
||||
if (this.profiler != null) {
|
||||
this.profiler.startCycle(tick);
|
||||
}
|
||||
try {
|
||||
runModulesForRegion(region.get(), job, tick);
|
||||
} finally {
|
||||
if (this.profiler != null) {
|
||||
this.profiler.endCycle(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Enqueues a region for a priority update, e.g. because a player entered it. */
|
||||
public void queueRegionForUpdate(RegionCoordinate coordinate, int priority, UpdateReason reason) {
|
||||
if (coordinate == null) throw new IllegalArgumentException("coordinate must not be null");
|
||||
if (reason == null) throw new IllegalArgumentException("reason must not be null");
|
||||
long tick = getSimulationTickCounter();
|
||||
this.scheduler.queueRegion(new RegionUpdateJob(coordinate, priority, tick, null, reason));
|
||||
}
|
||||
|
||||
/** Returns a profile snapshot of the last completed simulation cycle. */
|
||||
public SimulationProfileSnapshot createProfileSnapshot() {
|
||||
if (profiler instanceof com.livingworld.debug.SimulationProfiler concrete) {
|
||||
return concrete.createSnapshot();
|
||||
}
|
||||
return new SimulationProfileSnapshot(0L, java.util.Map.of(), 0, 0, 0, false);
|
||||
}
|
||||
|
||||
public long getMinecraftTickCounter() {
|
||||
return this.scheduler.getMinecraftTickCounter();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.livingworld.data.migration;
|
||||
|
||||
import com.livingworld.debug.DiagnosticCategory;
|
||||
import com.livingworld.debug.LivingWorldLogger;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* Detects the schema version of region save data and applies registered
|
||||
* {@link RegionMigration} steps in order until the data reaches the target version.
|
||||
*
|
||||
* <p>Usage:
|
||||
* <ol>
|
||||
* <li>Construct with the path to the migrations log file (or {@code null} to disable logging).
|
||||
* <li>Register one {@link RegionMigration} per version gap via {@link #register}.
|
||||
* <li>Call {@link #migrateIfNeeded} before decoding saved region properties.
|
||||
* </ol>
|
||||
*/
|
||||
public final class MigrationManager {
|
||||
|
||||
private final Map<Integer, RegionMigration> migrations = new TreeMap<>();
|
||||
private final Path logFile;
|
||||
|
||||
/**
|
||||
* @param logFile path to the append-only migrations log; {@code null} to disable file logging
|
||||
*/
|
||||
public MigrationManager(Path logFile) {
|
||||
this.logFile = logFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a migration step.
|
||||
*
|
||||
* @throws IllegalArgumentException if the migration is null, if its version span is not
|
||||
* exactly one, or if a migration for the same
|
||||
* {@code fromVersion} is already registered
|
||||
*/
|
||||
public void register(RegionMigration migration) {
|
||||
if (migration == null) {
|
||||
throw new IllegalArgumentException("migration must not be null");
|
||||
}
|
||||
int from = migration.fromVersion();
|
||||
int to = migration.toVersion();
|
||||
if (to != from + 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"Migration must advance exactly one version; got " + from + " → " + to);
|
||||
}
|
||||
if (migrations.containsKey(from)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Migration already registered for fromVersion " + from);
|
||||
}
|
||||
migrations.put(from, migration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of registered migration steps.
|
||||
*/
|
||||
public int getRegisteredMigrationCount() {
|
||||
return migrations.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when the data's {@code schemaVersion} already equals
|
||||
* {@code targetVersion} and no migration is needed.
|
||||
*/
|
||||
public boolean isUpToDate(Properties data, int targetVersion) {
|
||||
return readVersion(data) == targetVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the given save data to {@code targetVersion} if it is behind, and returns
|
||||
* the (possibly modified) properties. Returns the original object unchanged when already
|
||||
* at the target version.
|
||||
*
|
||||
* @throws IllegalStateException if the data is ahead of {@code targetVersion}, if
|
||||
* {@code schemaVersion} is missing or invalid, or if the
|
||||
* registered migrations do not cover the required range
|
||||
*/
|
||||
public Properties migrateIfNeeded(Properties data, int targetVersion) {
|
||||
int currentVersion = readVersion(data);
|
||||
if (currentVersion == targetVersion) {
|
||||
return data;
|
||||
}
|
||||
if (currentVersion > targetVersion) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot downgrade save data from schema version "
|
||||
+ currentVersion + " to " + targetVersion);
|
||||
}
|
||||
|
||||
List<RegionMigration> path = buildMigrationPath(currentVersion, targetVersion);
|
||||
|
||||
Properties result = copyProperties(data);
|
||||
for (RegionMigration step : path) {
|
||||
result = step.apply(result);
|
||||
result.setProperty("schemaVersion", Integer.toString(step.toVersion()));
|
||||
recordStep(step.fromVersion(), step.toVersion());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private List<RegionMigration> buildMigrationPath(int from, int to) {
|
||||
List<RegionMigration> path = new ArrayList<>();
|
||||
int version = from;
|
||||
while (version < to) {
|
||||
RegionMigration step = migrations.get(version);
|
||||
if (step == null) {
|
||||
throw new IllegalStateException(
|
||||
"No migration registered for schema version " + version
|
||||
+ " (target version: " + to + ")");
|
||||
}
|
||||
path.add(step);
|
||||
version++;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
private static int readVersion(Properties data) {
|
||||
if (data == null) {
|
||||
throw new IllegalArgumentException("data must not be null");
|
||||
}
|
||||
String raw = data.getProperty("schemaVersion");
|
||||
if (raw == null || raw.isBlank()) {
|
||||
throw new IllegalStateException("Save data is missing required key: schemaVersion");
|
||||
}
|
||||
int version;
|
||||
try {
|
||||
version = Integer.parseInt(raw.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalStateException("Invalid schemaVersion value: " + raw, e);
|
||||
}
|
||||
if (version <= 0) {
|
||||
throw new IllegalStateException("schemaVersion must be > 0, got: " + version);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
private static Properties copyProperties(Properties source) {
|
||||
Properties copy = new Properties();
|
||||
for (String key : source.stringPropertyNames()) {
|
||||
copy.setProperty(key, source.getProperty(key));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private void recordStep(int from, int to) {
|
||||
LivingWorldLogger.info(
|
||||
DiagnosticCategory.PERSISTENCE,
|
||||
"Applied region schema migration: " + from + " → " + to);
|
||||
if (logFile == null) {
|
||||
return;
|
||||
}
|
||||
String entry = Instant.now() + " Migrated region schema " + from + " → " + to
|
||||
+ System.lineSeparator();
|
||||
try {
|
||||
if (logFile.getParent() != null) {
|
||||
Files.createDirectories(logFile.getParent());
|
||||
}
|
||||
Files.writeString(
|
||||
logFile,
|
||||
entry,
|
||||
StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND);
|
||||
} catch (IOException e) {
|
||||
LivingWorldLogger.warn(
|
||||
DiagnosticCategory.PERSISTENCE,
|
||||
"Failed to write to migrations log: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.livingworld.data.migration;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* A single schema migration step for region save data.
|
||||
*
|
||||
* <p>Each migration advances exactly one schema version. Implementations must be
|
||||
* deterministic: given the same input {@link Properties}, they must always produce
|
||||
* the same output. The {@link MigrationManager} updates {@code schemaVersion} in the
|
||||
* result automatically; implementations must not set it themselves.
|
||||
*/
|
||||
public interface RegionMigration {
|
||||
|
||||
/** The schema version this migration reads from. */
|
||||
int fromVersion();
|
||||
|
||||
/**
|
||||
* The schema version this migration produces.
|
||||
*
|
||||
* <p>Must equal {@link #fromVersion()} + 1.
|
||||
*/
|
||||
int toVersion();
|
||||
|
||||
/**
|
||||
* Applies this migration to the given save data.
|
||||
*
|
||||
* @param data a copy of the raw region save properties at {@link #fromVersion()}
|
||||
* @return a new or mutated {@link Properties} containing the migrated data
|
||||
*/
|
||||
Properties apply(Properties data);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.livingworld.data.serialization;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* {@link PersistenceReader} backed by a {@link Properties} object.
|
||||
*
|
||||
* <p>All key lookups are namespaced by a caller-supplied prefix, mirroring
|
||||
* the convention used by {@link PropertiesPersistenceWriter}. Missing keys
|
||||
* return the supplied default value rather than throwing.</p>
|
||||
*/
|
||||
public final class PropertiesPersistenceReader implements PersistenceReader {
|
||||
|
||||
private final Properties props;
|
||||
private final String prefix;
|
||||
|
||||
public PropertiesPersistenceReader(Properties props, String prefix) {
|
||||
this.props = props;
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String readString(String key, String defaultValue) {
|
||||
String value = props.getProperty(prefix + key);
|
||||
return value != null ? value : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int readInt(String key, int defaultValue) {
|
||||
String value = props.getProperty(prefix + key);
|
||||
return value != null ? Integer.parseInt(value) : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long readLong(String key, long defaultValue) {
|
||||
String value = props.getProperty(prefix + key);
|
||||
return value != null ? Long.parseLong(value) : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double readDouble(String key, double defaultValue) {
|
||||
String value = props.getProperty(prefix + key);
|
||||
return value != null ? Double.parseDouble(value) : defaultValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean readBoolean(String key, boolean defaultValue) {
|
||||
String value = props.getProperty(prefix + key);
|
||||
return value != null ? Boolean.parseBoolean(value) : defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.livingworld.data.serialization;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* {@link PersistenceWriter} backed by a {@link Properties} object.
|
||||
*
|
||||
* <p>All keys are namespaced by a caller-supplied prefix so that multiple
|
||||
* modules can write to the same {@code Properties} without collision.
|
||||
* For example, a prefix of {@code "mod.pollution."} and a key of
|
||||
* {@code "airPollution"} stores the value under {@code "mod.pollution.airPollution"}.</p>
|
||||
*/
|
||||
public final class PropertiesPersistenceWriter implements PersistenceWriter {
|
||||
|
||||
private final Properties props;
|
||||
private final String prefix;
|
||||
|
||||
public PropertiesPersistenceWriter(Properties props, String prefix) {
|
||||
this.props = props;
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeString(String key, String value) {
|
||||
props.setProperty(prefix + key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeInt(String key, int value) {
|
||||
props.setProperty(prefix + key, Integer.toString(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeLong(String key, long value) {
|
||||
props.setProperty(prefix + key, Long.toString(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeDouble(String key, double value) {
|
||||
props.setProperty(prefix + key, Double.toString(value));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeBoolean(String key, boolean value) {
|
||||
props.setProperty(prefix + key, Boolean.toString(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.livingworld.modules.atmosphere;
|
||||
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.modules.pollution.PollutionModule;
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.soil.SoilModule;
|
||||
import com.livingworld.modules.soil.SoilRegionData;
|
||||
import com.livingworld.modules.vegetation.VegetationModule;
|
||||
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||
import com.livingworld.modules.water.WaterModule;
|
||||
import com.livingworld.modules.water.WaterRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.DoubleSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Drives per-region weather from ecosystem state and applies atmospheric feedback
|
||||
* to other simulation layers.
|
||||
*
|
||||
* <h3>Pipeline position</h3>
|
||||
* Runs last (after WorldEffects) so ecosystem health and pollution scores are final
|
||||
* before weather targets are computed.
|
||||
*
|
||||
* <h3>Per-cycle rules</h3>
|
||||
* <ol>
|
||||
* <li>Tree canopy scrubs air pollution: {@code airPollution -= treePressure × TREE_SCRUB_RATE}.
|
||||
* <li>Target rain level is derived from ecosystem health (healthy = wet, degraded = drought).
|
||||
* Global Minecraft rain adds a small bias so natural weather still matters.
|
||||
* <li>Target thunder level is derived from pollution (polluted = stormy).
|
||||
* <li>Rain and thunder smoothly interpolate toward their targets each cycle.
|
||||
* <li>Rain fills water availability; drought raises drought risk.
|
||||
* <li>High thunder + high pollution triggers acid rain (drains soil fertility).
|
||||
* </ol>
|
||||
*/
|
||||
public final class AtmosphereModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "atmosphere";
|
||||
|
||||
// Tree scrub: treePressure (0-100) × rate removed from air pollution per cycle.
|
||||
private static final double TREE_SCRUB_RATE = 0.003;
|
||||
|
||||
// Rain target ceiling when ecosystem is pristine (no global rain).
|
||||
private static final double MAX_RAIN_FROM_ECO = 0.85;
|
||||
// Additive bias when Minecraft's overworld is actually raining.
|
||||
private static final double GLOBAL_RAIN_BIAS = 0.15;
|
||||
|
||||
// Smoothing rates — weather changes slowly, not instantly.
|
||||
private static final double RAIN_SMOOTHING = 0.05;
|
||||
private static final double THUNDER_SMOOTHING = 0.03;
|
||||
|
||||
// Rain < this → drought conditions worsen each cycle.
|
||||
private static final double DROUGHT_THRESHOLD = 0.2;
|
||||
private static final double RAIN_MOISTURE_GAIN = 0.3;
|
||||
private static final double RAIN_DROUGHT_RELIEF = 0.15;
|
||||
private static final double DRY_DROUGHT_INCREASE = 0.05;
|
||||
|
||||
// Acid rain fires when BOTH thunder and pollution exceed these thresholds.
|
||||
private static final double ACID_THUNDER_THRESHOLD = 0.4;
|
||||
private static final double ACID_POLL_THRESHOLD = 20.0;
|
||||
private static final double ACID_FERTILITY_DRAIN = 0.0005;
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"Atmosphere",
|
||||
"1.0.0",
|
||||
"Per-region weather driven by ecosystem health and pollution. Trees scrub air pollution.",
|
||||
"9",
|
||||
List.of(),
|
||||
List.of(),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
private final BooleanSupplier globalRaining;
|
||||
private final Supplier<Season> currentSeason;
|
||||
private final DoubleSupplier globalWarmingPenalty;
|
||||
|
||||
public AtmosphereModule(BooleanSupplier globalRaining, Supplier<Season> currentSeason,
|
||||
DoubleSupplier globalWarmingPenalty) {
|
||||
this.globalRaining = globalRaining;
|
||||
this.currentSeason = currentSeason;
|
||||
this.globalWarmingPenalty = globalWarmingPenalty;
|
||||
}
|
||||
|
||||
@Override public String getModuleId() { return MODULE_ID; }
|
||||
@Override public ModuleMetadata getMetadata() { return METADATA; }
|
||||
@Override public void initialize(ModuleContext ctx) {}
|
||||
@Override public void onServerStarted(ServerContext ctx) {}
|
||||
@Override public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
@Override public void saveModuleData(PersistenceWriter w) {}
|
||||
@Override public void loadModuleData(PersistenceReader r) {}
|
||||
@Override public void shutdown() {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||
region.getModuleData().put(MODULE_ID, AtmosphereRegionData.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
Region region = context.getRegion();
|
||||
|
||||
AtmosphereRegionData data = region.getModuleData()
|
||||
.get(MODULE_ID, AtmosphereRegionData.class)
|
||||
.orElseGet(AtmosphereRegionData::defaults);
|
||||
|
||||
// 1. Vegetation scrubs air pollution.
|
||||
VegetationRegionData veg = region.getModuleData()
|
||||
.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
|
||||
.orElse(null);
|
||||
PollutionRegionData pollution = region.getModuleData()
|
||||
.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
|
||||
.orElse(null);
|
||||
|
||||
if (veg != null && pollution != null) {
|
||||
double scrub = veg.getTreePressure() * TREE_SCRUB_RATE;
|
||||
pollution.addPollution(-scrub, 0.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
// Recompute pollution score now that trees have scrubbed the air.
|
||||
double pollScore = pollution.getAirPollution() * 0.40
|
||||
+ pollution.getGroundPollution() * 0.35
|
||||
+ pollution.getWaterPollution() * 0.25;
|
||||
region.getMetrics().setPollutionScore(pollScore);
|
||||
}
|
||||
|
||||
// 2. Compute targets.
|
||||
double ecosystemHealth = region.getMetrics().getEcosystemHealth();
|
||||
double pollScore = region.getMetrics().getPollutionScore();
|
||||
boolean mcRaining = globalRaining.getAsBoolean();
|
||||
|
||||
double seasonMod = currentSeason.get().rainModifier();
|
||||
double targetRain = clamp01(ecosystemHealth / 100.0 * MAX_RAIN_FROM_ECO
|
||||
+ seasonMod
|
||||
- globalWarmingPenalty.getAsDouble()
|
||||
+ (mcRaining ? GLOBAL_RAIN_BIAS : 0.0));
|
||||
double targetThunder = clamp01(pollScore / 100.0 * 0.8);
|
||||
|
||||
// 3. Smooth toward targets.
|
||||
double rain = data.getRainLevel() + (targetRain - data.getRainLevel()) * RAIN_SMOOTHING;
|
||||
double thunder = data.getThunderLevel() + (targetThunder - data.getThunderLevel()) * THUNDER_SMOOTHING;
|
||||
data.setRainLevel(rain);
|
||||
data.setThunderLevel(thunder);
|
||||
|
||||
// 4. Apply rain effects to water availability and drought risk.
|
||||
WaterRegionData water = region.getModuleData()
|
||||
.get(WaterModule.MODULE_ID, WaterRegionData.class)
|
||||
.orElse(null);
|
||||
if (water != null) {
|
||||
water.setWaterAvailability(
|
||||
Math.min(100, water.getWaterAvailability() + rain * RAIN_MOISTURE_GAIN));
|
||||
if (rain < DROUGHT_THRESHOLD) {
|
||||
water.setDroughtRisk(
|
||||
Math.min(100, water.getDroughtRisk() + DRY_DROUGHT_INCREASE * (1.0 - rain)));
|
||||
} else {
|
||||
water.setDroughtRisk(
|
||||
Math.max(0, water.getDroughtRisk() - RAIN_DROUGHT_RELIEF * rain));
|
||||
}
|
||||
|
||||
// 5. Acid rain: stormy + polluted atmosphere corrodes soil and water.
|
||||
if (thunder > ACID_THUNDER_THRESHOLD && pollScore > ACID_POLL_THRESHOLD) {
|
||||
double acidStrength = thunder * (pollScore - ACID_POLL_THRESHOLD) * ACID_FERTILITY_DRAIN;
|
||||
SoilRegionData soil = region.getModuleData()
|
||||
.get(SoilModule.MODULE_ID, SoilRegionData.class)
|
||||
.orElse(null);
|
||||
if (soil != null) {
|
||||
soil.setFertility(Math.max(0, soil.getFertility() - acidStrength));
|
||||
soil.setContamination(Math.min(100, soil.getContamination() + acidStrength * 0.5));
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
}
|
||||
water.setWaterAvailability(
|
||||
Math.max(0, water.getWaterAvailability() - acidStrength * 0.3));
|
||||
}
|
||||
region.getModuleData().put(WaterModule.MODULE_ID, water);
|
||||
}
|
||||
|
||||
region.getModuleData().put(MODULE_ID, data);
|
||||
return ModuleUpdateResult.changed();
|
||||
}
|
||||
|
||||
private static double clamp01(double v) { return Math.min(1.0, Math.max(0.0, v)); }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.livingworld.modules.atmosphere;
|
||||
|
||||
/**
|
||||
* Per-region atmospheric state tracked by {@link AtmosphereModule}.
|
||||
*
|
||||
* <p>Both values smoothly interpolate toward ecosystem-derived targets each
|
||||
* simulation cycle and are used to drive per-player weather packets.
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>rainLevel</b> – 0.0 = drought, 1.0 = heavy rainfall
|
||||
* <li><b>thunderLevel</b> – 0.0 = calm, 1.0 = heavy storm (acid rain above 0.4)
|
||||
* </ul>
|
||||
*/
|
||||
public final class AtmosphereRegionData {
|
||||
|
||||
private double rainLevel;
|
||||
private double thunderLevel;
|
||||
|
||||
public AtmosphereRegionData(double rainLevel, double thunderLevel) {
|
||||
this.rainLevel = clamp(rainLevel);
|
||||
this.thunderLevel = clamp(thunderLevel);
|
||||
}
|
||||
|
||||
public static AtmosphereRegionData defaults() {
|
||||
return new AtmosphereRegionData(0.4, 0.0);
|
||||
}
|
||||
|
||||
public double getRainLevel() { return rainLevel; }
|
||||
public double getThunderLevel() { return thunderLevel; }
|
||||
|
||||
public void setRainLevel(double v) { this.rainLevel = clamp(v); }
|
||||
public void setThunderLevel(double v) { this.thunderLevel = clamp(v); }
|
||||
|
||||
public AtmosphereRegionData copy() {
|
||||
return new AtmosphereRegionData(rainLevel, thunderLevel);
|
||||
}
|
||||
|
||||
private static double clamp(double v) { return Math.min(1.0, Math.max(0.0, v)); }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "AtmosphereRegionData{rain=" + rainLevel + ", thunder=" + thunderLevel + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.livingworld.modules.atmosphere;
|
||||
|
||||
/**
|
||||
* The four ecological seasons, derived from absolute Minecraft day count.
|
||||
*
|
||||
* <p>One full year = 32 Minecraft days (8 days per season). Season drives the
|
||||
* rain target modifier in {@link AtmosphereModule} and seasonal soil effects in
|
||||
* the bootstrap post-sim pass.
|
||||
*/
|
||||
public enum Season {
|
||||
|
||||
// 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, double temperatureMod, double droughtMod, double vegGrowthMod) {
|
||||
this.rainModifier = rainModifier;
|
||||
this.temperatureMod = temperatureMod;
|
||||
this.droughtMod = droughtMod;
|
||||
this.vegGrowthMod = vegGrowthMod;
|
||||
}
|
||||
|
||||
public double rainModifier() { return rainModifier; }
|
||||
public double temperatureMod() { return temperatureMod; }
|
||||
public double droughtMod() { return droughtMod; }
|
||||
public double vegGrowthMod() { return vegGrowthMod; }
|
||||
|
||||
public String displayName() {
|
||||
String n = name();
|
||||
return n.charAt(0) + n.substring(1).toLowerCase();
|
||||
}
|
||||
|
||||
/** Returns the season for the given absolute Minecraft day number (gameTime / 24000). */
|
||||
public static Season fromAbsoluteDay(long absoluteDay) {
|
||||
return values()[(int) ((absoluteDay / SEASON_LENGTH_DAYS) % 4)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.livingworld.modules.ecosystem;
|
||||
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Integrates all ecosystem signals into a composite health score and manages
|
||||
* long-term stress and resilience dynamics.
|
||||
*
|
||||
* <p>This module runs <em>last</em> in the ecosystem update order, after
|
||||
* {@link com.livingworld.modules.pollution.PollutionModule},
|
||||
* {@link com.livingworld.modules.soil.SoilModule}, and
|
||||
* {@link com.livingworld.modules.vegetation.VegetationModule}, so it reads
|
||||
* fully updated current-tick values for all metrics.
|
||||
*
|
||||
* <h3>Ecosystem health formula</h3>
|
||||
* <pre>
|
||||
* health = soilQuality * 0.30
|
||||
* + waterQuality * 0.20
|
||||
* + (100 - pollutionScore) * 0.30
|
||||
* + vegetationPressure * 0.20
|
||||
* </pre>
|
||||
*
|
||||
* <h3>Stress model</h3>
|
||||
* Danger zones are defined for each metric. Each metric in its danger zone
|
||||
* contributes 2.0 stress per tick. Stress decays by 0.5 per tick when no
|
||||
* dangers are active.
|
||||
*
|
||||
* <h3>Resilience and recovery</h3>
|
||||
* Resilience is a slow-moving trend indicator. It increases when the ecosystem
|
||||
* is healthy and decreases under prolonged stress. Recovery rate is derived
|
||||
* from resilience and current stress level.
|
||||
*/
|
||||
public final class EcosystemModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "ecosystem";
|
||||
|
||||
// Weights for the composite health score (must sum to 1.0).
|
||||
private static final double WEIGHT_SOIL = 0.30;
|
||||
private static final double WEIGHT_WATER = 0.20;
|
||||
private static final double WEIGHT_POLLUTION = 0.30;
|
||||
private static final double WEIGHT_VEGETATION = 0.20;
|
||||
|
||||
// Danger zone thresholds (values outside these ranges add stress).
|
||||
private static final double DANGER_SOIL_LOW = 20.0;
|
||||
private static final double DANGER_POLLUTION_HIGH = 70.0;
|
||||
private static final double DANGER_VEGETATION_LOW = 10.0;
|
||||
private static final double DANGER_WATER_LOW = 20.0;
|
||||
|
||||
private static final double STRESS_PER_DANGER = 2.0;
|
||||
private static final double STRESS_DECAY_PER_TICK = 0.5;
|
||||
private static final double RESILIENCE_GROWTH_RATE = 0.02;
|
||||
private static final double RESILIENCE_DRAIN_RATE = 0.01;
|
||||
private static final double CHANGE_THRESHOLD = 0.01;
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"Ecosystem",
|
||||
"1.0.0",
|
||||
"Computes composite ecosystem health and manages long-term stress and resilience.",
|
||||
"1",
|
||||
List.of("pollution", "soil", "vegetation"),
|
||||
List.of(),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
@Override
|
||||
public String getModuleId() { return MODULE_ID; }
|
||||
|
||||
@Override
|
||||
public ModuleMetadata getMetadata() { return METADATA; }
|
||||
|
||||
@Override
|
||||
public void initialize(ModuleContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerStarted(ServerContext context) {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||
region.getModuleData().put(MODULE_ID, EcosystemRegionData.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
|
||||
Region region = context.getRegion();
|
||||
EcosystemRegionData data = region.getModuleData()
|
||||
.get(MODULE_ID, EcosystemRegionData.class)
|
||||
.orElseGet(EcosystemRegionData::defaults);
|
||||
RegionMetrics metrics = region.getMetrics();
|
||||
|
||||
double prevHealth = metrics.getEcosystemHealth();
|
||||
double prevRecoveryPressure = metrics.getRecoveryPressure();
|
||||
|
||||
// --- Composite health ---
|
||||
double health = metrics.getSoilQuality() * WEIGHT_SOIL
|
||||
+ metrics.getWaterQuality() * WEIGHT_WATER
|
||||
+ (100.0 - metrics.getPollutionScore()) * WEIGHT_POLLUTION
|
||||
+ metrics.getVegetationPressure() * WEIGHT_VEGETATION;
|
||||
health = Math.max(0.0, Math.min(100.0, health));
|
||||
|
||||
// --- Stress accumulation ---
|
||||
int dangerCount = 0;
|
||||
if (metrics.getSoilQuality() < DANGER_SOIL_LOW) dangerCount++;
|
||||
if (metrics.getPollutionScore() > DANGER_POLLUTION_HIGH) dangerCount++;
|
||||
if (metrics.getVegetationPressure() < DANGER_VEGETATION_LOW) dangerCount++;
|
||||
if (metrics.getWaterQuality() < DANGER_WATER_LOW) dangerCount++;
|
||||
|
||||
double newStress;
|
||||
if (dangerCount > 0) {
|
||||
newStress = data.getStress() + dangerCount * STRESS_PER_DANGER;
|
||||
} else {
|
||||
newStress = Math.max(0.0, data.getStress() - STRESS_DECAY_PER_TICK);
|
||||
}
|
||||
data.setStress(newStress);
|
||||
|
||||
// --- Resilience trend ---
|
||||
if (health > 60.0) {
|
||||
data.setResilience(data.getResilience() + RESILIENCE_GROWTH_RATE);
|
||||
} else {
|
||||
data.setResilience(data.getResilience() - RESILIENCE_DRAIN_RATE);
|
||||
}
|
||||
|
||||
// --- Recovery rate: high resilience and low stress produce fast recovery ---
|
||||
double recoveryRate = data.getResilience() * (1.0 - data.getStress() / 100.0) * 0.10 + 1.0;
|
||||
data.setRecoveryRate(Math.min(100.0, recoveryRate));
|
||||
|
||||
data.setEcosystemHealth(health);
|
||||
|
||||
// --- Summary metrics ---
|
||||
metrics.setEcosystemHealth(health);
|
||||
// Recovery pressure is high when the ecosystem is suffering and needs attention.
|
||||
double recoveryPressure = Math.max(0.0, (100.0 - health) + data.getStress() * 0.30);
|
||||
metrics.setRecoveryPressure(Math.min(100.0, recoveryPressure));
|
||||
|
||||
region.getModuleData().put(MODULE_ID, data);
|
||||
|
||||
boolean changed =
|
||||
Math.abs(metrics.getEcosystemHealth() - prevHealth) > CHANGE_THRESHOLD
|
||||
|| Math.abs(metrics.getRecoveryPressure() - prevRecoveryPressure) > CHANGE_THRESHOLD;
|
||||
|
||||
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
|
||||
@Override
|
||||
public void saveModuleData(PersistenceWriter writer) {}
|
||||
|
||||
@Override
|
||||
public void loadModuleData(PersistenceReader reader) {}
|
||||
|
||||
@Override
|
||||
public void shutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.livingworld.modules.ecosystem;
|
||||
|
||||
/**
|
||||
* Per-region ecosystem summary state tracked by {@link EcosystemModule}.
|
||||
*
|
||||
* <p>These values integrate signals from all other ecosystem modules to represent
|
||||
* the overall ecological condition of a region. All values are clamped to [0, 100].
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>ecosystemHealth</b> – weighted composite of soil, water, pollution and vegetation
|
||||
* <li><b>stress</b> – accumulated ecological stress; rises when multiple metrics
|
||||
* are in danger zones, recovers slowly in good conditions
|
||||
* <li><b>resilience</b> – long-term stability; high resilience means the region
|
||||
* resists and recovers from damage more quickly
|
||||
* <li><b>recoveryRate</b> – current rate of ecological self-repair per tick
|
||||
* </ul>
|
||||
*/
|
||||
public final class EcosystemRegionData {
|
||||
|
||||
private static final double MIN = 0.0;
|
||||
private static final double MAX = 100.0;
|
||||
|
||||
private double ecosystemHealth;
|
||||
private double stress;
|
||||
private double resilience;
|
||||
private double recoveryRate;
|
||||
|
||||
public EcosystemRegionData(
|
||||
double ecosystemHealth,
|
||||
double stress,
|
||||
double resilience,
|
||||
double recoveryRate) {
|
||||
this.ecosystemHealth = clamp(ecosystemHealth);
|
||||
this.stress = clamp(stress);
|
||||
this.resilience = clamp(resilience);
|
||||
this.recoveryRate = clamp(recoveryRate);
|
||||
}
|
||||
|
||||
/** Returns a default moderate-health ecosystem state. */
|
||||
public static EcosystemRegionData defaults() {
|
||||
return new EcosystemRegionData(60.0, 20.0, 50.0, 5.0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Getters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public double getEcosystemHealth() { return ecosystemHealth; }
|
||||
public double getStress() { return stress; }
|
||||
public double getResilience() { return resilience; }
|
||||
public double getRecoveryRate() { return recoveryRate; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Mutation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Records an ecological stress event of the given magnitude.
|
||||
*
|
||||
* <p>Stress accumulates and slowly erodes resilience.
|
||||
*
|
||||
* @param amount positive stress magnitude
|
||||
*/
|
||||
public void applyStress(double amount) {
|
||||
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||
stress = clamp(stress + amount);
|
||||
resilience = clamp(resilience - amount * 0.1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an ecological recovery event of the given magnitude.
|
||||
*
|
||||
* <p>Reduces stress and gradually rebuilds resilience.
|
||||
*
|
||||
* @param amount positive recovery magnitude
|
||||
*/
|
||||
public void applyRecovery(double amount) {
|
||||
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||
stress = clamp(stress - amount);
|
||||
resilience = clamp(resilience + amount * 0.05);
|
||||
}
|
||||
|
||||
public void setEcosystemHealth(double v) { ecosystemHealth = clamp(v); }
|
||||
public void setStress(double v) { stress = clamp(v); }
|
||||
public void setResilience(double v) { resilience = clamp(v); }
|
||||
public void setRecoveryRate(double v) { recoveryRate = clamp(v); }
|
||||
|
||||
/** Clamps all fields to [0, 100]. */
|
||||
public void normalize() {
|
||||
ecosystemHealth = clamp(ecosystemHealth);
|
||||
stress = clamp(stress);
|
||||
resilience = clamp(resilience);
|
||||
recoveryRate = clamp(recoveryRate);
|
||||
}
|
||||
|
||||
/** Returns an independent copy. */
|
||||
public EcosystemRegionData copy() {
|
||||
return new EcosystemRegionData(ecosystemHealth, stress, resilience, recoveryRate);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static double clamp(double v) {
|
||||
return Math.min(MAX, Math.max(MIN, v));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "EcosystemRegionData{"
|
||||
+ "health=" + ecosystemHealth
|
||||
+ ", stress=" + stress
|
||||
+ ", resilience=" + resilience
|
||||
+ ", recoveryRate=" + recoveryRate + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.livingworld.modules.pollution;
|
||||
|
||||
import com.livingworld.config.EcosystemTuning;
|
||||
import com.livingworld.core.services.CoreServices;
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Simulates natural pollution decay and computes the summary pollution metrics
|
||||
* written to {@link RegionMetrics}.
|
||||
*
|
||||
* <p>This module should run first in the ecosystem update order so that
|
||||
* {@link com.livingworld.modules.soil.SoilModule} and
|
||||
* {@link com.livingworld.modules.vegetation.VegetationModule} see current-tick
|
||||
* pollution values when they execute.
|
||||
*
|
||||
* <h3>Per-tick rules</h3>
|
||||
* <ol>
|
||||
* <li>Air, ground, and water pollution each decay at different rates.
|
||||
* <li>Ground pollution slowly leaches into water.
|
||||
* <li>{@link RegionMetrics#getPollutionScore()} is recomputed as a weighted average.
|
||||
* <li>{@link RegionMetrics#getWaterQuality()} is reduced proportionally to water pollution.
|
||||
* </ol>
|
||||
*
|
||||
* <h3>Configurable constants</h3>
|
||||
* These are intentionally simple for V1 and expected to be tuned.
|
||||
* <ul>
|
||||
* <li>BASE_DECAY_RATE – fraction of pollution removed per tick before resistance
|
||||
* <li>GROUND_TO_WATER_LEACH – fraction of groundPollution that leaches to water each tick
|
||||
* <li>WATER_QUALITY_IMPACT – fraction by which water pollution degrades waterQuality
|
||||
* </ul>
|
||||
*/
|
||||
public final class PollutionModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "pollution";
|
||||
|
||||
private static final double WATER_QUALITY_IMPACT = 0.05;
|
||||
private static final double CHANGE_THRESHOLD = 0.01;
|
||||
|
||||
private EcosystemTuning tuning = new EcosystemTuning();
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"Pollution",
|
||||
"1.0.0",
|
||||
"Simulates pollution decay and spread across regions.",
|
||||
"1",
|
||||
List.of(),
|
||||
List.of(),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
@Override
|
||||
public String getModuleId() { return MODULE_ID; }
|
||||
|
||||
@Override
|
||||
public ModuleMetadata getMetadata() { return METADATA; }
|
||||
|
||||
@Override
|
||||
public void initialize(ModuleContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
if (context.hasService(CoreServices.TUNING)) this.tuning = context.getService(CoreServices.TUNING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerStarted(ServerContext context) {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||
region.getModuleData().put(MODULE_ID, PollutionRegionData.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
|
||||
Region region = context.getRegion();
|
||||
PollutionRegionData data = region.getModuleData()
|
||||
.get(MODULE_ID, PollutionRegionData.class)
|
||||
.orElseGet(PollutionRegionData::defaults);
|
||||
|
||||
double prevPollutionScore = region.getMetrics().getPollutionScore();
|
||||
double prevWaterQuality = region.getMetrics().getWaterQuality();
|
||||
|
||||
// Natural decay: air decays fastest, water slowest (modulated by resistance).
|
||||
data.decay(tuning.getPollutionDecayRate());
|
||||
|
||||
// Ground pollution slowly leaches into water even after decay.
|
||||
double leach = data.getGroundPollution() * tuning.getGroundToWaterLeach();
|
||||
data.addPollution(0.0, 0.0, leach);
|
||||
|
||||
// Summary metric: weighted average emphasising waterPollution as most damaging.
|
||||
double pollutionScore = (data.getAirPollution() * 0.40
|
||||
+ data.getGroundPollution() * 0.35
|
||||
+ data.getWaterPollution() * 0.25);
|
||||
region.getMetrics().setPollutionScore(pollutionScore);
|
||||
|
||||
// Water quality degrades proportionally to water pollution this tick.
|
||||
double waterQualityDrop = data.getWaterPollution() * WATER_QUALITY_IMPACT;
|
||||
region.getMetrics().setWaterQuality(
|
||||
Math.max(0.0, region.getMetrics().getWaterQuality() - waterQualityDrop));
|
||||
|
||||
// Persist updated data.
|
||||
region.getModuleData().put(MODULE_ID, data);
|
||||
|
||||
boolean changed =
|
||||
Math.abs(region.getMetrics().getPollutionScore() - prevPollutionScore) > CHANGE_THRESHOLD
|
||||
|| Math.abs(region.getMetrics().getWaterQuality() - prevWaterQuality) > CHANGE_THRESHOLD;
|
||||
|
||||
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
|
||||
@Override
|
||||
public void saveModuleData(PersistenceWriter writer) {}
|
||||
|
||||
@Override
|
||||
public void loadModuleData(PersistenceReader reader) {}
|
||||
|
||||
@Override
|
||||
public void shutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.livingworld.modules.pollution;
|
||||
|
||||
/**
|
||||
* Per-region pollution state tracked by {@link PollutionModule}.
|
||||
*
|
||||
* <p>Stores three independent pollution layers (air, ground, water) and a decay
|
||||
* resistance score. All values are clamped to [0, 100].
|
||||
*
|
||||
* <p>Decay resistance slows natural recovery. A brand-new region has zero
|
||||
* pollution and low decay resistance, meaning pollution introduced there will
|
||||
* dissipate quickly.
|
||||
*/
|
||||
public final class PollutionRegionData {
|
||||
|
||||
private static final double MIN = 0.0;
|
||||
private static final double MAX = 100.0;
|
||||
|
||||
private double airPollution;
|
||||
private double groundPollution;
|
||||
private double waterPollution;
|
||||
private double decayResistance;
|
||||
|
||||
public PollutionRegionData(
|
||||
double airPollution,
|
||||
double groundPollution,
|
||||
double waterPollution,
|
||||
double decayResistance) {
|
||||
this.airPollution = clamp(airPollution);
|
||||
this.groundPollution = clamp(groundPollution);
|
||||
this.waterPollution = clamp(waterPollution);
|
||||
this.decayResistance = clamp(decayResistance);
|
||||
}
|
||||
|
||||
/** Returns a clean region with low decay resistance. */
|
||||
public static PollutionRegionData defaults() {
|
||||
return new PollutionRegionData(0.0, 0.0, 0.0, 20.0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Getters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public double getAirPollution() { return airPollution; }
|
||||
public double getGroundPollution() { return groundPollution; }
|
||||
public double getWaterPollution() { return waterPollution; }
|
||||
public double getDecayResistance() { return decayResistance; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Mutation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Adds pollution to each layer; values are clamped after addition. */
|
||||
public void addPollution(double air, double ground, double water) {
|
||||
airPollution = clamp(airPollution + air);
|
||||
groundPollution = clamp(groundPollution + ground);
|
||||
waterPollution = clamp(waterPollution + water);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a single natural-decay tick.
|
||||
*
|
||||
* <p>{@code baseRate} is the fraction removed per tick before resistance is
|
||||
* applied (e.g. 0.02 = 2 %). Decay resistance reduces the effective rate:
|
||||
* effective = baseRate * (1 - decayResistance / 200).
|
||||
*
|
||||
* @param baseRate fraction to decay per tick (0–1)
|
||||
*/
|
||||
public void decay(double baseRate) {
|
||||
double resistanceFactor = 1.0 - (decayResistance / 200.0);
|
||||
double effectiveRate = Math.max(0.0, baseRate * resistanceFactor);
|
||||
airPollution = clamp(airPollution * (1.0 - effectiveRate * 2.0));
|
||||
groundPollution = clamp(groundPollution * (1.0 - effectiveRate * 0.5));
|
||||
waterPollution = clamp(waterPollution * (1.0 - effectiveRate * 0.3));
|
||||
}
|
||||
|
||||
/** Clamps all fields to [0, 100]. */
|
||||
public void normalize() {
|
||||
airPollution = clamp(airPollution);
|
||||
groundPollution = clamp(groundPollution);
|
||||
waterPollution = clamp(waterPollution);
|
||||
decayResistance = clamp(decayResistance);
|
||||
}
|
||||
|
||||
/** Returns an independent copy. */
|
||||
public PollutionRegionData copy() {
|
||||
return new PollutionRegionData(airPollution, groundPollution, waterPollution, decayResistance);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static double clamp(double v) {
|
||||
return Math.min(MAX, Math.max(MIN, v));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "PollutionRegionData{"
|
||||
+ "air=" + airPollution
|
||||
+ ", ground=" + groundPollution
|
||||
+ ", water=" + waterPollution
|
||||
+ ", decayResistance=" + decayResistance + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.livingworld.modules.recovery;
|
||||
|
||||
import com.livingworld.config.EcosystemTuning;
|
||||
import com.livingworld.core.services.CoreServices;
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Manages ecological succession: the gradual progression of a region from bare
|
||||
* ground through grassland, scrubland, and young woodland to mature forest —
|
||||
* and the regression back toward bare ground when conditions deteriorate.
|
||||
*
|
||||
* <p>This module runs near the end of the pipeline (after pollution, soil,
|
||||
* water, vegetation, and resources have updated) so that succession decisions
|
||||
* are made against fully current metrics.
|
||||
*
|
||||
* <h3>Advancement</h3>
|
||||
* Each tick that metrics meet a stage's thresholds, {@code recoveryProgress}
|
||||
* increases by the ecosystem's {@code recoveryPressure}-derived rate. When
|
||||
* it reaches 100, the region advances one succession stage.
|
||||
*
|
||||
* <h3>Regression</h3>
|
||||
* Each tick that metrics fall badly below the current stage's minimums,
|
||||
* {@code damageAccumulation} increases. At 70 accumulated damage the region
|
||||
* regresses one stage.
|
||||
*
|
||||
* <h3>Recovery pressure metric</h3>
|
||||
* The module modifies {@link RegionMetrics#getRecoveryPressure()} to reflect
|
||||
* how far the region is from mature forest (EcosystemModule also writes this;
|
||||
* this module's write takes precedence because it runs later).
|
||||
*/
|
||||
public final class RecoveryModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "recovery";
|
||||
|
||||
/** Extra recovery progress per point of ecosystemHealth above 50. */
|
||||
private static final double HEALTH_PROGRESS_BONUS = 0.05;
|
||||
private static final double CHANGE_THRESHOLD = 0.01;
|
||||
|
||||
private EcosystemTuning tuning = new EcosystemTuning();
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"Recovery & Succession",
|
||||
"1.0.0",
|
||||
"Manages ecological succession stages from barren ground to mature forest.",
|
||||
"1",
|
||||
List.of("pollution", "soil", "vegetation"),
|
||||
List.of("ecosystem"),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
@Override
|
||||
public String getModuleId() { return MODULE_ID; }
|
||||
|
||||
@Override
|
||||
public ModuleMetadata getMetadata() { return METADATA; }
|
||||
|
||||
@Override
|
||||
public void initialize(ModuleContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
if (context.hasService(CoreServices.TUNING)) this.tuning = context.getService(CoreServices.TUNING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerStarted(ServerContext context) {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||
region.getModuleData().put(MODULE_ID, RecoveryRegionData.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
|
||||
Region region = context.getRegion();
|
||||
RecoveryRegionData data = region.getModuleData()
|
||||
.get(MODULE_ID, RecoveryRegionData.class)
|
||||
.orElseGet(RecoveryRegionData::defaults);
|
||||
RegionMetrics metrics = region.getMetrics();
|
||||
|
||||
SuccessionStage stageBefore = data.getSuccessionStage();
|
||||
double progressBefore = data.getRecoveryProgress();
|
||||
|
||||
double soil = metrics.getSoilQuality();
|
||||
double poll = metrics.getPollutionScore();
|
||||
double veg = metrics.getVegetationPressure();
|
||||
|
||||
if (data.getSuccessionStage().conditionsMetForAdvancement(soil, poll, veg)) {
|
||||
// Conditions are good: advance recovery progress.
|
||||
double progressGain = tuning.getBaseProgressPerTick();
|
||||
if (metrics.getEcosystemHealth() > 50.0) {
|
||||
progressGain += (metrics.getEcosystemHealth() - 50.0) * HEALTH_PROGRESS_BONUS;
|
||||
}
|
||||
data.advanceProgress(progressGain, soil, poll, veg);
|
||||
|
||||
// Damage decays passively when conditions are good. Use 4-arg constructor to
|
||||
// preserve the dynamic succession cap set by applyDynamicCapUpdate().
|
||||
data = new RecoveryRegionData(
|
||||
data.getSuccessionStage(),
|
||||
data.getRecoveryProgress(),
|
||||
Math.max(0.0, data.getDamageAccumulation() * (1.0 - tuning.getDamageDecayRate())),
|
||||
data.getMaxSuccessionStage());
|
||||
|
||||
} else if (data.getSuccessionStage().conditionsMissedForRegression(soil, poll, veg)) {
|
||||
// Conditions are bad: accumulate damage toward regression.
|
||||
data.accumulateDamage(tuning.getDamagePerBadTick(), soil, poll, veg);
|
||||
}
|
||||
|
||||
// Recovery pressure reflects distance from mature forest.
|
||||
int stagesFromPeak = SuccessionStage.MATURE_FOREST.ordinal()
|
||||
- data.getSuccessionStage().ordinal();
|
||||
double recoveryPressure = Math.min(100.0, stagesFromPeak * 20.0
|
||||
+ data.getDamageAccumulation() * 0.30);
|
||||
metrics.setRecoveryPressure(recoveryPressure);
|
||||
|
||||
region.getModuleData().put(MODULE_ID, data);
|
||||
|
||||
boolean stageChanged = data.getSuccessionStage() != stageBefore;
|
||||
boolean progressChanged = Math.abs(data.getRecoveryProgress() - progressBefore)
|
||||
> CHANGE_THRESHOLD;
|
||||
return (stageChanged || progressChanged)
|
||||
? ModuleUpdateResult.changed()
|
||||
: ModuleUpdateResult.noChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
|
||||
@Override
|
||||
public void saveModuleData(PersistenceWriter writer) {}
|
||||
|
||||
@Override
|
||||
public void loadModuleData(PersistenceReader reader) {}
|
||||
|
||||
@Override
|
||||
public void shutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.livingworld.modules.recovery;
|
||||
|
||||
/**
|
||||
* Per-region ecological recovery state tracked by {@link RecoveryModule}.
|
||||
*
|
||||
* <p>Tracks where a region sits in the ecological succession sequence and how much
|
||||
* progress it has made toward the next stage.
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>successionStage</b> – current ecological stage (see {@link SuccessionStage})
|
||||
* <li><b>recoveryProgress</b> – progress (0–100) toward advancing to the next stage
|
||||
* <li><b>damageAccumulation</b> – accumulated ecological damage (0–100); when this
|
||||
* exceeds a threshold the region regresses to the previous stage
|
||||
* </ul>
|
||||
*/
|
||||
public final class RecoveryRegionData {
|
||||
|
||||
private static final double MIN = 0.0;
|
||||
private static final double MAX = 100.0;
|
||||
|
||||
private SuccessionStage successionStage;
|
||||
private double recoveryProgress;
|
||||
private double damageAccumulation;
|
||||
/** Biome-derived ceiling — region cannot advance past this stage. */
|
||||
private SuccessionStage maxSuccessionStage;
|
||||
|
||||
public RecoveryRegionData(
|
||||
SuccessionStage successionStage,
|
||||
double recoveryProgress,
|
||||
double damageAccumulation) {
|
||||
this(successionStage, recoveryProgress, damageAccumulation, SuccessionStage.MATURE_FOREST);
|
||||
}
|
||||
|
||||
public RecoveryRegionData(
|
||||
SuccessionStage successionStage,
|
||||
double recoveryProgress,
|
||||
double damageAccumulation,
|
||||
SuccessionStage maxSuccessionStage) {
|
||||
if (successionStage == null) {
|
||||
throw new IllegalArgumentException("successionStage must not be null");
|
||||
}
|
||||
if (maxSuccessionStage == null) {
|
||||
throw new IllegalArgumentException("maxSuccessionStage must not be null");
|
||||
}
|
||||
this.successionStage = successionStage;
|
||||
this.recoveryProgress = clamp(recoveryProgress);
|
||||
this.damageAccumulation = clamp(damageAccumulation);
|
||||
this.maxSuccessionStage = maxSuccessionStage;
|
||||
}
|
||||
|
||||
/** Returns a region at grassland stage with no accumulated damage. */
|
||||
public static RecoveryRegionData defaults() {
|
||||
return new RecoveryRegionData(SuccessionStage.GRASSLAND, 0.0, 0.0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Getters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public SuccessionStage getSuccessionStage() { return successionStage; }
|
||||
public double getRecoveryProgress() { return recoveryProgress; }
|
||||
public double getDamageAccumulation() { return damageAccumulation; }
|
||||
public SuccessionStage getMaxSuccessionStage() { return maxSuccessionStage; }
|
||||
|
||||
public void setMaxSuccessionStage(SuccessionStage cap) {
|
||||
if (cap == null) throw new IllegalArgumentException("cap must not be null");
|
||||
this.maxSuccessionStage = cap;
|
||||
// If current stage already exceeds the new cap, clamp it.
|
||||
if (successionStage.ordinal() > cap.ordinal()) {
|
||||
successionStage = cap;
|
||||
recoveryProgress = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Advances one restoration stage without bypassing the region's biome cap. */
|
||||
public void boostOneStage() {
|
||||
if (successionStage.hasNext()
|
||||
&& successionStage.next().ordinal() <= maxSuccessionStage.ordinal()) {
|
||||
successionStage = successionStage.next();
|
||||
recoveryProgress = 0.0;
|
||||
damageAccumulation = Math.max(0.0, damageAccumulation - 20.0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Permanently raises the ecological ceiling by one stage. */
|
||||
public void raiseCapOneStage() {
|
||||
if (maxSuccessionStage.hasNext()) {
|
||||
maxSuccessionStage = maxSuccessionStage.next();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Mutation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advances recovery progress. If progress reaches 100 and conditions allow,
|
||||
* the stage advances and progress resets.
|
||||
*
|
||||
* @param amount positive progress to add
|
||||
* @param soilQuality current soilQuality metric
|
||||
* @param pollutionScore current pollutionScore metric
|
||||
* @param vegetationPressure current vegetationPressure metric
|
||||
*/
|
||||
public void advanceProgress(double amount,
|
||||
double soilQuality,
|
||||
double pollutionScore,
|
||||
double vegetationPressure) {
|
||||
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||
recoveryProgress = clamp(recoveryProgress + amount);
|
||||
if (recoveryProgress >= 100.0
|
||||
&& successionStage.hasNext()
|
||||
&& successionStage.next().ordinal() <= maxSuccessionStage.ordinal()
|
||||
&& successionStage.conditionsMetForAdvancement(soilQuality, pollutionScore, vegetationPressure)) {
|
||||
successionStage = successionStage.next();
|
||||
recoveryProgress = 0.0;
|
||||
damageAccumulation = clamp(damageAccumulation - 10.0); // partial healing on advance
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulates ecological damage. If damage exceeds 70, the region regresses
|
||||
* one succession stage and damage resets partially.
|
||||
*
|
||||
* @param amount positive damage to accumulate
|
||||
* @param soilQuality current soilQuality metric
|
||||
* @param pollutionScore current pollutionScore metric
|
||||
* @param vegetationPressure current vegetationPressure metric
|
||||
*/
|
||||
public void accumulateDamage(double amount,
|
||||
double soilQuality,
|
||||
double pollutionScore,
|
||||
double vegetationPressure) {
|
||||
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||
damageAccumulation = clamp(damageAccumulation + amount);
|
||||
if (damageAccumulation >= 70.0
|
||||
&& successionStage.conditionsMissedForRegression(soilQuality, pollutionScore, vegetationPressure)) {
|
||||
successionStage = successionStage.prev();
|
||||
damageAccumulation = 30.0; // partial reset so regression isn't instantaneous
|
||||
recoveryProgress = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamps all numeric fields to [0, 100]. */
|
||||
public void normalize() {
|
||||
recoveryProgress = clamp(recoveryProgress);
|
||||
damageAccumulation = clamp(damageAccumulation);
|
||||
}
|
||||
|
||||
/** Returns an independent copy. */
|
||||
public RecoveryRegionData copy() {
|
||||
return new RecoveryRegionData(successionStage, recoveryProgress, damageAccumulation, maxSuccessionStage);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static double clamp(double v) {
|
||||
return Math.min(MAX, Math.max(MIN, v));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RecoveryRegionData{"
|
||||
+ "stage=" + successionStage
|
||||
+ ", progress=" + recoveryProgress
|
||||
+ ", damage=" + damageAccumulation
|
||||
+ ", maxStage=" + maxSuccessionStage + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.livingworld.modules.recovery;
|
||||
|
||||
/**
|
||||
* Ecological succession stages that a region can progress through or regress from.
|
||||
*
|
||||
* <p>Stages are ordered from most degraded ({@link #BARREN}) to most developed
|
||||
* ({@link #MATURE_FOREST}). Advancement requires conditions to meet each stage's
|
||||
* thresholds; sustained damage can cause regression.
|
||||
*/
|
||||
public enum SuccessionStage {
|
||||
|
||||
/** No significant vegetation; exposed or contaminated soil. */
|
||||
BARREN(
|
||||
/* minSoilQuality */ 0.0,
|
||||
/* maxPollutionScore */ 100.0,
|
||||
/* minVegetationPressure */ 0.0),
|
||||
|
||||
/** Patchy grass cover starting to establish. */
|
||||
SPARSE_GRASS(
|
||||
10.0,
|
||||
80.0,
|
||||
10.0),
|
||||
|
||||
/** Continuous grass cover with scattered herbs. */
|
||||
GRASSLAND(
|
||||
25.0,
|
||||
70.0,
|
||||
20.0),
|
||||
|
||||
/** Shrubs and mixed vegetation; beginning of structural complexity. */
|
||||
SCRUBLAND(
|
||||
40.0,
|
||||
60.0,
|
||||
35.0),
|
||||
|
||||
/** Young trees intermixed with shrubs; canopy forming. */
|
||||
YOUNG_WOODLAND(
|
||||
50.0,
|
||||
50.0,
|
||||
45.0),
|
||||
|
||||
/** Established tree canopy; full ecological complexity. */
|
||||
MATURE_FOREST(
|
||||
60.0,
|
||||
40.0,
|
||||
55.0);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Stage thresholds
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Minimum soilQuality metric required to enter (and stay in) this stage. */
|
||||
public final double minSoilQuality;
|
||||
|
||||
/** Maximum pollutionScore metric allowed to enter (and stay in) this stage. */
|
||||
public final double maxPollutionScore;
|
||||
|
||||
/** Minimum vegetationPressure metric required to enter (and stay in) this stage. */
|
||||
public final double minVegetationPressure;
|
||||
|
||||
SuccessionStage(double minSoilQuality, double maxPollutionScore, double minVegetationPressure) {
|
||||
this.minSoilQuality = minSoilQuality;
|
||||
this.maxPollutionScore = maxPollutionScore;
|
||||
this.minVegetationPressure = minVegetationPressure;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Navigation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Returns {@code true} if this stage is not {@link #MATURE_FOREST}. */
|
||||
public boolean hasNext() {
|
||||
return ordinal() < MATURE_FOREST.ordinal();
|
||||
}
|
||||
|
||||
/** Returns {@code true} if this stage is not {@link #BARREN}. */
|
||||
public boolean hasPrev() {
|
||||
return ordinal() > BARREN.ordinal();
|
||||
}
|
||||
|
||||
/** Returns the next stage, or this stage if already {@link #MATURE_FOREST}. */
|
||||
public SuccessionStage next() {
|
||||
return hasNext() ? values()[ordinal() + 1] : this;
|
||||
}
|
||||
|
||||
/** Returns the previous stage, or this stage if already {@link #BARREN}. */
|
||||
public SuccessionStage prev() {
|
||||
return hasPrev() ? values()[ordinal() - 1] : this;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Condition checks (based on RegionMetrics)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns {@code true} when current ecosystem metrics are good enough for this
|
||||
* region to <em>advance</em> to the next succession stage.
|
||||
*/
|
||||
public boolean conditionsMetForAdvancement(
|
||||
double soilQuality, double pollutionScore, double vegetationPressure) {
|
||||
if (!hasNext()) return false;
|
||||
SuccessionStage target = next();
|
||||
return soilQuality >= target.minSoilQuality
|
||||
&& pollutionScore <= target.maxPollutionScore
|
||||
&& vegetationPressure >= target.minVegetationPressure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} when current metrics have deteriorated enough to
|
||||
* <em>regress</em> to the previous succession stage.
|
||||
*
|
||||
* <p>Regression threshold is set tighter than advancement (conditions must be
|
||||
* significantly worse than the current stage's minimums).
|
||||
*/
|
||||
public boolean conditionsMissedForRegression(
|
||||
double soilQuality, double pollutionScore, double vegetationPressure) {
|
||||
if (!hasPrev()) return false;
|
||||
return soilQuality < minSoilQuality * 0.5
|
||||
|| pollutionScore > maxPollutionScore * 1.2
|
||||
|| vegetationPressure < minVegetationPressure * 0.5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.livingworld.modules.resources;
|
||||
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.regions.Region;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Tracks how intensively each resource type has been harvested and models
|
||||
* natural regeneration, writing {@link com.livingworld.regions.RegionMetrics#getResourceDepletion()}.
|
||||
*
|
||||
* <p>Depletion events (mining, logging, farming) are normally recorded by the
|
||||
* NeoForge platform adapter when players interact with the world. This module
|
||||
* handles the autonomous decay (regeneration) side.
|
||||
*
|
||||
* <h3>Regeneration rates (per tick)</h3>
|
||||
* <ul>
|
||||
* <li><b>mining</b> – 0.01 % (geological timescale; very slow)
|
||||
* <li><b>logging</b> – 0.5 % base + bonus when vegetationPressure is high
|
||||
* <li><b>farming</b> – 0.3 % base + bonus when soilQuality is high
|
||||
* </ul>
|
||||
*
|
||||
* <h3>Ecosystem feedback</h3>
|
||||
* High logging depletion suppresses tree growth (read by VegetationModule via
|
||||
* {@link com.livingworld.regions.RegionMetrics#getResourceDepletion()}).
|
||||
*/
|
||||
public final class ResourceDepletionModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "resources";
|
||||
|
||||
private static final double MINING_REGEN_RATE = 0.0001;
|
||||
private static final double LOGGING_BASE_REGEN = 0.005;
|
||||
private static final double LOGGING_VEG_BONUS_RATE = 0.005;
|
||||
private static final double LOGGING_VEG_THRESHOLD = 50.0;
|
||||
private static final double FARMING_BASE_REGEN = 0.003;
|
||||
private static final double FARMING_SOIL_BONUS_RATE = 0.003;
|
||||
private static final double FARMING_SOIL_THRESHOLD = 50.0;
|
||||
private static final double CHANGE_THRESHOLD = 0.01;
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"Resource Depletion",
|
||||
"1.0.0",
|
||||
"Tracks resource exhaustion and natural regeneration.",
|
||||
"1",
|
||||
List.of(),
|
||||
List.of("soil", "vegetation"),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
@Override
|
||||
public String getModuleId() { return MODULE_ID; }
|
||||
|
||||
@Override
|
||||
public ModuleMetadata getMetadata() { return METADATA; }
|
||||
|
||||
@Override
|
||||
public void initialize(ModuleContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerStarted(ServerContext context) {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||
region.getModuleData().put(MODULE_ID, ResourceRegionData.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
|
||||
Region region = context.getRegion();
|
||||
ResourceRegionData data = region.getModuleData()
|
||||
.get(MODULE_ID, ResourceRegionData.class)
|
||||
.orElseGet(ResourceRegionData::defaults);
|
||||
|
||||
double prevTotal = data.totalDepletion();
|
||||
|
||||
// Mining regenerates extremely slowly (geological timescale).
|
||||
data.setMiningDepletion(data.getMiningDepletion() * (1.0 - MINING_REGEN_RATE));
|
||||
|
||||
// Logging regenerates faster when vegetation is recovering.
|
||||
double loggingRegen = LOGGING_BASE_REGEN;
|
||||
if (region.getMetrics().getVegetationPressure() > LOGGING_VEG_THRESHOLD) {
|
||||
loggingRegen += (region.getMetrics().getVegetationPressure() - LOGGING_VEG_THRESHOLD)
|
||||
* LOGGING_VEG_BONUS_RATE;
|
||||
}
|
||||
data.setLoggingDepletion(Math.max(0.0, data.getLoggingDepletion() - loggingRegen));
|
||||
|
||||
// Farming depletion recovers faster with good soil quality.
|
||||
double farmingRegen = FARMING_BASE_REGEN;
|
||||
if (region.getMetrics().getSoilQuality() > FARMING_SOIL_THRESHOLD) {
|
||||
farmingRegen += (region.getMetrics().getSoilQuality() - FARMING_SOIL_THRESHOLD)
|
||||
* FARMING_SOIL_BONUS_RATE;
|
||||
}
|
||||
data.setFarmingDepletion(Math.max(0.0, data.getFarmingDepletion() - farmingRegen));
|
||||
|
||||
// Write summary metric.
|
||||
region.getMetrics().setResourceDepletion(data.totalDepletion());
|
||||
|
||||
region.getModuleData().put(MODULE_ID, data);
|
||||
|
||||
boolean changed = Math.abs(data.totalDepletion() - prevTotal) > CHANGE_THRESHOLD;
|
||||
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
|
||||
@Override
|
||||
public void saveModuleData(PersistenceWriter writer) {}
|
||||
|
||||
@Override
|
||||
public void loadModuleData(PersistenceReader reader) {}
|
||||
|
||||
@Override
|
||||
public void shutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.livingworld.modules.resources;
|
||||
|
||||
/**
|
||||
* Per-region resource depletion state tracked by {@link ResourceDepletionModule}.
|
||||
*
|
||||
* <p>Three independent depletion layers model how intensively each type of
|
||||
* resource harvesting has affected the region. All values are clamped to [0, 100],
|
||||
* where 0 means untouched and 100 means completely exhausted.
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>miningDepletion</b> – how heavily the region's mineral resources have
|
||||
* been extracted (very slow natural recovery)
|
||||
* <li><b>loggingDepletion</b> – how heavily trees have been harvested (recovery
|
||||
* is linked to vegetation succession)
|
||||
* <li><b>farmingDepletion</b> – how heavily the land has been cultivated without
|
||||
* rest (recovery driven by soil quality)
|
||||
* </ul>
|
||||
*
|
||||
* <p>Depletion events are recorded via {@link #recordMining}, {@link #recordLogging},
|
||||
* and {@link #recordFarming}, which are intended to be called from platform-layer
|
||||
* event handlers when players interact with the world.
|
||||
*/
|
||||
public final class ResourceRegionData {
|
||||
|
||||
private static final double MIN = 0.0;
|
||||
private static final double MAX = 100.0;
|
||||
|
||||
private double miningDepletion;
|
||||
private double loggingDepletion;
|
||||
private double farmingDepletion;
|
||||
|
||||
public ResourceRegionData(double miningDepletion, double loggingDepletion, double farmingDepletion) {
|
||||
this.miningDepletion = clamp(miningDepletion);
|
||||
this.loggingDepletion = clamp(loggingDepletion);
|
||||
this.farmingDepletion = clamp(farmingDepletion);
|
||||
}
|
||||
|
||||
/** Returns a pristine, unextracted region. */
|
||||
public static ResourceRegionData defaults() {
|
||||
return new ResourceRegionData(0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Getters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public double getMiningDepletion() { return miningDepletion; }
|
||||
public double getLoggingDepletion() { return loggingDepletion; }
|
||||
public double getFarmingDepletion() { return farmingDepletion; }
|
||||
|
||||
/** Weighted total depletion score, matching the metric written to {@link com.livingworld.regions.RegionMetrics}. */
|
||||
public double totalDepletion() {
|
||||
return clamp(miningDepletion * 0.50 + loggingDepletion * 0.30 + farmingDepletion * 0.20);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Depletion events (called by platform event handlers)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Records a mining event of the given intensity.
|
||||
*
|
||||
* @param intensity depletion to add (0–100)
|
||||
*/
|
||||
public void recordMining(double intensity) {
|
||||
if (intensity < 0) throw new IllegalArgumentException("intensity must be >= 0");
|
||||
miningDepletion = clamp(miningDepletion + intensity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a logging event of the given intensity.
|
||||
*
|
||||
* @param intensity depletion to add (0–100)
|
||||
*/
|
||||
public void recordLogging(double intensity) {
|
||||
if (intensity < 0) throw new IllegalArgumentException("intensity must be >= 0");
|
||||
loggingDepletion = clamp(loggingDepletion + intensity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a farming event of the given intensity.
|
||||
*
|
||||
* @param intensity depletion to add (0–100)
|
||||
*/
|
||||
public void recordFarming(double intensity) {
|
||||
if (intensity < 0) throw new IllegalArgumentException("intensity must be >= 0");
|
||||
farmingDepletion = clamp(farmingDepletion + intensity);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal setters (used by the module's regeneration logic)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public void setMiningDepletion(double v) { miningDepletion = clamp(v); }
|
||||
public void setLoggingDepletion(double v) { loggingDepletion = clamp(v); }
|
||||
public void setFarmingDepletion(double v) { farmingDepletion = clamp(v); }
|
||||
|
||||
/** Clamps all fields to [0, 100]. */
|
||||
public void normalize() {
|
||||
miningDepletion = clamp(miningDepletion);
|
||||
loggingDepletion = clamp(loggingDepletion);
|
||||
farmingDepletion = clamp(farmingDepletion);
|
||||
}
|
||||
|
||||
/** Returns an independent copy. */
|
||||
public ResourceRegionData copy() {
|
||||
return new ResourceRegionData(miningDepletion, loggingDepletion, farmingDepletion);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static double clamp(double v) {
|
||||
return Math.min(MAX, Math.max(MIN, v));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ResourceRegionData{"
|
||||
+ "mining=" + miningDepletion
|
||||
+ ", logging=" + loggingDepletion
|
||||
+ ", farming=" + farmingDepletion + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.livingworld.modules.soil;
|
||||
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Simulates soil health dynamics and writes {@link RegionMetrics#getSoilQuality()}.
|
||||
*
|
||||
* <p>This module runs after {@link com.livingworld.modules.pollution.PollutionModule}
|
||||
* so it reads the current-tick pollution score when deciding contamination accumulation.
|
||||
*
|
||||
* <h3>Per-tick rules</h3>
|
||||
* <ol>
|
||||
* <li>High pollution causes contamination to accumulate in the soil.
|
||||
* <li>Contamination steadily degrades fertility.
|
||||
* <li>Good vegetation cover promotes fertility recovery and resists erosion.
|
||||
* <li>Low fertility with low vegetation accelerates erosion.
|
||||
* <li>Soil quality is computed as a weighted score of fertility, contamination, and erosion.
|
||||
* </ol>
|
||||
*/
|
||||
public final class SoilModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "soil";
|
||||
|
||||
/** Pollution score above which contamination begins accumulating per tick. */
|
||||
private static final double POLLUTION_CONTAMINATION_THRESHOLD = 10.0;
|
||||
/** Fraction of excess pollution score that becomes contamination per tick. */
|
||||
private static final double POLLUTION_TO_CONTAMINATION_RATE = 0.003;
|
||||
/** Fertility reduction per unit of contamination per tick. */
|
||||
private static final double CONTAMINATION_FERTILITY_DRAIN = 0.005;
|
||||
/** Vegetation pressure threshold for fertility recovery to kick in. */
|
||||
private static final double VEGETATION_RECOVERY_THRESHOLD = 40.0;
|
||||
/** Fertility gained per unit of excess vegetation pressure per tick. */
|
||||
private static final double VEGETATION_RECOVERY_RATE = 0.002;
|
||||
/** Fertility level below which erosion increases. */
|
||||
private static final double EROSION_FERTILITY_THRESHOLD = 30.0;
|
||||
/** Erosion increase per tick when fertility is low. */
|
||||
private static final double EROSION_INCREASE_RATE = 0.05;
|
||||
/** Erosion reduction per tick when vegetation is good. */
|
||||
private static final double EROSION_VEGETATION_RESISTANCE = 0.03;
|
||||
private static final double CHANGE_THRESHOLD = 0.01;
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"Soil",
|
||||
"1.0.0",
|
||||
"Simulates soil fertility, contamination, and erosion dynamics.",
|
||||
"1",
|
||||
List.of("pollution"),
|
||||
List.of(),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
@Override
|
||||
public String getModuleId() { return MODULE_ID; }
|
||||
|
||||
@Override
|
||||
public ModuleMetadata getMetadata() { return METADATA; }
|
||||
|
||||
@Override
|
||||
public void initialize(ModuleContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerStarted(ServerContext context) {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||
region.getModuleData().put(MODULE_ID, SoilRegionData.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
|
||||
Region region = context.getRegion();
|
||||
SoilRegionData data = region.getModuleData()
|
||||
.get(MODULE_ID, SoilRegionData.class)
|
||||
.orElseGet(SoilRegionData::defaults);
|
||||
RegionMetrics metrics = region.getMetrics();
|
||||
|
||||
double prevSoilQuality = metrics.getSoilQuality();
|
||||
|
||||
// Pollution causes contamination to build up in the soil.
|
||||
if (metrics.getPollutionScore() > POLLUTION_CONTAMINATION_THRESHOLD) {
|
||||
double excess = metrics.getPollutionScore() - POLLUTION_CONTAMINATION_THRESHOLD;
|
||||
data.setContamination(data.getContamination() + excess * POLLUTION_TO_CONTAMINATION_RATE);
|
||||
}
|
||||
|
||||
// Contamination steadily drains fertility.
|
||||
data.setFertility(data.getFertility()
|
||||
- data.getContamination() * CONTAMINATION_FERTILITY_DRAIN);
|
||||
|
||||
// Good vegetation cover promotes fertility recovery.
|
||||
if (metrics.getVegetationPressure() > VEGETATION_RECOVERY_THRESHOLD) {
|
||||
double excess = metrics.getVegetationPressure() - VEGETATION_RECOVERY_THRESHOLD;
|
||||
data.setFertility(data.getFertility() + excess * VEGETATION_RECOVERY_RATE);
|
||||
}
|
||||
|
||||
// Low fertility accelerates erosion; good vegetation cover slows it.
|
||||
if (data.getFertility() < EROSION_FERTILITY_THRESHOLD) {
|
||||
data.setErosion(data.getErosion() + EROSION_INCREASE_RATE);
|
||||
}
|
||||
if (metrics.getVegetationPressure() > VEGETATION_RECOVERY_THRESHOLD) {
|
||||
data.setErosion(data.getErosion() - EROSION_VEGETATION_RESISTANCE);
|
||||
}
|
||||
|
||||
// Soil quality: fertility is the main driver, penalised by contamination and erosion.
|
||||
double soilQuality = Math.max(0.0, Math.min(100.0,
|
||||
data.getFertility()
|
||||
- data.getContamination() * 0.40
|
||||
- data.getErosion() * 0.30));
|
||||
metrics.setSoilQuality(soilQuality);
|
||||
|
||||
region.getModuleData().put(MODULE_ID, data);
|
||||
|
||||
boolean changed = Math.abs(metrics.getSoilQuality() - prevSoilQuality) > CHANGE_THRESHOLD;
|
||||
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
|
||||
@Override
|
||||
public void saveModuleData(PersistenceWriter writer) {}
|
||||
|
||||
@Override
|
||||
public void loadModuleData(PersistenceReader reader) {}
|
||||
|
||||
@Override
|
||||
public void shutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.livingworld.modules.soil;
|
||||
|
||||
/**
|
||||
* Per-region soil state tracked by {@link SoilModule}.
|
||||
*
|
||||
* <p>Five interacting values describe the physical and chemical condition of the
|
||||
* land in a region. All values are clamped to [0, 100].
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>fertility</b> – capacity to support plant growth (higher is better)
|
||||
* <li><b>moisture</b> – water content in soil (too low or too high is harmful)
|
||||
* <li><b>contamination</b> – chemical or biological pollution absorbed by soil
|
||||
* <li><b>compaction</b> – density of soil; high compaction resists root growth
|
||||
* <li><b>erosion</b> – structural loss of topsoil (higher is worse)
|
||||
* </ul>
|
||||
*/
|
||||
public final class SoilRegionData {
|
||||
|
||||
private static final double MIN = 0.0;
|
||||
private static final double MAX = 100.0;
|
||||
|
||||
private double fertility;
|
||||
private double moisture;
|
||||
private double contamination;
|
||||
private double compaction;
|
||||
private double erosion;
|
||||
|
||||
public SoilRegionData(
|
||||
double fertility,
|
||||
double moisture,
|
||||
double contamination,
|
||||
double compaction,
|
||||
double erosion) {
|
||||
this.fertility = clamp(fertility);
|
||||
this.moisture = clamp(moisture);
|
||||
this.contamination = clamp(contamination);
|
||||
this.compaction = clamp(compaction);
|
||||
this.erosion = clamp(erosion);
|
||||
}
|
||||
|
||||
/** Returns a healthy default soil profile. */
|
||||
public static SoilRegionData defaults() {
|
||||
return new SoilRegionData(60.0, 50.0, 0.0, 10.0, 0.0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Getters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public double getFertility() { return fertility; }
|
||||
public double getMoisture() { return moisture; }
|
||||
public double getContamination() { return contamination; }
|
||||
public double getCompaction() { return compaction; }
|
||||
public double getErosion() { return erosion; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Mutation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Degrades soil by the given amount.
|
||||
*
|
||||
* <p>Reduces fertility and increases contamination proportionally.
|
||||
*
|
||||
* @param amount positive degradation magnitude
|
||||
*/
|
||||
public void degrade(double amount) {
|
||||
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||
fertility = clamp(fertility - amount);
|
||||
contamination = clamp(contamination + amount * 0.5);
|
||||
erosion = clamp(erosion + amount * 0.3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ecological recovery by the given amount.
|
||||
*
|
||||
* <p>Increases fertility and reduces contamination and erosion.
|
||||
*
|
||||
* @param amount positive recovery magnitude
|
||||
*/
|
||||
public void recover(double amount) {
|
||||
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||
fertility = clamp(fertility + amount);
|
||||
contamination = clamp(contamination - amount * 0.4);
|
||||
erosion = clamp(erosion - amount * 0.3);
|
||||
}
|
||||
|
||||
public void setFertility(double v) { fertility = clamp(v); }
|
||||
public void setMoisture(double v) { moisture = clamp(v); }
|
||||
public void setContamination(double v) { contamination = clamp(v); }
|
||||
public void setCompaction(double v) { compaction = clamp(v); }
|
||||
public void setErosion(double v) { erosion = clamp(v); }
|
||||
|
||||
/** Clamps all fields to [0, 100]. */
|
||||
public void normalize() {
|
||||
fertility = clamp(fertility);
|
||||
moisture = clamp(moisture);
|
||||
contamination = clamp(contamination);
|
||||
compaction = clamp(compaction);
|
||||
erosion = clamp(erosion);
|
||||
}
|
||||
|
||||
/** Returns an independent copy. */
|
||||
public SoilRegionData copy() {
|
||||
return new SoilRegionData(fertility, moisture, contamination, compaction, erosion);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static double clamp(double v) {
|
||||
return Math.min(MAX, Math.max(MIN, v));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SoilRegionData{"
|
||||
+ "fertility=" + fertility
|
||||
+ ", moisture=" + moisture
|
||||
+ ", contamination=" + contamination
|
||||
+ ", compaction=" + compaction
|
||||
+ ", erosion=" + erosion + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.livingworld.modules.vegetation;
|
||||
|
||||
import com.livingworld.config.EcosystemTuning;
|
||||
import com.livingworld.core.services.CoreServices;
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Simulates vegetation succession and die-off, writing
|
||||
* {@link RegionMetrics#getVegetationPressure()}.
|
||||
*
|
||||
* <p>This module runs after {@link com.livingworld.modules.soil.SoilModule} and
|
||||
* {@link com.livingworld.modules.pollution.PollutionModule} so it reads
|
||||
* current-tick soil quality and pollution.
|
||||
*
|
||||
* <h3>Succession model</h3>
|
||||
* When soil quality is adequate and pollution is low, vegetation follows a
|
||||
* succession path: grass → flowers → shrubs → trees. Each tier can only grow
|
||||
* once the tier below it reaches a critical threshold.
|
||||
*
|
||||
* <h3>Die-off model</h3>
|
||||
* High pollution or very low soil quality kills vegetation in the reverse order
|
||||
* (trees → shrubs → grass) and increases dead organic material.
|
||||
*
|
||||
* <h3>Dead vegetation</h3>
|
||||
* Dead matter decomposes slowly each tick, eventually recycling into soil
|
||||
* nutrients (modelled implicitly via the soil module's contamination dynamics).
|
||||
*/
|
||||
public final class VegetationModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "vegetation";
|
||||
|
||||
// --- growth thresholds (not tunable — structural to the succession model) ---
|
||||
private static final double GROWTH_SOIL_THRESHOLD = 35.0;
|
||||
private static final double GROWTH_POLLUTION_LIMIT = 40.0;
|
||||
private static final double SHRUB_UNLOCK_GRASS = 50.0;
|
||||
private static final double TREE_UNLOCK_SHRUB = 40.0;
|
||||
private static final double DIEOFF_SOIL_THRESHOLD = 20.0;
|
||||
private static final double DIEOFF_POLLUTION_THRESHOLD = 30.0;
|
||||
private static final double DEAD_ACCUMULATION_RATE = 0.50;
|
||||
private static final double TREE_SEVERE_POLLUTION = 60.0;
|
||||
private static final double TREE_SEVERE_SOIL = 10.0;
|
||||
private static final double DEAD_DECOMPOSITION_RATE = 0.01;
|
||||
private static final double CHANGE_THRESHOLD = 0.01;
|
||||
|
||||
private EcosystemTuning tuning = new EcosystemTuning();
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"Vegetation",
|
||||
"1.0.0",
|
||||
"Simulates vegetation succession, growth, and die-off.",
|
||||
"1",
|
||||
List.of("pollution", "soil"),
|
||||
List.of(),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
@Override
|
||||
public String getModuleId() { return MODULE_ID; }
|
||||
|
||||
@Override
|
||||
public ModuleMetadata getMetadata() { return METADATA; }
|
||||
|
||||
@Override
|
||||
public void initialize(ModuleContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
if (context.hasService(CoreServices.TUNING)) this.tuning = context.getService(CoreServices.TUNING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerStarted(ServerContext context) {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||
region.getModuleData().put(MODULE_ID, VegetationRegionData.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
|
||||
Region region = context.getRegion();
|
||||
VegetationRegionData data = region.getModuleData()
|
||||
.get(MODULE_ID, VegetationRegionData.class)
|
||||
.orElseGet(VegetationRegionData::defaults);
|
||||
RegionMetrics metrics = region.getMetrics();
|
||||
|
||||
double prevVegetationPressure = metrics.getVegetationPressure();
|
||||
|
||||
boolean goodConditions = metrics.getSoilQuality() > GROWTH_SOIL_THRESHOLD
|
||||
&& metrics.getPollutionScore() < GROWTH_POLLUTION_LIMIT;
|
||||
|
||||
boolean badConditions = metrics.getSoilQuality() < DIEOFF_SOIL_THRESHOLD
|
||||
|| metrics.getPollutionScore() > DIEOFF_POLLUTION_THRESHOLD;
|
||||
|
||||
if (goodConditions) {
|
||||
double soilBonus = metrics.getSoilQuality() - GROWTH_SOIL_THRESHOLD;
|
||||
|
||||
data.setGrassPressure(data.getGrassPressure() + soilBonus * tuning.getGrassGrowthRate());
|
||||
data.setFlowerPressure(data.getFlowerPressure() + soilBonus * tuning.getFlowerGrowthRate());
|
||||
|
||||
// Shrubs grow only once grass is established.
|
||||
if (data.getGrassPressure() > SHRUB_UNLOCK_GRASS) {
|
||||
data.setShrubPressure(data.getShrubPressure() + soilBonus * tuning.getShrubGrowthRate());
|
||||
}
|
||||
// Trees grow only once shrubs are established.
|
||||
if (data.getShrubPressure() > TREE_UNLOCK_SHRUB) {
|
||||
data.setTreePressure(data.getTreePressure() + soilBonus * tuning.getTreeGrowthRate());
|
||||
}
|
||||
}
|
||||
|
||||
if (badConditions) {
|
||||
data.setGrassPressure(data.getGrassPressure() - tuning.getGrassDieoffRate());
|
||||
data.setFlowerPressure(data.getFlowerPressure() - tuning.getFlowerDieoffRate());
|
||||
data.setShrubPressure(data.getShrubPressure() - tuning.getShrubDieoffRate());
|
||||
// Trees die only under severe stress to reflect their resilience.
|
||||
if (metrics.getPollutionScore() > TREE_SEVERE_POLLUTION
|
||||
|| metrics.getSoilQuality() < TREE_SEVERE_SOIL) {
|
||||
data.setTreePressure(data.getTreePressure() - tuning.getTreeDieoffRate());
|
||||
}
|
||||
data.setDeadVegetation(data.getDeadVegetation() + DEAD_ACCUMULATION_RATE);
|
||||
}
|
||||
|
||||
// Dead vegetation decomposes slowly each tick.
|
||||
data.setDeadVegetation(data.getDeadVegetation() * (1.0 - DEAD_DECOMPOSITION_RATE));
|
||||
|
||||
// Vegetation pressure summary: living tiers weighted by ecological significance,
|
||||
// penalised by dead material burden.
|
||||
double vegetationPressure = Math.max(0.0, Math.min(100.0,
|
||||
data.getGrassPressure() * 0.35
|
||||
+ data.getFlowerPressure() * 0.10
|
||||
+ data.getShrubPressure() * 0.25
|
||||
+ data.getTreePressure() * 0.25
|
||||
- data.getDeadVegetation() * 0.20));
|
||||
metrics.setVegetationPressure(vegetationPressure);
|
||||
|
||||
region.getModuleData().put(MODULE_ID, data);
|
||||
|
||||
boolean changed = Math.abs(metrics.getVegetationPressure() - prevVegetationPressure)
|
||||
> CHANGE_THRESHOLD;
|
||||
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
|
||||
@Override
|
||||
public void saveModuleData(PersistenceWriter writer) {}
|
||||
|
||||
@Override
|
||||
public void loadModuleData(PersistenceReader reader) {}
|
||||
|
||||
@Override
|
||||
public void shutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.livingworld.modules.vegetation;
|
||||
|
||||
/**
|
||||
* Per-region vegetation state tracked by {@link VegetationModule}.
|
||||
*
|
||||
* <p>Five values capture the biomass distribution and health of plant cover in a
|
||||
* region. All values are clamped to [0, 100].
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>grassPressure</b> – density and health of grass-layer plants
|
||||
* <li><b>flowerPressure</b> – density and health of flowering plants
|
||||
* <li><b>shrubPressure</b> – density and health of shrubs / undergrowth
|
||||
* <li><b>treePressure</b> – density and health of tree canopy
|
||||
* <li><b>deadVegetation</b> – accumulated dead organic material (higher = more decay burden)
|
||||
* </ul>
|
||||
*
|
||||
* <p>Vegetation succession naturally flows from grass → flowers → shrubs → trees
|
||||
* when conditions allow. Damage reverses this sequence and raises deadVegetation.
|
||||
*/
|
||||
public final class VegetationRegionData {
|
||||
|
||||
private static final double MIN = 0.0;
|
||||
private static final double MAX = 100.0;
|
||||
|
||||
private double grassPressure;
|
||||
private double flowerPressure;
|
||||
private double shrubPressure;
|
||||
private double treePressure;
|
||||
private double deadVegetation;
|
||||
|
||||
public VegetationRegionData(
|
||||
double grassPressure,
|
||||
double flowerPressure,
|
||||
double shrubPressure,
|
||||
double treePressure,
|
||||
double deadVegetation) {
|
||||
this.grassPressure = clamp(grassPressure);
|
||||
this.flowerPressure = clamp(flowerPressure);
|
||||
this.shrubPressure = clamp(shrubPressure);
|
||||
this.treePressure = clamp(treePressure);
|
||||
this.deadVegetation = clamp(deadVegetation);
|
||||
}
|
||||
|
||||
/** Returns a default mixed-vegetation profile. */
|
||||
public static VegetationRegionData defaults() {
|
||||
return new VegetationRegionData(50.0, 30.0, 30.0, 40.0, 5.0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Getters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public double getGrassPressure() { return grassPressure; }
|
||||
public double getFlowerPressure() { return flowerPressure; }
|
||||
public double getShrubPressure() { return shrubPressure; }
|
||||
public double getTreePressure() { return treePressure; }
|
||||
public double getDeadVegetation() { return deadVegetation; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Mutation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simulates the impact of logging or clear-cutting by the given amount.
|
||||
*
|
||||
* <p>Reduces tree and shrub pressure while increasing dead vegetation
|
||||
* proportionally.
|
||||
*
|
||||
* @param amount positive logging damage magnitude
|
||||
*/
|
||||
public void reduceFromLogging(double amount) {
|
||||
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||
treePressure = clamp(treePressure - amount);
|
||||
shrubPressure = clamp(shrubPressure - amount * 0.5);
|
||||
deadVegetation = clamp(deadVegetation + amount * 0.8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ecological recovery across all vegetation layers by the given amount.
|
||||
*
|
||||
* <p>Increases all living pressures modestly and reduces dead vegetation.
|
||||
*
|
||||
* @param amount positive recovery magnitude
|
||||
*/
|
||||
public void recover(double amount) {
|
||||
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||
grassPressure = clamp(grassPressure + amount);
|
||||
flowerPressure = clamp(flowerPressure + amount * 0.7);
|
||||
shrubPressure = clamp(shrubPressure + amount * 0.5);
|
||||
treePressure = clamp(treePressure + amount * 0.3);
|
||||
deadVegetation = clamp(deadVegetation - amount * 0.6);
|
||||
}
|
||||
|
||||
public void setGrassPressure(double v) { grassPressure = clamp(v); }
|
||||
public void setFlowerPressure(double v) { flowerPressure = clamp(v); }
|
||||
public void setShrubPressure(double v) { shrubPressure = clamp(v); }
|
||||
public void setTreePressure(double v) { treePressure = clamp(v); }
|
||||
public void setDeadVegetation(double v) { deadVegetation = clamp(v); }
|
||||
|
||||
/** Clamps all fields to [0, 100]. */
|
||||
public void normalize() {
|
||||
grassPressure = clamp(grassPressure);
|
||||
flowerPressure = clamp(flowerPressure);
|
||||
shrubPressure = clamp(shrubPressure);
|
||||
treePressure = clamp(treePressure);
|
||||
deadVegetation = clamp(deadVegetation);
|
||||
}
|
||||
|
||||
/** Returns an independent copy. */
|
||||
public VegetationRegionData copy() {
|
||||
return new VegetationRegionData(
|
||||
grassPressure, flowerPressure, shrubPressure, treePressure, deadVegetation);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static double clamp(double v) {
|
||||
return Math.min(MAX, Math.max(MIN, v));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "VegetationRegionData{"
|
||||
+ "grass=" + grassPressure
|
||||
+ ", flower=" + flowerPressure
|
||||
+ ", shrub=" + shrubPressure
|
||||
+ ", tree=" + treePressure
|
||||
+ ", dead=" + deadVegetation + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.livingworld.modules.water;
|
||||
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Refines {@link RegionMetrics#getWaterQuality()} after the pollution module has
|
||||
* applied its raw damage, adding vegetation-driven purification and soil-driven
|
||||
* contamination leaching.
|
||||
*
|
||||
* <p>This module runs <em>after</em>
|
||||
* {@link com.livingworld.modules.pollution.PollutionModule} and
|
||||
* {@link com.livingworld.modules.soil.SoilModule} in the update pipeline so
|
||||
* it reads current-tick values for both pollutionScore and soilQuality.
|
||||
*
|
||||
* <h3>Per-tick rules</h3>
|
||||
* <ol>
|
||||
* <li>Purification capacity is derived from current vegetation pressure
|
||||
* (plants filter water).
|
||||
* <li>Low soil quality allows contamination to leach into groundwater,
|
||||
* reducing water quality.
|
||||
* <li>Purification capacity partially restores water quality each tick.
|
||||
* <li>Water availability drifts toward a baseline unless stressed by
|
||||
* drought or flood conditions (V1 placeholder).
|
||||
* </ol>
|
||||
*/
|
||||
public final class WaterModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "water";
|
||||
|
||||
/** Vegetation pressure drives this fraction of purification capacity. */
|
||||
private static final double VEG_TO_PURIFICATION_FACTOR = 0.50;
|
||||
/** Each point of soil quality below 40 leaches this much water quality per tick. */
|
||||
private static final double SOIL_LEACH_RATE = 0.005;
|
||||
/** Soil quality threshold below which contamination leaches into water. */
|
||||
private static final double SOIL_LEACH_THRESHOLD = 40.0;
|
||||
/** Fraction of purification capacity applied to water quality per tick. */
|
||||
private static final double PURIFICATION_RATE = 0.01;
|
||||
private static final double CHANGE_THRESHOLD = 0.01;
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"Water Quality",
|
||||
"1.0.0",
|
||||
"Refines water quality with vegetation purification and soil contamination leaching.",
|
||||
"1",
|
||||
List.of("pollution", "soil"),
|
||||
List.of("vegetation"),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
@Override
|
||||
public String getModuleId() { return MODULE_ID; }
|
||||
|
||||
@Override
|
||||
public ModuleMetadata getMetadata() { return METADATA; }
|
||||
|
||||
@Override
|
||||
public void initialize(ModuleContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerStarted(ServerContext context) {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||
region.getModuleData().put(MODULE_ID, WaterRegionData.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
|
||||
Region region = context.getRegion();
|
||||
WaterRegionData data = region.getModuleData()
|
||||
.get(MODULE_ID, WaterRegionData.class)
|
||||
.orElseGet(WaterRegionData::defaults);
|
||||
RegionMetrics metrics = region.getMetrics();
|
||||
|
||||
double prevWaterQuality = metrics.getWaterQuality();
|
||||
|
||||
// Purification capacity is driven by vegetation cover.
|
||||
double purification = metrics.getVegetationPressure() * VEG_TO_PURIFICATION_FACTOR;
|
||||
data.setPurificationCapacity(purification);
|
||||
|
||||
// Low soil quality allows contamination to leach into groundwater.
|
||||
if (metrics.getSoilQuality() < SOIL_LEACH_THRESHOLD) {
|
||||
double leach = (SOIL_LEACH_THRESHOLD - metrics.getSoilQuality()) * SOIL_LEACH_RATE;
|
||||
metrics.setWaterQuality(Math.max(0.0, metrics.getWaterQuality() - leach));
|
||||
}
|
||||
|
||||
// Vegetation purification partially restores water quality.
|
||||
double recovery = purification * PURIFICATION_RATE;
|
||||
metrics.setWaterQuality(Math.min(100.0, metrics.getWaterQuality() + recovery));
|
||||
|
||||
region.getModuleData().put(MODULE_ID, data);
|
||||
|
||||
boolean changed = Math.abs(metrics.getWaterQuality() - prevWaterQuality) > CHANGE_THRESHOLD;
|
||||
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
|
||||
@Override
|
||||
public void saveModuleData(PersistenceWriter writer) {}
|
||||
|
||||
@Override
|
||||
public void loadModuleData(PersistenceReader reader) {}
|
||||
|
||||
@Override
|
||||
public void shutdown() {}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.livingworld.modules.water;
|
||||
|
||||
/**
|
||||
* Per-region water state tracked by {@link WaterModule}.
|
||||
*
|
||||
* <p>All values are clamped to [0, 100].
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>waterAvailability</b> – how much fresh water exists in this region
|
||||
* <li><b>purificationCapacity</b> – ecosystem's ability to filter water;
|
||||
* driven by vegetation cover
|
||||
* <li><b>droughtRisk</b> – likelihood of water shortage conditions
|
||||
* <li><b>floodRisk</b> – likelihood of waterlogging or runoff damage
|
||||
* </ul>
|
||||
*/
|
||||
public final class WaterRegionData {
|
||||
|
||||
private static final double MIN = 0.0;
|
||||
private static final double MAX = 100.0;
|
||||
|
||||
private double waterAvailability;
|
||||
private double purificationCapacity;
|
||||
private double droughtRisk;
|
||||
private double floodRisk;
|
||||
|
||||
public WaterRegionData(
|
||||
double waterAvailability,
|
||||
double purificationCapacity,
|
||||
double droughtRisk,
|
||||
double floodRisk) {
|
||||
this.waterAvailability = clamp(waterAvailability);
|
||||
this.purificationCapacity = clamp(purificationCapacity);
|
||||
this.droughtRisk = clamp(droughtRisk);
|
||||
this.floodRisk = clamp(floodRisk);
|
||||
}
|
||||
|
||||
/** Returns a healthy default water profile. */
|
||||
public static WaterRegionData defaults() {
|
||||
return new WaterRegionData(60.0, 50.0, 10.0, 10.0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Getters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public double getWaterAvailability() { return waterAvailability; }
|
||||
public double getPurificationCapacity() { return purificationCapacity; }
|
||||
public double getDroughtRisk() { return droughtRisk; }
|
||||
public double getFloodRisk() { return floodRisk; }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Setters (clamp on write)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public void setWaterAvailability(double v) { waterAvailability = clamp(v); }
|
||||
public void setPurificationCapacity(double v) { purificationCapacity = clamp(v); }
|
||||
public void setDroughtRisk(double v) { droughtRisk = clamp(v); }
|
||||
public void setFloodRisk(double v) { floodRisk = clamp(v); }
|
||||
|
||||
/** Clamps all fields to [0, 100]. */
|
||||
public void normalize() {
|
||||
waterAvailability = clamp(waterAvailability);
|
||||
purificationCapacity = clamp(purificationCapacity);
|
||||
droughtRisk = clamp(droughtRisk);
|
||||
floodRisk = clamp(floodRisk);
|
||||
}
|
||||
|
||||
/** Returns an independent copy. */
|
||||
public WaterRegionData copy() {
|
||||
return new WaterRegionData(waterAvailability, purificationCapacity, droughtRisk, floodRisk);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static double clamp(double v) {
|
||||
return Math.min(MAX, Math.max(MIN, v));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "WaterRegionData{"
|
||||
+ "availability=" + waterAvailability
|
||||
+ ", purification=" + purificationCapacity
|
||||
+ ", drought=" + droughtRisk
|
||||
+ ", flood=" + floodRisk + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.livingworld.modules.worldeffects;
|
||||
|
||||
/**
|
||||
* Receives {@link WorldEffectRequest}s generated by {@link WorldEffectsModule}
|
||||
* and applies the corresponding changes to the world.
|
||||
*
|
||||
* <p>The NeoForge platform adapter registers itself as a consumer during bootstrap.
|
||||
* Tests register a capturing consumer to verify which effects were requested.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface WorldEffectConsumer {
|
||||
|
||||
/** Called when the ecosystem simulation wants a visible world change applied. */
|
||||
void consume(WorldEffectRequest request);
|
||||
|
||||
/** A no-op consumer used when no platform adapter is registered. */
|
||||
WorldEffectConsumer NO_OP = request -> {};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.livingworld.modules.worldeffects;
|
||||
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
|
||||
/**
|
||||
* An immutable request for a visible world change in a specific region.
|
||||
*
|
||||
* <p>Generated by {@link WorldEffectsModule} and delivered to registered
|
||||
* {@link WorldEffectConsumer}s. The platform layer (NeoForge adapter) is
|
||||
* responsible for translating the request into actual block operations on
|
||||
* loaded chunks.
|
||||
*
|
||||
* @param type the category of effect to apply
|
||||
* @param region the region in which the effect should be applied
|
||||
* @param intensity how strongly to apply the effect (0.0 = minimal, 1.0 = full)
|
||||
*/
|
||||
public record WorldEffectRequest(
|
||||
WorldEffectType type,
|
||||
RegionCoordinate region,
|
||||
double intensity) {
|
||||
|
||||
public WorldEffectRequest {
|
||||
if (type == null) throw new IllegalArgumentException("type must not be null");
|
||||
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||
if (intensity < 0.0 || intensity > 1.0) {
|
||||
throw new IllegalArgumentException(
|
||||
"intensity must be in [0.0, 1.0], got: " + intensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.livingworld.modules.worldeffects;
|
||||
|
||||
/**
|
||||
* Categories of visible world changes that the ecosystem simulation can request.
|
||||
*
|
||||
* <p>These are abstract descriptions of what should happen. The platform adapter
|
||||
* (NeoForge layer) translates each type into concrete block changes or entity
|
||||
* interactions on loaded chunks.
|
||||
*/
|
||||
public enum WorldEffectType {
|
||||
|
||||
/**
|
||||
* High pollution combined with low soil quality causes grass blocks to
|
||||
* degrade into dirt or coarse dirt.
|
||||
*/
|
||||
GRASS_DEGRADES_TO_DIRT,
|
||||
|
||||
/**
|
||||
* Healthy soil and good vegetation pressure allows grass, moss, and flowers
|
||||
* to spread onto adjacent bare blocks.
|
||||
*/
|
||||
VEGETATION_SPREADS,
|
||||
|
||||
/**
|
||||
* Sustained logging depletion slows the chance of saplings spawning from
|
||||
* leaf decay and reduces natural sapling growth rate.
|
||||
*/
|
||||
SAPLING_GROWTH_SLOWED,
|
||||
|
||||
/**
|
||||
* A well-recovered region at an advanced succession stage boosts the
|
||||
* chance of saplings appearing and growing.
|
||||
*/
|
||||
SAPLING_GROWTH_BOOSTED,
|
||||
|
||||
/**
|
||||
* A visual tint or overlay indicator applied to blocks in heavily polluted
|
||||
* regions (e.g. discoloured water, dark soil). Implementation details are
|
||||
* left to the platform layer.
|
||||
*/
|
||||
POLLUTION_VISUAL_INDICATOR,
|
||||
|
||||
/**
|
||||
* Drought combined with a storm triggers wildfire ignition — fire is placed
|
||||
* on flammable surface blocks and spreads naturally via Minecraft fire tick.
|
||||
*/
|
||||
WILDFIRE,
|
||||
|
||||
/**
|
||||
* Sustained bad conditions (low soil quality or high pollution) cause living
|
||||
* vegetation to die visibly: leaves are removed, upper log sections stripped to
|
||||
* stumps, and surface plants replaced with dead material.
|
||||
*/
|
||||
VEGETATION_DIES,
|
||||
|
||||
/**
|
||||
* Heavy regional rain on low-succession (barren / sparse-grass) terrain causes
|
||||
* water to pool in low-lying spots. The platform adapter places water source blocks
|
||||
* in surface depressions. Once present, the water body scan detects them and feeds
|
||||
* 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,
|
||||
|
||||
/**
|
||||
* A groundwater spring emerges at the valley floor: a water source block is placed
|
||||
* at the lowest natural terrain point in the region. The water then flows downhill
|
||||
* under Minecraft's own fluid physics, forming a permanent river without relying on rain.
|
||||
*/
|
||||
GROUND_SPRING_EMERGES,
|
||||
|
||||
/**
|
||||
* Flowing water adjacent to soft terrain gradually softens and removes it: stone
|
||||
* becomes gravel, gravel becomes sand, sand and dirt are removed. Widens river
|
||||
* valleys and carves gorges independently of rainfall.
|
||||
*/
|
||||
HYDRAULIC_EROSION,
|
||||
|
||||
/**
|
||||
* Active volcanic eruption: lava source blocks are placed at the summit and on
|
||||
* natural rock surfaces. Lava flows downhill under Minecraft physics, permanently
|
||||
* reshaping the terrain around the volcano.
|
||||
*/
|
||||
LAVA_FLOW,
|
||||
|
||||
/**
|
||||
* Volcanic ash clouds deposit a grey layer over the surrounding landscape: grass
|
||||
* and soil surfaces are covered with tuff and gravel, surface plants are killed.
|
||||
* Spreads outward from the eruption epicentre.
|
||||
*/
|
||||
ASH_DEPOSIT,
|
||||
|
||||
/**
|
||||
* Cooling lava solidifies into new land: lava adjacent to water converts to
|
||||
* cobblestone, lava exposed to air converts to basalt. Creates permanent new
|
||||
* terrain that was not there before the eruption.
|
||||
*/
|
||||
COBBLESTONE_FORMS,
|
||||
|
||||
/**
|
||||
* Submarine volcanic eruption builds new land upward from the seafloor: basalt and
|
||||
* cobblestone are stacked on the ocean floor, gradually rising through the water column.
|
||||
* Over many eruption cycles the seamount breaks the surface and forms a new island.
|
||||
*/
|
||||
OCEAN_VOLCANIC_BUILDUP,
|
||||
|
||||
/** Healthy shallow ocean terrain develops living coral blocks and fans. */
|
||||
CORAL_GROWTH,
|
||||
|
||||
/** Polluted reefs bleach into dead coral or disappear at extreme pollution. */
|
||||
CORAL_BLEACHING,
|
||||
|
||||
/** Cold, clean ocean terrain grows kelp columns from the seafloor. */
|
||||
KELP_GROWTH,
|
||||
|
||||
/** Polluted ocean terrain loses kelp and other fragile aquatic plants. */
|
||||
KELP_DIE,
|
||||
|
||||
/** Nutrient pollution creates a visible algae bloom near the ocean surface. */
|
||||
ALGAE_BLOOM,
|
||||
|
||||
/** A collapsed bloom leaves a sedimented, oxygen-poor dead zone. */
|
||||
DEAD_ZONE,
|
||||
|
||||
/** Volcanic seafloor activity creates a permanent bubbling vent field. */
|
||||
HYDROTHERMAL_VENT,
|
||||
|
||||
/** Polluted rainfall weathers exposed rock and kills surface vegetation. */
|
||||
ACID_RAIN_DAMAGE,
|
||||
|
||||
/** Blizzard snowfall builds bounded snow layers on exposed terrain. */
|
||||
SNOW_ACCUMULATION,
|
||||
|
||||
/** Blizzard conditions freeze exposed standing water. */
|
||||
WATER_FREEZES,
|
||||
|
||||
/** Warm conditions remove accumulated seasonal snow one layer at a time. */
|
||||
SNOW_MELTS,
|
||||
|
||||
/** Dry high winds deposit sand and strip fragile surface plants. */
|
||||
SAND_DEPOSIT,
|
||||
|
||||
/** A registered geothermal site ejects a temporary column of water. */
|
||||
GEYSER_ERUPT,
|
||||
|
||||
/** Repeated geyser eruptions build a calcite and tuff travertine ring. */
|
||||
TRAVERTINE_DEPOSIT,
|
||||
|
||||
/** Low-magnitude seismic activity removes a short line of surface blocks. */
|
||||
GROUND_CRACK,
|
||||
|
||||
/** High-magnitude seismic activity opens a narrow, deep fissure. */
|
||||
FISSURE_OPENS,
|
||||
|
||||
/** Saturated ground collapses into a detected shallow cave. */
|
||||
SINKHOLE_COLLAPSES,
|
||||
|
||||
/** Unstable steep terrain transfers loose surface material downhill. */
|
||||
LANDSLIDE,
|
||||
|
||||
/** A cooling volcanic roof collapses to reveal a lava-adjacent cavity. */
|
||||
LAVA_TUBE_COLLAPSE,
|
||||
|
||||
/** A temporary surge places water along a low terrain path. */
|
||||
FLASH_FLOOD,
|
||||
|
||||
/** Seasonal thaw converts exposed ice back into water. */
|
||||
ICE_MELTS,
|
||||
|
||||
/** Sustained drought drains exposed river sources and hardens the bed. */
|
||||
RIVERBED_DRIES,
|
||||
|
||||
/** River sediment accumulates below sea level at an ocean mouth. */
|
||||
SEDIMENT_DEPOSIT,
|
||||
|
||||
/** A glacier toe pushes loose rock downhill. */
|
||||
GLACIER_ADVANCE,
|
||||
|
||||
/** Moving ice leaves polished andesite behind. */
|
||||
GLACIER_POLISH,
|
||||
|
||||
/** Falling river water deepens its impact basin. */
|
||||
PLUNGE_POOL_DEEPENS,
|
||||
|
||||
/** Waterlogged wetland soil slowly develops a persistent peat profile. */
|
||||
PEAT_FORMS,
|
||||
|
||||
/** Deciduous leaves enter a temporary autumn colour stage. */
|
||||
LEAVES_CHANGE_COLOUR,
|
||||
|
||||
/** Coloured deciduous leaves fall after the seasonal display. */
|
||||
LEAVES_FALL,
|
||||
|
||||
/** Exposed drought-baked dirt develops an impermeable clay crust. */
|
||||
SOIL_CRUSTS,
|
||||
|
||||
/** Heavy rain breaks clay crust back through gravel toward soil. */
|
||||
SOIL_CRUST_BREAKS,
|
||||
|
||||
/** Warming frozen subsoil collapses into wet mud and meltwater. */
|
||||
PERMAFROST_THAWS,
|
||||
|
||||
/** Deforested runoff replaces river sand with coarse gravel silt. */
|
||||
RIVER_SILTS,
|
||||
|
||||
/** Exhausted farmland fails into coarse dirt and loses its crop. */
|
||||
CROPLAND_EXHAUSTS,
|
||||
|
||||
/** Saturated groundwater enters a natural cave opening. */
|
||||
CAVE_FLOODS,
|
||||
|
||||
/** Volcanic mineralisation buries old ore and exposes a new shallow vein. */
|
||||
VEIN_SHIFTS,
|
||||
|
||||
/** Wet dripstone cave ceilings grow a pointed stalactite segment. */
|
||||
STALACTITE_GROWS,
|
||||
|
||||
/** Long-lived Nether portal influence converts nearby surface terrain. */
|
||||
NETHER_BLEED,
|
||||
|
||||
/** End portal influence introduces end stone, chorus growth and obsidian. */
|
||||
END_CORRUPTION,
|
||||
|
||||
/** Erosion or earthquakes uncover a recognized buried structure. */
|
||||
RUINS_EXPOSED,
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.livingworld.modules.worldeffects;
|
||||
|
||||
import com.livingworld.data.serialization.PersistenceReader;
|
||||
import com.livingworld.data.serialization.PersistenceWriter;
|
||||
import com.livingworld.events.LivingWorldEvent;
|
||||
import com.livingworld.modules.ModuleContext;
|
||||
import com.livingworld.modules.ModuleMetadata;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.ServerContext;
|
||||
import com.livingworld.modules.SimulationModule;
|
||||
import com.livingworld.modules.atmosphere.AtmosphereModule;
|
||||
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryModule;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||
import com.livingworld.modules.resources.ResourceRegionData;
|
||||
import com.livingworld.modules.water.WaterModule;
|
||||
import com.livingworld.modules.water.WaterRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
/**
|
||||
* Translates ecosystem simulation state into visible world change requests.
|
||||
*
|
||||
* <p>This module runs <em>last</em> in the pipeline. It reads the fully-updated
|
||||
* state of all other modules and emits {@link WorldEffectRequest}s to registered
|
||||
* {@link WorldEffectConsumer}s. The consumers (typically the NeoForge platform
|
||||
* adapter) apply the requests as actual block changes on loaded chunks.
|
||||
*
|
||||
* <p>This module contains no Minecraft imports. The platform boundary lives
|
||||
* entirely within the consumer implementations.
|
||||
*
|
||||
* <h3>Visible effects generated</h3>
|
||||
* <ol>
|
||||
* <li>{@link WorldEffectType#GRASS_DEGRADES_TO_DIRT} — when pollutionScore > 60
|
||||
* and soilQuality < 30.
|
||||
* <li>{@link WorldEffectType#VEGETATION_SPREADS} — when vegetationPressure > 60
|
||||
* and soilQuality > 50.
|
||||
* <li>{@link WorldEffectType#SAPLING_GROWTH_SLOWED} — when logging depletion
|
||||
* exceeds 50 % (read from {@link ResourceRegionData}).
|
||||
* <li>{@link WorldEffectType#SAPLING_GROWTH_BOOSTED} — when the succession stage
|
||||
* is {@link SuccessionStage#YOUNG_WOODLAND} or higher.
|
||||
* <li>{@link WorldEffectType#POLLUTION_VISUAL_INDICATOR} — when pollutionScore > 70.
|
||||
* </ol>
|
||||
*
|
||||
* <h3>Registering consumers</h3>
|
||||
* Call {@link #registerConsumer(WorldEffectConsumer)} before the simulation starts.
|
||||
* Multiple consumers may be registered. If no consumer is registered the module
|
||||
* operates silently as a no-op.
|
||||
*/
|
||||
public final class WorldEffectsModule implements SimulationModule {
|
||||
|
||||
public static final String MODULE_ID = "worldeffects";
|
||||
|
||||
// Thresholds that trigger effect requests.
|
||||
// Grass degradation: pollution score > 15 AND soil quality below the gate.
|
||||
// Soil defaults to 60 so the gate is set at 75 to avoid blocking early effects.
|
||||
private static final double GRASS_DEGRADE_POLLUTION_MIN = 15.0;
|
||||
private static final double GRASS_DEGRADE_SOIL_MAX = 75.0;
|
||||
// Vegetation die-off: mirrors VegetationModule's bad-conditions thresholds.
|
||||
private static final double DIEOFF_SOIL_MAX = 20.0;
|
||||
private static final double DIEOFF_POLLUTION_MIN = 30.0;
|
||||
private static final double VEG_SPREAD_VEG_MIN = 60.0;
|
||||
private static final double VEG_SPREAD_SOIL_MIN = 50.0;
|
||||
private static final double SAPLING_SLOW_LOGGING_MIN = 50.0;
|
||||
// Smoke particles appear as soon as any meaningful pollution exists.
|
||||
private static final double POLLUTION_INDICATOR_MIN = 10.0;
|
||||
// Wildfire: lightning storm over a drought-stressed region.
|
||||
private static final double WILDFIRE_DROUGHT_MIN = 70.0;
|
||||
private static final double WILDFIRE_THUNDER_MIN = 0.5;
|
||||
private static final double WILDFIRE_CHANCE_PER_CYCLE = 0.01; // 1 % per sim cycle
|
||||
|
||||
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||
MODULE_ID,
|
||||
"World Effects",
|
||||
"1.0.0",
|
||||
"Translates ecosystem state into visible block-change requests for the platform layer.",
|
||||
"1",
|
||||
List.of("pollution", "soil", "vegetation", "resources", "recovery"),
|
||||
List.of(),
|
||||
true,
|
||||
true,
|
||||
false);
|
||||
|
||||
private final List<WorldEffectConsumer> consumers = new ArrayList<>();
|
||||
private final Random wildfireRandom = new Random();
|
||||
|
||||
/**
|
||||
* Registers a consumer that will receive effect requests each simulation tick.
|
||||
*
|
||||
* @param consumer the consumer to register (must not be null)
|
||||
*/
|
||||
public void registerConsumer(WorldEffectConsumer consumer) {
|
||||
if (consumer == null) throw new IllegalArgumentException("consumer must not be null");
|
||||
consumers.add(consumer);
|
||||
}
|
||||
|
||||
/** Returns an unmodifiable view of all registered consumers. */
|
||||
public List<WorldEffectConsumer> getConsumers() {
|
||||
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; }
|
||||
|
||||
@Override
|
||||
public ModuleMetadata getMetadata() { return METADATA; }
|
||||
|
||||
@Override
|
||||
public void initialize(ModuleContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerStarted(ServerContext context) {}
|
||||
|
||||
@Override
|
||||
public void createDefaultRegionData(Region region) {
|
||||
// No per-region data; this module only reads state, it does not persist any.
|
||||
}
|
||||
|
||||
@Override
|
||||
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||
if (consumers.isEmpty()) return ModuleUpdateResult.noChange();
|
||||
|
||||
Region region = context.getRegion();
|
||||
RegionMetrics m = region.getMetrics();
|
||||
boolean emitted = false;
|
||||
|
||||
// --- Effect 1: grass degrades when pollution is high and soil is poor ---
|
||||
if (m.getPollutionScore() > GRASS_DEGRADE_POLLUTION_MIN
|
||||
&& m.getSoilQuality() < GRASS_DEGRADE_SOIL_MAX) {
|
||||
double intensity = computeIntensity(
|
||||
m.getPollutionScore() - GRASS_DEGRADE_POLLUTION_MIN, 40.0)
|
||||
* computeIntensity(GRASS_DEGRADE_SOIL_MAX - m.getSoilQuality(), 30.0);
|
||||
emit(new WorldEffectRequest(
|
||||
WorldEffectType.GRASS_DEGRADES_TO_DIRT, region.getCoordinate(), intensity));
|
||||
emitted = true;
|
||||
}
|
||||
|
||||
// --- Effect 2: vegetation spreads when soil and vegetation pressure are healthy ---
|
||||
if (m.getVegetationPressure() > VEG_SPREAD_VEG_MIN
|
||||
&& m.getSoilQuality() > VEG_SPREAD_SOIL_MIN) {
|
||||
double intensity = computeIntensity(
|
||||
m.getVegetationPressure() - VEG_SPREAD_VEG_MIN, 40.0);
|
||||
emit(new WorldEffectRequest(
|
||||
WorldEffectType.VEGETATION_SPREADS, region.getCoordinate(), intensity));
|
||||
emitted = true;
|
||||
}
|
||||
|
||||
// --- Effect 3: logging depletion slows sapling growth ---
|
||||
ResourceRegionData resources = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
|
||||
.orElse(null);
|
||||
if (resources != null && resources.getLoggingDepletion() > SAPLING_SLOW_LOGGING_MIN) {
|
||||
double intensity = computeIntensity(
|
||||
resources.getLoggingDepletion() - SAPLING_SLOW_LOGGING_MIN, 50.0);
|
||||
emit(new WorldEffectRequest(
|
||||
WorldEffectType.SAPLING_GROWTH_SLOWED, region.getCoordinate(), intensity));
|
||||
emitted = true;
|
||||
}
|
||||
|
||||
// --- Effect 4: advanced succession boosts sapling growth ---
|
||||
RecoveryRegionData recovery = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
|
||||
.orElse(null);
|
||||
if (recovery != null
|
||||
&& recovery.getSuccessionStage().ordinal()
|
||||
>= SuccessionStage.YOUNG_WOODLAND.ordinal()) {
|
||||
double intensity = computeIntensity(
|
||||
recovery.getSuccessionStage().ordinal()
|
||||
- SuccessionStage.YOUNG_WOODLAND.ordinal() + 1.0, 2.0);
|
||||
emit(new WorldEffectRequest(
|
||||
WorldEffectType.SAPLING_GROWTH_BOOSTED, region.getCoordinate(), intensity));
|
||||
emitted = true;
|
||||
}
|
||||
|
||||
// --- Effect 5: pollution visual indicator ---
|
||||
if (m.getPollutionScore() > POLLUTION_INDICATOR_MIN) {
|
||||
double intensity = computeIntensity(
|
||||
m.getPollutionScore() - POLLUTION_INDICATOR_MIN, 30.0);
|
||||
emit(new WorldEffectRequest(
|
||||
WorldEffectType.POLLUTION_VISUAL_INDICATOR, region.getCoordinate(), intensity));
|
||||
emitted = true;
|
||||
}
|
||||
|
||||
// --- Effect 6: wildfire — drought + storm ignites surface vegetation ---
|
||||
AtmosphereRegionData atm = region.getModuleData()
|
||||
.get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class)
|
||||
.orElse(null);
|
||||
WaterRegionData water = region.getModuleData()
|
||||
.get(WaterModule.MODULE_ID, WaterRegionData.class)
|
||||
.orElse(null);
|
||||
if (atm != null && water != null
|
||||
&& water.getDroughtRisk() > WILDFIRE_DROUGHT_MIN
|
||||
&& atm.getThunderLevel() > WILDFIRE_THUNDER_MIN
|
||||
&& wildfireRandom.nextDouble() < WILDFIRE_CHANCE_PER_CYCLE) {
|
||||
double intensity = computeIntensity(atm.getThunderLevel() - WILDFIRE_THUNDER_MIN, 0.5)
|
||||
* computeIntensity(water.getDroughtRisk() - WILDFIRE_DROUGHT_MIN, 30.0);
|
||||
emit(new WorldEffectRequest(
|
||||
WorldEffectType.WILDFIRE, region.getCoordinate(), Math.max(0.1, intensity)));
|
||||
emitted = true;
|
||||
}
|
||||
|
||||
// --- Effect 7: vegetation dies under bad soil or heavy pollution ---
|
||||
// Mirrors the bad-conditions gate in VegetationModule so block changes
|
||||
// stay in sync with the data-layer die-off.
|
||||
boolean badSoil = m.getSoilQuality() < DIEOFF_SOIL_MAX;
|
||||
boolean badPollution = m.getPollutionScore() > DIEOFF_POLLUTION_MIN;
|
||||
if (badSoil || badPollution) {
|
||||
double intensitySoil = badSoil
|
||||
? computeIntensity(DIEOFF_SOIL_MAX - m.getSoilQuality(), DIEOFF_SOIL_MAX)
|
||||
: 0.0;
|
||||
double intensityPoll = badPollution
|
||||
? computeIntensity(m.getPollutionScore() - DIEOFF_POLLUTION_MIN, 70.0)
|
||||
: 0.0;
|
||||
double dieoffIntensity = Math.max(intensitySoil, intensityPoll);
|
||||
emit(new WorldEffectRequest(
|
||||
WorldEffectType.VEGETATION_DIES, region.getCoordinate(),
|
||||
Math.max(0.1, dieoffIntensity)));
|
||||
emitted = true;
|
||||
}
|
||||
|
||||
// --- Effect 8: rain pools in arid low-succession terrain ---
|
||||
// Heavy regional rain on barren/sparse-grass land cannot drain into vegetation;
|
||||
// water collects in depressions, forming puddles visible as actual water blocks.
|
||||
if (recovery != null && atm != null
|
||||
&& recovery.getSuccessionStage().ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal()
|
||||
&& atm.getRainLevel() > 0.3) {
|
||||
double intensity = computeIntensity(atm.getRainLevel() - 0.3, 0.5);
|
||||
emit(new WorldEffectRequest(
|
||||
WorldEffectType.WATER_POOL_FORMS, region.getCoordinate(), Math.max(0.1, intensity)));
|
||||
emitted = true;
|
||||
}
|
||||
|
||||
return emitted ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||
|
||||
@Override
|
||||
public void saveModuleData(PersistenceWriter writer) {}
|
||||
|
||||
@Override
|
||||
public void loadModuleData(PersistenceReader reader) {}
|
||||
|
||||
@Override
|
||||
public void shutdown() { consumers.clear(); }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Computes effect intensity as a fraction of the excess over a range, clamped to [0, 1]. */
|
||||
private static double computeIntensity(double excess, double range) {
|
||||
return Math.min(1.0, Math.max(0.0, excess / range));
|
||||
}
|
||||
|
||||
private void emit(WorldEffectRequest request) {
|
||||
for (WorldEffectConsumer consumer : consumers) {
|
||||
consumer.consume(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.livingworld.platform;
|
||||
|
||||
/**
|
||||
* Platform-neutral description of a block broken by a player.
|
||||
*
|
||||
* <p>The NeoForge adapter constructs this from a {@code BlockEvent.BreakEvent}
|
||||
* and passes it to the bootstrap handler so no Minecraft types cross the
|
||||
* platform boundary into core simulation code.</p>
|
||||
*
|
||||
* @param dimensionId Minecraft dimension key string (e.g. {@code "minecraft:overworld"})
|
||||
* @param blockX world X coordinate of the broken block
|
||||
* @param blockZ world Z coordinate of the broken block
|
||||
* @param blockRegistryName registry name of the broken block (e.g. {@code "minecraft:oak_log"})
|
||||
*/
|
||||
public record BlockBreakInfo(
|
||||
String dimensionId,
|
||||
int blockX,
|
||||
int blockZ,
|
||||
String blockRegistryName) {
|
||||
|
||||
public BlockBreakInfo {
|
||||
if (dimensionId == null || dimensionId.isBlank()) {
|
||||
throw new IllegalArgumentException("dimensionId must not be null or blank");
|
||||
}
|
||||
if (blockRegistryName == null || blockRegistryName.isBlank()) {
|
||||
throw new IllegalArgumentException("blockRegistryName must not be null or blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.livingworld.platform.neoforge;
|
||||
|
||||
import com.livingworld.config.EcosystemTuning;
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/**
|
||||
* NeoForge server-side TOML configuration for the Living World ecosystem simulation.
|
||||
*
|
||||
* <p>Registered via {@link net.neoforged.fml.ModContainer#registerConfig} with type
|
||||
* {@link net.neoforged.fml.config.ModConfig.Type#SERVER}. The resulting file is written to
|
||||
* {@code saves/<world>/serverconfig/livingworld-server.toml} and can be edited by
|
||||
* server admins to tune all ecosystem rates without recompiling.
|
||||
*
|
||||
* <p>Call {@link #createTuning()} after the server config has loaded (i.e. from
|
||||
* {@link net.neoforged.neoforge.event.server.ServerStartingEvent}) to obtain an
|
||||
* {@link EcosystemTuning} populated with the current config values.
|
||||
*/
|
||||
public final class NeoForgeModConfig {
|
||||
|
||||
public static final ModConfigSpec SPEC;
|
||||
|
||||
// -- pollution --
|
||||
private static final ModConfigSpec.DoubleValue POLLUTION_DECAY_RATE;
|
||||
private static final ModConfigSpec.DoubleValue GROUND_TO_WATER_LEACH;
|
||||
private static final ModConfigSpec.DoubleValue POLLUTION_SPREAD_RATE;
|
||||
private static final ModConfigSpec.DoubleValue WIND_BOOST;
|
||||
|
||||
// -- vegetation growth --
|
||||
private static final ModConfigSpec.DoubleValue GRASS_GROWTH_RATE;
|
||||
private static final ModConfigSpec.DoubleValue FLOWER_GROWTH_RATE;
|
||||
private static final ModConfigSpec.DoubleValue SHRUB_GROWTH_RATE;
|
||||
private static final ModConfigSpec.DoubleValue TREE_GROWTH_RATE;
|
||||
|
||||
// -- vegetation die-off --
|
||||
private static final ModConfigSpec.DoubleValue GRASS_DIEOFF_RATE;
|
||||
private static final ModConfigSpec.DoubleValue FLOWER_DIEOFF_RATE;
|
||||
private static final ModConfigSpec.DoubleValue SHRUB_DIEOFF_RATE;
|
||||
private static final ModConfigSpec.DoubleValue TREE_DIEOFF_RATE;
|
||||
|
||||
// -- recovery succession --
|
||||
private static final ModConfigSpec.DoubleValue BASE_PROGRESS_PER_TICK;
|
||||
private static final ModConfigSpec.DoubleValue DAMAGE_PER_BAD_TICK;
|
||||
private static final ModConfigSpec.DoubleValue DAMAGE_DECAY_RATE;
|
||||
|
||||
// -- seed dispersal --
|
||||
private static final ModConfigSpec.DoubleValue SEED_EMISSION_RATE;
|
||||
private static final ModConfigSpec.DoubleValue CORRIDOR_BOOST_MULTIPLIER;
|
||||
private static final ModConfigSpec.DoubleValue SEED_POLLUTION_BLOCK;
|
||||
private static final ModConfigSpec.IntValue SEA_LEVEL;
|
||||
|
||||
static {
|
||||
ModConfigSpec.Builder b = new ModConfigSpec.Builder();
|
||||
|
||||
b.comment("Pollution dynamics").push("pollution");
|
||||
POLLUTION_DECAY_RATE = b
|
||||
.comment("Fraction of pollution removed per tick. Lower = stickier pollution. Default: 0.002")
|
||||
.defineInRange("decay_rate", 0.002, 0.0001, 0.5);
|
||||
GROUND_TO_WATER_LEACH = b
|
||||
.comment("Fraction of ground pollution that leaches into water each tick. Default: 0.005")
|
||||
.defineInRange("ground_to_water_leach", 0.005, 0.0, 0.1);
|
||||
POLLUTION_SPREAD_RATE = b
|
||||
.comment("Base rate at which air pollution diffuses to adjacent regions. Default: 0.02")
|
||||
.defineInRange("spread_rate", 0.02, 0.0, 0.5);
|
||||
WIND_BOOST = b
|
||||
.comment("Downwind spread multiplier (0 = no wind bias, 2 = strong directional drift). Default: 0.5")
|
||||
.defineInRange("wind_boost", 0.5, 0.0, 2.0);
|
||||
b.pop();
|
||||
|
||||
b.comment("Long-timescale hydrology").push("hydrology");
|
||||
SEA_LEVEL = b
|
||||
.comment("Baseline simulated sea level. Long climate cycles may drift this by +/-3 blocks.")
|
||||
.defineInRange("sea_level", 62, 1, 320);
|
||||
b.pop();
|
||||
|
||||
b.comment("Vegetation growth rates (per soil-quality unit above threshold per tick)").push("vegetation");
|
||||
GRASS_GROWTH_RATE = b.comment("Grass growth rate. Default: 0.06")
|
||||
.defineInRange("grass_growth", 0.06, 0.001, 2.0);
|
||||
FLOWER_GROWTH_RATE = b.comment("Flower growth rate. Default: 0.03")
|
||||
.defineInRange("flower_growth", 0.03, 0.001, 2.0);
|
||||
SHRUB_GROWTH_RATE = b.comment("Shrub growth rate. Default: 0.02")
|
||||
.defineInRange("shrub_growth", 0.02, 0.001, 2.0);
|
||||
TREE_GROWTH_RATE = b.comment("Tree growth rate. Default: 0.008")
|
||||
.defineInRange("tree_growth", 0.008, 0.001, 2.0);
|
||||
GRASS_DIEOFF_RATE = b.comment("Grass die-off flat reduction per bad tick. Default: 1.0")
|
||||
.defineInRange("grass_dieoff", 1.0, 0.0, 20.0);
|
||||
FLOWER_DIEOFF_RATE = b.comment("Flower die-off per bad tick. Default: 0.5")
|
||||
.defineInRange("flower_dieoff", 0.5, 0.0, 20.0);
|
||||
SHRUB_DIEOFF_RATE = b.comment("Shrub die-off per bad tick. Default: 0.25")
|
||||
.defineInRange("shrub_dieoff", 0.25, 0.0, 20.0);
|
||||
TREE_DIEOFF_RATE = b.comment("Tree die-off per bad tick (only under severe conditions). Default: 0.1")
|
||||
.defineInRange("tree_dieoff", 0.1, 0.0, 20.0);
|
||||
b.pop();
|
||||
|
||||
b.comment("Ecological succession and recovery").push("recovery");
|
||||
BASE_PROGRESS_PER_TICK = b
|
||||
.comment("Recovery progress added per tick under good conditions. Default: 2.0")
|
||||
.defineInRange("base_progress_per_tick", 2.0, 0.1, 50.0);
|
||||
DAMAGE_PER_BAD_TICK = b
|
||||
.comment("Ecological damage accumulated per tick under bad conditions. Default: 5.0")
|
||||
.defineInRange("damage_per_bad_tick", 5.0, 0.1, 100.0);
|
||||
DAMAGE_DECAY_RATE = b
|
||||
.comment("Fraction of accumulated damage that decays per tick when conditions are OK. Default: 0.03")
|
||||
.defineInRange("damage_decay_rate", 0.03, 0.001, 0.5);
|
||||
b.pop();
|
||||
|
||||
b.comment("Seed dispersal and ecological corridors").push("seed_dispersal");
|
||||
SEED_EMISSION_RATE = b
|
||||
.comment("Fraction of vegetation pressure emitted as seeds to each neighbour per tick (requires YOUNG_WOODLAND+). Default: 0.01")
|
||||
.defineInRange("emission_rate", 0.01, 0.0, 0.5);
|
||||
CORRIDOR_BOOST_MULTIPLIER = b
|
||||
.comment("Seed strength multiplier when the target region has 2+ healthy neighbours — the corridor effect. Default: 3.5")
|
||||
.defineInRange("corridor_boost", 3.5, 1.0, 20.0);
|
||||
SEED_POLLUTION_BLOCK = b
|
||||
.comment("Fraction of incoming seeds blocked per unit of pollution score in the target region. Default: 0.015")
|
||||
.defineInRange("pollution_block", 0.015, 0.0, 0.1);
|
||||
b.pop();
|
||||
|
||||
SPEC = b.build();
|
||||
}
|
||||
|
||||
private NeoForgeModConfig() {}
|
||||
|
||||
/** Creates an {@link EcosystemTuning} populated from the currently loaded config values. */
|
||||
public static EcosystemTuning createTuning() {
|
||||
EcosystemTuning t = new EcosystemTuning();
|
||||
t.setPollutionDecayRate(POLLUTION_DECAY_RATE.get());
|
||||
t.setGroundToWaterLeach(GROUND_TO_WATER_LEACH.get());
|
||||
t.setPollutionSpreadRate(POLLUTION_SPREAD_RATE.get());
|
||||
t.setWindBoost(WIND_BOOST.get());
|
||||
t.setGrassGrowthRate(GRASS_GROWTH_RATE.get());
|
||||
t.setFlowerGrowthRate(FLOWER_GROWTH_RATE.get());
|
||||
t.setShrubGrowthRate(SHRUB_GROWTH_RATE.get());
|
||||
t.setTreeGrowthRate(TREE_GROWTH_RATE.get());
|
||||
t.setGrassDieoffRate(GRASS_DIEOFF_RATE.get());
|
||||
t.setFlowerDieoffRate(FLOWER_DIEOFF_RATE.get());
|
||||
t.setShrubDieoffRate(SHRUB_DIEOFF_RATE.get());
|
||||
t.setTreeDieoffRate(TREE_DIEOFF_RATE.get());
|
||||
t.setBaseProgressPerTick(BASE_PROGRESS_PER_TICK.get());
|
||||
t.setDamagePerBadTick(DAMAGE_PER_BAD_TICK.get());
|
||||
t.setDamageDecayRate(DAMAGE_DECAY_RATE.get());
|
||||
t.setSeedEmissionRate(SEED_EMISSION_RATE.get());
|
||||
t.setCorridorBoostMultiplier(CORRIDOR_BOOST_MULTIPLIER.get());
|
||||
t.setSeedPollutionBlock(SEED_POLLUTION_BLOCK.get());
|
||||
t.setSeaLevel(SEA_LEVEL.get());
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.livingworld.platform.neoforge;
|
||||
|
||||
import com.livingworld.platform.BlockBreakInfo;
|
||||
import com.livingworld.platform.PlatformAdapter;
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import java.nio.file.Path;
|
||||
@@ -8,11 +9,14 @@ import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.fml.loading.FMLEnvironment;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/**
|
||||
@@ -25,17 +29,21 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
|
||||
private final Supplier<Path> worldSaveDirectory;
|
||||
private final Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar;
|
||||
private final Runnable serverTickHook;
|
||||
private final Consumer<BlockBreakInfo> blockBreakHandler;
|
||||
private boolean commandsRegistered;
|
||||
private boolean serverTickRegistered;
|
||||
private boolean playerEventsRegistered;
|
||||
|
||||
public NeoForgePlatformAdapter(
|
||||
Supplier<Path> worldSaveDirectory,
|
||||
Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar,
|
||||
Runnable serverTickHook) {
|
||||
Runnable serverTickHook,
|
||||
Consumer<BlockBreakInfo> blockBreakHandler) {
|
||||
this.worldSaveDirectory =
|
||||
Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory");
|
||||
this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar");
|
||||
this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook");
|
||||
this.blockBreakHandler = Objects.requireNonNull(blockBreakHandler, "blockBreakHandler");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,6 +100,39 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
|
||||
|
||||
@Override
|
||||
public void registerPlayerEventHooks() {
|
||||
// Player activity hooks are intentionally deferred until a module needs them.
|
||||
if (playerEventsRegistered) {
|
||||
return;
|
||||
}
|
||||
NeoForge.EVENT_BUS.addListener(BlockEvent.BreakEvent.class, this::onBlockBroken);
|
||||
playerEventsRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a NeoForge block-break event into a platform-neutral
|
||||
* {@link BlockBreakInfo} and forwards it to the core handler.
|
||||
*
|
||||
* <p>Only player-caused breaks are forwarded (non-null player). Creative-mode
|
||||
* breaks are included intentionally so creative players can still trigger
|
||||
* depletion for testing.</p>
|
||||
*/
|
||||
private void onBlockBroken(BlockEvent.BreakEvent event) {
|
||||
if (event.getPlayer() == null) {
|
||||
return;
|
||||
}
|
||||
if (!(event.getLevel() instanceof Level level)) {
|
||||
return;
|
||||
}
|
||||
String dimensionId = level.dimension().location().toString();
|
||||
ResourceLocation blockId = event.getState().getBlockHolder()
|
||||
.unwrapKey()
|
||||
.map(key -> key.location())
|
||||
.orElse(null);
|
||||
if (blockId == null) {
|
||||
return;
|
||||
}
|
||||
int blockX = event.getPos().getX();
|
||||
int blockZ = event.getPos().getZ();
|
||||
blockBreakHandler.accept(
|
||||
new BlockBreakInfo(dimensionId, blockX, blockZ, blockId.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ class LivingWorldBootstrapTest {
|
||||
assertTrue(bootstrap.getServices().isRegistered(CoreServices.TIME));
|
||||
assertTrue(bootstrap.getServices().isRegistered(CoreServices.DEBUG));
|
||||
|
||||
for (int tick = 0; tick < 100; tick++) {
|
||||
for (int tick = 0; tick < 50; tick++) {
|
||||
bootstrap.onServerTick();
|
||||
}
|
||||
TimeService timeService = bootstrap.getServices().get(CoreServices.TIME);
|
||||
|
||||
@@ -1,36 +1,79 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
|
||||
class RegionInfoFormatterTest {
|
||||
|
||||
@Test
|
||||
void includesIdentityLifecycleMetricsFlagsAndSortedModuleIds() {
|
||||
Region region = new RegionFactory().createNewRegion(
|
||||
private static Region region() {
|
||||
return new RegionFactory().createNewRegion(
|
||||
new RegionCoordinate("minecraft:overworld", -1, 2), 0);
|
||||
region.getMetrics().setPollutionScore(75);
|
||||
region.getFlags().setHasHighPollution(true);
|
||||
region.getModuleData().put("water", "data");
|
||||
region.getModuleData().put("soil", "data");
|
||||
}
|
||||
|
||||
List<String> lines = RegionInfoFormatter.format(region);
|
||||
@Test
|
||||
void headerContainsRegionId() {
|
||||
List<String> lines = RegionInfoFormatter.format(region());
|
||||
assertTrue(lines.get(0).contains("minecraft:overworld:-1:2"),
|
||||
"header should contain stable ID; got: " + lines.get(0));
|
||||
assertTrue(lines.get(0).contains("ACTIVE"), lines.get(0));
|
||||
}
|
||||
|
||||
assertEquals(5, lines.size());
|
||||
assertTrue(lines.get(0).contains("minecraft:overworld:-1:2"));
|
||||
assertTrue(lines.get(1).contains("ACTIVE"));
|
||||
assertTrue(lines.get(2).contains("pollution=75.0"));
|
||||
assertTrue(lines.get(3).contains("highPollution=true"));
|
||||
assertEquals("Module data: soil, water", lines.get(4));
|
||||
@Test
|
||||
void metricsLineContainsPollutionScore() {
|
||||
Region r = region();
|
||||
r.getMetrics().setPollutionScore(75);
|
||||
List<String> lines = RegionInfoFormatter.format(r);
|
||||
assertTrue(lines.stream().anyMatch(l -> l.contains("poll=75.0")),
|
||||
"metrics line should contain poll=75.0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsLineContainsHighPollution() {
|
||||
Region r = region();
|
||||
r.getFlags().setHasHighPollution(true);
|
||||
List<String> lines = RegionInfoFormatter.format(r);
|
||||
assertTrue(lines.stream().anyMatch(l -> l.contains("highPollution=true")),
|
||||
"flags line should contain highPollution=true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleDataSectionPresentForAllModules() {
|
||||
List<String> lines = RegionInfoFormatter.format(region());
|
||||
assertTrue(lines.stream().anyMatch(l -> l.contains("--- Module Data ---")));
|
||||
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" pollution:")));
|
||||
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" soil:")));
|
||||
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" water:")));
|
||||
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" vegetation:")));
|
||||
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" resources:")));
|
||||
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" recovery:")));
|
||||
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" ecosystem:")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleDataValuesShownWhenPresent() {
|
||||
Region r = region();
|
||||
r.getModuleData().put("pollution", new PollutionRegionData(33.0, 0.0, 0.0, 20.0));
|
||||
r.getModuleData().put("recovery",
|
||||
new RecoveryRegionData(SuccessionStage.YOUNG_WOODLAND, 50.0, 0.0));
|
||||
|
||||
List<String> lines = RegionInfoFormatter.format(r);
|
||||
assertTrue(lines.stream().anyMatch(l -> l.contains("air=33.0")));
|
||||
assertTrue(lines.stream().anyMatch(l -> l.contains("stage=YOUNG_WOODLAND")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataShownWhenModuleAbsent() {
|
||||
List<String> lines = RegionInfoFormatter.format(region());
|
||||
assertTrue(lines.stream().anyMatch(l -> l.contains("(no data)")));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -31,7 +31,7 @@ class SimulationConfigTest {
|
||||
@Test
|
||||
void defaultSimulationIntervalTicks() {
|
||||
final SimulationConfig config = new SimulationConfig();
|
||||
assertEquals(100, config.getSimulationIntervalTicks());
|
||||
assertEquals(50, config.getSimulationIntervalTicks());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -276,6 +276,6 @@ class SimulationConfigTest {
|
||||
final String result = config.toString();
|
||||
assertTrue(result.contains("SimulationConfig"));
|
||||
assertTrue(result.contains("regionSizeChunks=8"));
|
||||
assertTrue(result.contains("simulationIntervalTicks=100"));
|
||||
assertTrue(result.contains("simulationIntervalTicks=50"));
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import com.livingworld.modules.soil.SoilRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionModuleData;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
@@ -99,6 +104,77 @@ class FileRegionPersistenceServiceTest {
|
||||
assertEquals(0, restarted.getDirtyRegionCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripsModuleData() {
|
||||
FileRegionPersistenceService service = service();
|
||||
service.registerModuleCodec(
|
||||
"pollution",
|
||||
(data, w) -> {
|
||||
PollutionRegionData d = data.get("pollution", PollutionRegionData.class)
|
||||
.orElseGet(PollutionRegionData::defaults);
|
||||
w.writeDouble("airPollution", d.getAirPollution());
|
||||
w.writeDouble("groundPollution", d.getGroundPollution());
|
||||
w.writeDouble("waterPollution", d.getWaterPollution());
|
||||
w.writeDouble("decayResistance", d.getDecayResistance());
|
||||
},
|
||||
(r, data) -> data.put("pollution", new PollutionRegionData(
|
||||
r.readDouble("airPollution", 0.0),
|
||||
r.readDouble("groundPollution", 0.0),
|
||||
r.readDouble("waterPollution", 0.0),
|
||||
r.readDouble("decayResistance", 20.0))));
|
||||
service.registerModuleCodec(
|
||||
"soil",
|
||||
(data, w) -> {
|
||||
SoilRegionData d = data.get("soil", SoilRegionData.class)
|
||||
.orElseGet(SoilRegionData::defaults);
|
||||
w.writeDouble("fertility", d.getFertility());
|
||||
w.writeDouble("contamination", d.getContamination());
|
||||
},
|
||||
(r, data) -> data.put("soil", new SoilRegionData(
|
||||
r.readDouble("fertility", 60.0),
|
||||
r.readDouble("moisture", 50.0),
|
||||
r.readDouble("contamination", 0.0),
|
||||
r.readDouble("compaction", 10.0),
|
||||
r.readDouble("erosion", 0.0))));
|
||||
service.registerModuleCodec(
|
||||
"recovery",
|
||||
(data, w) -> {
|
||||
RecoveryRegionData d = data.get("recovery", RecoveryRegionData.class)
|
||||
.orElseGet(RecoveryRegionData::defaults);
|
||||
w.writeString("successionStage", d.getSuccessionStage().name());
|
||||
w.writeDouble("damageAccumulation", d.getDamageAccumulation());
|
||||
},
|
||||
(r, data) -> data.put("recovery", new RecoveryRegionData(
|
||||
SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
|
||||
r.readDouble("recoveryProgress", 0.0),
|
||||
r.readDouble("damageAccumulation", 0.0))));
|
||||
|
||||
Region original = new RegionFactory().createNewRegion(
|
||||
new RegionCoordinate("minecraft:overworld", 0, 0), 0);
|
||||
RegionModuleData moduleData = original.getModuleData();
|
||||
moduleData.put("pollution", new PollutionRegionData(42.0, 15.0, 8.0, 25.0));
|
||||
moduleData.put("soil", new SoilRegionData(75.0, 60.0, 5.0, 20.0, 3.0));
|
||||
moduleData.put("recovery", new RecoveryRegionData(SuccessionStage.YOUNG_WOODLAND, 66.0, 12.5));
|
||||
|
||||
service.saveRegion(original);
|
||||
Region restored = service.loadRegion(original.getCoordinate()).orElseThrow();
|
||||
RegionModuleData restoredData = restored.getModuleData();
|
||||
|
||||
PollutionRegionData pollution = restoredData.get("pollution", PollutionRegionData.class).orElseThrow();
|
||||
assertEquals(42.0, pollution.getAirPollution());
|
||||
assertEquals(15.0, pollution.getGroundPollution());
|
||||
assertEquals(8.0, pollution.getWaterPollution());
|
||||
assertEquals(25.0, pollution.getDecayResistance());
|
||||
|
||||
SoilRegionData soil = restoredData.get("soil", SoilRegionData.class).orElseThrow();
|
||||
assertEquals(75.0, soil.getFertility());
|
||||
assertEquals(5.0, soil.getContamination());
|
||||
|
||||
RecoveryRegionData recovery = restoredData.get("recovery", RecoveryRegionData.class).orElseThrow();
|
||||
assertEquals(SuccessionStage.YOUNG_WOODLAND, recovery.getSuccessionStage());
|
||||
assertEquals(12.5, recovery.getDamageAccumulation());
|
||||
}
|
||||
|
||||
private FileRegionPersistenceService service() {
|
||||
return new FileRegionPersistenceService(
|
||||
temporaryDirectory,
|
||||
|
||||
@@ -233,6 +233,11 @@ class SimulationManagerTest {
|
||||
return coordinates.stream().map(regions::get).filter(java.util.Objects::nonNull).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Collection<Region> getActiveRegions() {
|
||||
return regions.values();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markDirty(Region region) {
|
||||
markDirtyCount++;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.livingworld.data.migration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MigrationManagerTest {
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static Properties propsAtVersion(int version) {
|
||||
Properties p = new Properties();
|
||||
p.setProperty("schemaVersion", Integer.toString(version));
|
||||
p.setProperty("someField", "original");
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Migration that adds a tag proving it ran. */
|
||||
private static RegionMigration tagMigration(int from) {
|
||||
return new RegionMigration() {
|
||||
@Override public int fromVersion() { return from; }
|
||||
@Override public int toVersion() { return from + 1; }
|
||||
@Override public Properties apply(Properties data) {
|
||||
Properties out = new Properties();
|
||||
out.putAll(data);
|
||||
out.setProperty("migrated_" + from + "_to_" + (from + 1), "true");
|
||||
return out;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// construction
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void newManagerHasNoMigrations() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
assertEquals(0, manager.getRegisteredMigrationCount());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// registration
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void registerNullMigrationThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
assertThrows(IllegalArgumentException.class, () -> manager.register(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerMigrationThatSkipsVersionThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
RegionMigration bad = new RegionMigration() {
|
||||
@Override public int fromVersion() { return 1; }
|
||||
@Override public int toVersion() { return 3; } // skips v2
|
||||
@Override public Properties apply(Properties data) { return data; }
|
||||
};
|
||||
assertThrows(IllegalArgumentException.class, () -> manager.register(bad));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerDuplicateFromVersionThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
manager.register(tagMigration(1));
|
||||
assertThrows(IllegalArgumentException.class, () -> manager.register(tagMigration(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerIncreasesCount() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
manager.register(tagMigration(1));
|
||||
manager.register(tagMigration(2));
|
||||
assertEquals(2, manager.getRegisteredMigrationCount());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// isUpToDate
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void isUpToDateReturnsTrueWhenVersionMatches() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
assertTrue(manager.isUpToDate(propsAtVersion(1), 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isUpToDateReturnsFalseWhenBehind() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
assertFalse(manager.isUpToDate(propsAtVersion(1), 2));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// migrateIfNeeded — already at target
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void migrateIfNeededReturnsSameObjectWhenAlreadyAtTarget() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
Properties data = propsAtVersion(1);
|
||||
Properties result = manager.migrateIfNeeded(data, 1);
|
||||
assertSame(data, result);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// migrateIfNeeded — single step
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void singleMigrationIsApplied() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
manager.register(tagMigration(1));
|
||||
|
||||
Properties data = propsAtVersion(1);
|
||||
Properties result = manager.migrateIfNeeded(data, 2);
|
||||
|
||||
assertEquals("2", result.getProperty("schemaVersion"));
|
||||
assertEquals("true", result.getProperty("migrated_1_to_2"));
|
||||
assertEquals("original", result.getProperty("someField")); // original data preserved
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// migrateIfNeeded — multi-step
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void multiStepMigrationAppliesAllStepsInOrder() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
manager.register(tagMigration(1));
|
||||
manager.register(tagMigration(2));
|
||||
|
||||
Properties result = manager.migrateIfNeeded(propsAtVersion(1), 3);
|
||||
|
||||
assertEquals("3", result.getProperty("schemaVersion"));
|
||||
assertEquals("true", result.getProperty("migrated_1_to_2"));
|
||||
assertEquals("true", result.getProperty("migrated_2_to_3"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// migrateIfNeeded — source data is not mutated
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void originalDataIsNotMutatedByMigration() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
manager.register(tagMigration(1));
|
||||
|
||||
Properties data = propsAtVersion(1);
|
||||
manager.migrateIfNeeded(data, 2);
|
||||
|
||||
assertEquals("1", data.getProperty("schemaVersion"));
|
||||
assertNull(data.getProperty("migrated_1_to_2"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// migrateIfNeeded — error cases
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void downgradeAttemptThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> manager.migrateIfNeeded(propsAtVersion(2), 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingMigrationInChainThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
manager.register(tagMigration(1));
|
||||
// no migration for version 2 → 3
|
||||
|
||||
// data at v1, target v3: step 1→2 exists but 2→3 is missing
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> manager.migrateIfNeeded(propsAtVersion(1), 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingSchemaVersionKeyThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
Properties data = new Properties();
|
||||
data.setProperty("someField", "value");
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> manager.migrateIfNeeded(data, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidSchemaVersionValueThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
Properties data = new Properties();
|
||||
data.setProperty("schemaVersion", "not-a-number");
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> manager.migrateIfNeeded(data, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroSchemaVersionThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
Properties data = new Properties();
|
||||
data.setProperty("schemaVersion", "0");
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> manager.migrateIfNeeded(data, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullDataThrows() {
|
||||
MigrationManager manager = new MigrationManager(null);
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> manager.migrateIfNeeded(null, 1));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// migrations.log
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void migrationIsRecordedInLogFile(@TempDir Path tempDir) throws Exception {
|
||||
Path logFile = tempDir.resolve("migrations.log");
|
||||
MigrationManager manager = new MigrationManager(logFile);
|
||||
manager.register(tagMigration(1));
|
||||
|
||||
manager.migrateIfNeeded(propsAtVersion(1), 2);
|
||||
|
||||
assertTrue(Files.exists(logFile), "migrations.log should be created");
|
||||
String content = Files.readString(logFile);
|
||||
assertTrue(content.contains("1 → 2"), "log should record the version step");
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiStepMigrationWritesMultipleLogEntries(@TempDir Path tempDir) throws Exception {
|
||||
Path logFile = tempDir.resolve("sub/migrations.log");
|
||||
MigrationManager manager = new MigrationManager(logFile);
|
||||
manager.register(tagMigration(1));
|
||||
manager.register(tagMigration(2));
|
||||
|
||||
manager.migrateIfNeeded(propsAtVersion(1), 3);
|
||||
|
||||
String content = Files.readString(logFile);
|
||||
assertTrue(content.contains("1 → 2"));
|
||||
assertTrue(content.contains("2 → 3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noLogFileWrittenWhenAlreadyAtTargetVersion(@TempDir Path tempDir) throws Exception {
|
||||
Path logFile = tempDir.resolve("migrations.log");
|
||||
MigrationManager manager = new MigrationManager(logFile);
|
||||
|
||||
manager.migrateIfNeeded(propsAtVersion(1), 1);
|
||||
|
||||
assertFalse(Files.exists(logFile), "log should not be created when no migration runs");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package com.livingworld.modules;
|
||||
|
||||
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||
import com.livingworld.modules.pollution.PollutionModule;
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.soil.SoilModule;
|
||||
import com.livingworld.modules.soil.SoilRegionData;
|
||||
import com.livingworld.modules.vegetation.VegetationModule;
|
||||
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionLifecycleState;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Integration tests that run all four ecosystem modules in pipeline order against
|
||||
* real Region objects and verify ecological cause-and-effect across multiple ticks.
|
||||
*/
|
||||
class EcosystemModuleIntegrationTest {
|
||||
|
||||
private static final List<SimulationModule> MODULES = List.of(
|
||||
new PollutionModule(),
|
||||
new SoilModule(),
|
||||
new VegetationModule(),
|
||||
new EcosystemModule());
|
||||
|
||||
private RegionFactory factory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
factory = new RegionFactory();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private Region freshRegion() {
|
||||
return factory.createNewRegion(
|
||||
new RegionCoordinate("overworld", 0, 0), 0L);
|
||||
}
|
||||
|
||||
/** Runs all modules once against the region, in pipeline order. */
|
||||
private void tick(Region region) {
|
||||
RegionUpdateContext ctx = new RegionUpdateContext(region);
|
||||
for (SimulationModule module : MODULES) {
|
||||
module.createDefaultRegionData(region);
|
||||
module.updateRegion(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs N ticks against the region. */
|
||||
private void tick(Region region, int ticks) {
|
||||
for (int i = 0; i < ticks; i++) {
|
||||
tick(region);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// clean region stays stable
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void cleanRegionKeepsHighEcosystemHealthOverManyTicks() {
|
||||
Region region = freshRegion();
|
||||
// Start with pristine defaults – no pollution.
|
||||
tick(region, 50);
|
||||
// Ecosystem health should remain high with no external stressors.
|
||||
assertTrue(region.getMetrics().getEcosystemHealth() >= 50.0,
|
||||
"Clean region should maintain reasonable ecosystem health");
|
||||
assertTrue(region.getMetrics().getPollutionScore() < 5.0,
|
||||
"No pollution was added; score should stay near zero");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// heavy pollution degrades soil and vegetation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void heavyPollutionDegradesSoilQualityOverTime() {
|
||||
Region region = freshRegion();
|
||||
// Prime the region with severe pollution directly in module data.
|
||||
PollutionRegionData pollData = new PollutionRegionData(80.0, 80.0, 50.0, 20.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||
|
||||
double initialSoilQuality = region.getMetrics().getSoilQuality();
|
||||
|
||||
tick(region, 30);
|
||||
|
||||
double laterSoilQuality = region.getMetrics().getSoilQuality();
|
||||
assertTrue(laterSoilQuality < initialSoilQuality,
|
||||
"Sustained heavy pollution should degrade soil quality. Before="
|
||||
+ initialSoilQuality + " After=" + laterSoilQuality);
|
||||
}
|
||||
|
||||
@Test
|
||||
void heavyPollutionReducesVegetationPressure() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollData = new PollutionRegionData(90.0, 90.0, 70.0, 20.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||
|
||||
double initialVeg = region.getMetrics().getVegetationPressure();
|
||||
tick(region, 30);
|
||||
double laterVeg = region.getMetrics().getVegetationPressure();
|
||||
|
||||
assertTrue(laterVeg < initialVeg,
|
||||
"Heavy pollution should reduce vegetation pressure. Before="
|
||||
+ initialVeg + " After=" + laterVeg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void heavyPollutionIncreasesEcosystemStress() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollData = new PollutionRegionData(100.0, 100.0, 100.0, 50.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||
|
||||
tick(region, 20);
|
||||
|
||||
EcosystemRegionData ecoData = region.getModuleData()
|
||||
.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class)
|
||||
.orElseThrow();
|
||||
assertTrue(ecoData.getStress() > 20.0,
|
||||
"Severe pollution should elevate ecosystem stress");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// vegetation succession
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void bareGroundWithGoodSoilGrowsGrassOverTime() {
|
||||
Region region = freshRegion();
|
||||
// Strip all vegetation.
|
||||
VegetationRegionData barren = new VegetationRegionData(5.0, 0.0, 0.0, 0.0, 0.0);
|
||||
region.getModuleData().put(VegetationModule.MODULE_ID, barren);
|
||||
|
||||
tick(region, 50);
|
||||
|
||||
VegetationRegionData later = region.getModuleData()
|
||||
.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
|
||||
.orElseThrow();
|
||||
assertTrue(later.getGrassPressure() > 5.0,
|
||||
"Good soil should allow grass to grow back from a barren start");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loggingReducesTreeAndShrubPressure() {
|
||||
Region region = freshRegion();
|
||||
VegetationRegionData preLog = VegetationRegionData.defaults();
|
||||
double treesBefore = preLog.getTreePressure();
|
||||
preLog.reduceFromLogging(30.0);
|
||||
region.getModuleData().put(VegetationModule.MODULE_ID, preLog);
|
||||
|
||||
assertTrue(preLog.getTreePressure() < treesBefore,
|
||||
"Logging should immediately reduce tree pressure");
|
||||
assertTrue(preLog.getDeadVegetation() > 5.0,
|
||||
"Logging should produce dead vegetation");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// water quality
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void waterPollutionDegradedWaterQuality() {
|
||||
Region region = freshRegion();
|
||||
// Force high water pollution.
|
||||
PollutionRegionData pollData = new PollutionRegionData(0.0, 0.0, 80.0, 20.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||
// Set metrics to a known starting waterQuality.
|
||||
region.getMetrics().setWaterQuality(80.0);
|
||||
|
||||
tick(region, 10);
|
||||
|
||||
assertTrue(region.getMetrics().getWaterQuality() < 80.0,
|
||||
"Water pollution should degrade water quality metric");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// recovery after pollution clears
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void ecosystemHealthImprovesDramaticallyAfterPollutionIsRemoved() {
|
||||
Region region = freshRegion();
|
||||
// Heavily pollute for 20 ticks.
|
||||
PollutionRegionData pollData = new PollutionRegionData(90.0, 90.0, 60.0, 20.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||
tick(region, 20);
|
||||
double healthMidPollution = region.getMetrics().getEcosystemHealth();
|
||||
|
||||
// Remove pollution and run for another 50 ticks.
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
|
||||
tick(region, 50);
|
||||
double healthAfterRecovery = region.getMetrics().getEcosystemHealth();
|
||||
|
||||
assertTrue(healthAfterRecovery > healthMidPollution,
|
||||
"Ecosystem should recover after pollution is removed. MidPollution="
|
||||
+ healthMidPollution + " AfterRecovery=" + healthAfterRecovery);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// module order matters
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void pollutionModuleUpdatesMetricsReadBySoilModuleInSameTick() {
|
||||
Region region = freshRegion();
|
||||
// groundPollution=90 → pollutionScore=90*0.35=31.5 > POLLUTION_CONTAMINATION_THRESHOLD(30).
|
||||
PollutionRegionData pollData = new PollutionRegionData(0.0, 90.0, 0.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||
|
||||
// Run exactly one tick.
|
||||
tick(region);
|
||||
|
||||
// Pollution module should have set a non-zero pollution score.
|
||||
assertTrue(region.getMetrics().getPollutionScore() > 0.0,
|
||||
"PollutionModule should have written a non-zero pollutionScore to metrics");
|
||||
// Soil module (running after) should have begun accumulating contamination.
|
||||
SoilRegionData soilData = region.getModuleData()
|
||||
.get(SoilModule.MODULE_ID, SoilRegionData.class)
|
||||
.orElseThrow();
|
||||
assertTrue(soilData.getContamination() > 0.0,
|
||||
"SoilModule should have accumulated contamination from this tick's pollutionScore");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// lifecycle: createDefaultRegionData is idempotent
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void createDefaultRegionDataIsIdempotent() {
|
||||
Region region = freshRegion();
|
||||
PollutionModule module = new PollutionModule();
|
||||
module.createDefaultRegionData(region);
|
||||
module.createDefaultRegionData(region); // second call must not overwrite
|
||||
|
||||
// Data exists and is valid defaults.
|
||||
PollutionRegionData data = region.getModuleData()
|
||||
.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
|
||||
.orElseThrow();
|
||||
assertEquals(0.0, data.getAirPollution(), 1e-9);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ModuleUpdateResult signals
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void pollutionModuleReturnsNoChangeWhenPollutionIsZero() {
|
||||
Region region = freshRegion();
|
||||
// Ensure pollution module data is initialised to all-zero.
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
|
||||
// Zero water quality impact needs zero starting waterQuality impact too.
|
||||
region.getMetrics().setWaterQuality(60.0);
|
||||
|
||||
ModuleUpdateResult result = new PollutionModule()
|
||||
.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
// Zero pollution decays to zero; pollutionScore stays 0; no meaningful change.
|
||||
assertFalse(result.changedRegion(),
|
||||
"PollutionModule with zero pollution should return noChange");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pollutionModuleReturnsChangedWhenPollutionIsPresent() {
|
||||
Region region = freshRegion();
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID,
|
||||
new PollutionRegionData(50.0, 50.0, 50.0, 0.0));
|
||||
|
||||
ModuleUpdateResult result = new PollutionModule()
|
||||
.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertTrue(result.changedRegion(),
|
||||
"PollutionModule with non-zero pollution should return changed");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// long-run stability
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void allMetricsRemainInValidRangeAfter1000Ticks() {
|
||||
Region region = freshRegion();
|
||||
// Add moderate pollution so the simulation isn't completely quiescent.
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID,
|
||||
new PollutionRegionData(30.0, 20.0, 10.0, 25.0));
|
||||
|
||||
tick(region, 1000);
|
||||
|
||||
RegionMetrics m = region.getMetrics();
|
||||
assertInRange("ecosystemHealth", m.getEcosystemHealth());
|
||||
assertInRange("pollutionScore", m.getPollutionScore());
|
||||
assertInRange("soilQuality", m.getSoilQuality());
|
||||
assertInRange("waterQuality", m.getWaterQuality());
|
||||
assertInRange("vegetationPressure",m.getVegetationPressure());
|
||||
assertInRange("recoveryPressure", m.getRecoveryPressure());
|
||||
}
|
||||
|
||||
private static void assertInRange(String name, double value) {
|
||||
assertTrue(value >= 0.0 && value <= 100.0,
|
||||
name + " must be in [0, 100] but was " + value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package com.livingworld.modules;
|
||||
|
||||
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||
import com.livingworld.modules.pollution.PollutionModule;
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryModule;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||
import com.livingworld.modules.resources.ResourceRegionData;
|
||||
import com.livingworld.modules.soil.SoilModule;
|
||||
import com.livingworld.modules.soil.SoilRegionData;
|
||||
import com.livingworld.modules.vegetation.VegetationModule;
|
||||
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||
import com.livingworld.modules.water.WaterModule;
|
||||
import com.livingworld.modules.worldeffects.WorldEffectRequest;
|
||||
import com.livingworld.modules.worldeffects.WorldEffectType;
|
||||
import com.livingworld.modules.worldeffects.WorldEffectsModule;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Full Volume 2 integration test: runs all eight ecosystem modules in pipeline
|
||||
* order (Pollution → Soil → Water → Vegetation → ResourceDepletion → Recovery
|
||||
* → Ecosystem → WorldEffects) and verifies ecological cause-and-effect across
|
||||
* multiple simulation ticks.
|
||||
*/
|
||||
@DisplayName("Volume 2 Full Pipeline Integration")
|
||||
class Volume2IntegrationTest {
|
||||
|
||||
private RegionFactory factory;
|
||||
private WorldEffectsModule worldEffects;
|
||||
private List<WorldEffectRequest> effectsCapture;
|
||||
|
||||
private List<SimulationModule> pipeline;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
factory = new RegionFactory();
|
||||
worldEffects = new WorldEffectsModule();
|
||||
effectsCapture = new ArrayList<>();
|
||||
worldEffects.registerConsumer(effectsCapture::add);
|
||||
|
||||
pipeline = List.of(
|
||||
new PollutionModule(),
|
||||
new SoilModule(),
|
||||
new WaterModule(),
|
||||
new VegetationModule(),
|
||||
new ResourceDepletionModule(),
|
||||
new RecoveryModule(),
|
||||
new EcosystemModule(),
|
||||
worldEffects);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private Region freshRegion() {
|
||||
return factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||
}
|
||||
|
||||
private void tick(Region region) {
|
||||
effectsCapture.clear();
|
||||
RegionUpdateContext ctx = new RegionUpdateContext(region);
|
||||
for (SimulationModule module : pipeline) {
|
||||
module.createDefaultRegionData(region);
|
||||
module.updateRegion(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private void tick(Region region, int ticks) {
|
||||
for (int i = 0; i < ticks; i++) {
|
||||
tick(region);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Structural tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("all modules initialize without error")
|
||||
void allModulesInitialize() {
|
||||
Region region = freshRegion();
|
||||
for (SimulationModule module : pipeline) {
|
||||
assertDoesNotThrow(() -> module.createDefaultRegionData(region));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("pipeline runs 100 ticks on a clean region without exception")
|
||||
void cleanRegionStableOver100Ticks() {
|
||||
Region region = freshRegion();
|
||||
assertDoesNotThrow(() -> tick(region, 100));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all metrics stay in [0, 100] over 1000 ticks — pristine region")
|
||||
void metricsInRangePristine() {
|
||||
Region region = freshRegion();
|
||||
tick(region, 1000);
|
||||
assertMetricsInRange(region.getMetrics());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all metrics stay in [0, 100] over 500 ticks — heavily polluted region")
|
||||
void metricsInRangeHeavilyPolluted() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
|
||||
tick(region, 500);
|
||||
assertMetricsInRange(region.getMetrics());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Causal chain tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("heavy pollution degrades soil quality over 30 ticks")
|
||||
void heavyPollutionDegradesSoil() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
double initialSoil = region.getMetrics().getSoilQuality();
|
||||
|
||||
tick(region, 30);
|
||||
|
||||
assertTrue(region.getMetrics().getSoilQuality() < initialSoil,
|
||||
"Soil quality should degrade under heavy pollution");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("heavy pollution reduces vegetation pressure over 50 ticks")
|
||||
void heavyPollutionReducesVegetation() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
double initialVeg = region.getMetrics().getVegetationPressure();
|
||||
|
||||
tick(region, 50);
|
||||
|
||||
assertTrue(region.getMetrics().getVegetationPressure() < initialVeg,
|
||||
"Vegetation pressure should fall under heavy pollution");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("heavy pollution lowers water quality over 20 ticks")
|
||||
void heavyPollutionLowersWater() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
double initialWater = region.getMetrics().getWaterQuality();
|
||||
|
||||
tick(region, 20);
|
||||
|
||||
assertTrue(region.getMetrics().getWaterQuality() < initialWater,
|
||||
"Water quality should fall under heavy pollution");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("heavy pollution increases ecosystem stress over 30 ticks")
|
||||
void heavyPollutionIncreasesStress() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
|
||||
tick(region, 30);
|
||||
|
||||
EcosystemRegionData eco = region.getModuleData()
|
||||
.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class).orElseThrow();
|
||||
assertTrue(eco.getStress() > 20.0,
|
||||
"Ecosystem stress should be elevated under heavy pollution");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bare ground (low soil + zero pollution) grows grass over 50 ticks")
|
||||
void bareGroundGrowsGrass() {
|
||||
Region region = freshRegion();
|
||||
// Start with bare-ish ground: good soil, no pollution, low veg
|
||||
SoilRegionData soil = new SoilRegionData(70.0, 50.0, 0.0, 10.0, 0.0);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
VegetationRegionData veg = new VegetationRegionData(5.0, 5.0, 0.0, 0.0, 0.0);
|
||||
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
|
||||
|
||||
tick(region, 50);
|
||||
|
||||
VegetationRegionData after = region.getModuleData()
|
||||
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElseThrow();
|
||||
assertTrue(after.getGrassPressure() > 5.0,
|
||||
"Grass should grow on bare ground with good soil, was: " + after.getGrassPressure());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("logging depletion reduces tree and shrub pressure over 20 ticks")
|
||||
void loggingReducesTreeShrubPressure() {
|
||||
Region region = freshRegion();
|
||||
ResourceRegionData resources = ResourceRegionData.defaults();
|
||||
resources.recordLogging(80.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||
VegetationRegionData vegData = new VegetationRegionData(50.0, 30.0, 50.0, 60.0, 5.0);
|
||||
vegData.reduceFromLogging(80.0);
|
||||
region.getModuleData().put(VegetationModule.MODULE_ID, vegData);
|
||||
|
||||
tick(region, 20);
|
||||
|
||||
VegetationRegionData after = region.getModuleData()
|
||||
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElseThrow();
|
||||
assertTrue(after.getTreePressure() < 60.0,
|
||||
"Tree pressure should be reduced after logging");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("high vegetation purifies water quality over 30 ticks (pollution-free region)")
|
||||
void vegetationPurifiesWater() {
|
||||
Region region = freshRegion();
|
||||
// Start with high veg, good soil, slightly low water quality
|
||||
VegetationRegionData vegData = new VegetationRegionData(80.0, 50.0, 60.0, 40.0, 5.0);
|
||||
region.getModuleData().put(VegetationModule.MODULE_ID, vegData);
|
||||
region.getMetrics().setWaterQuality(40.0);
|
||||
|
||||
tick(region, 30);
|
||||
|
||||
assertTrue(region.getMetrics().getWaterQuality() > 40.0,
|
||||
"Water quality should improve when vegetation is healthy");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ecosystem health improves after pollution is removed (over 50 ticks)")
|
||||
void healthImprovesAfterPollutionRemoved() {
|
||||
Region region = freshRegion();
|
||||
// First: run 30 ticks with heavy pollution
|
||||
PollutionRegionData heavyPollution = new PollutionRegionData(80.0, 80.0, 80.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, heavyPollution);
|
||||
tick(region, 30);
|
||||
double healthAfterPollution = region.getMetrics().getEcosystemHealth();
|
||||
|
||||
// Now remove pollution and run 50 more ticks
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
|
||||
tick(region, 50);
|
||||
|
||||
assertTrue(region.getMetrics().getEcosystemHealth() > healthAfterPollution,
|
||||
"Ecosystem health should improve once pollution is removed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("succession advances from BARREN toward GRASSLAND under good conditions")
|
||||
void successionAdvancesUnderGoodConditions() {
|
||||
Region region = freshRegion();
|
||||
// Set good conditions exceeding all early stage thresholds
|
||||
region.getMetrics().setSoilQuality(70.0);
|
||||
region.getMetrics().setPollutionScore(0.0);
|
||||
region.getMetrics().setVegetationPressure(40.0);
|
||||
RecoveryRegionData recovery = new RecoveryRegionData(SuccessionStage.BARREN, 0.0, 0.0);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||
|
||||
tick(region, 300);
|
||||
|
||||
RecoveryRegionData result = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||
assertTrue(result.getSuccessionStage().ordinal() > SuccessionStage.BARREN.ordinal(),
|
||||
"Succession should advance beyond BARREN with good conditions");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("succession regresses under severe pollution over 30 ticks")
|
||||
void successionRegressesUnderSeverePollution() {
|
||||
Region region = freshRegion();
|
||||
// Start at SCRUBLAND
|
||||
RecoveryRegionData recovery = new RecoveryRegionData(SuccessionStage.SCRUBLAND, 0.0, 0.0);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||
// Force very bad conditions
|
||||
PollutionRegionData pollution = new PollutionRegionData(95.0, 95.0, 95.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
SoilRegionData soil = new SoilRegionData(5.0, 0.0, 50.0, 30.0, 20.0);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
|
||||
tick(region, 30);
|
||||
|
||||
RecoveryRegionData result = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||
// Either damage accumulated or regression happened
|
||||
assertTrue(result.getDamageAccumulation() > 0.0
|
||||
|| result.getSuccessionStage().ordinal() < SuccessionStage.SCRUBLAND.ordinal(),
|
||||
"Succession should regress or take damage under severe pollution");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// World effects tests
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("GRASS_DEGRADES_TO_DIRT emitted when pollution is high and soil is poor")
|
||||
void worldEffectGrassDegrades() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
SoilRegionData soil = new SoilRegionData(10.0, 0.0, 50.0, 30.0, 20.0);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
|
||||
// Prime the pipeline so metrics are set by pollution/soil before worldeffects runs
|
||||
tick(region, 5);
|
||||
effectsCapture.clear();
|
||||
tick(region);
|
||||
|
||||
boolean found = effectsCapture.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
|
||||
assertTrue(found,
|
||||
"GRASS_DEGRADES_TO_DIRT should be emitted when soil is poor and pollution is high");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("VEGETATION_SPREADS emitted when vegetation and soil are healthy")
|
||||
void worldEffectVegetationSpreads() {
|
||||
Region region = freshRegion();
|
||||
// Force high vegetation and good soil
|
||||
VegetationRegionData veg = new VegetationRegionData(90.0, 50.0, 70.0, 60.0, 0.0);
|
||||
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
|
||||
SoilRegionData soil = new SoilRegionData(80.0, 60.0, 0.0, 5.0, 0.0);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
|
||||
tick(region, 3);
|
||||
effectsCapture.clear();
|
||||
tick(region);
|
||||
|
||||
boolean found = effectsCapture.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
|
||||
assertTrue(found,
|
||||
"VEGETATION_SPREADS should be emitted with high vegetation and good soil");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SAPLING_GROWTH_SLOWED emitted when logging depletion is high")
|
||||
void worldEffectSaplingSlowed() {
|
||||
Region region = freshRegion();
|
||||
ResourceRegionData resources = ResourceRegionData.defaults();
|
||||
resources.recordLogging(80.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||
|
||||
effectsCapture.clear();
|
||||
tick(region);
|
||||
|
||||
boolean found = effectsCapture.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
|
||||
assertTrue(found, "SAPLING_GROWTH_SLOWED should be emitted with heavy logging depletion");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SAPLING_GROWTH_BOOSTED emitted when region reaches YOUNG_WOODLAND")
|
||||
void worldEffectSaplingBoosted() {
|
||||
Region region = freshRegion();
|
||||
RecoveryRegionData recovery = new RecoveryRegionData(
|
||||
SuccessionStage.YOUNG_WOODLAND, 0.0, 0.0);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||
|
||||
effectsCapture.clear();
|
||||
tick(region);
|
||||
|
||||
boolean found = effectsCapture.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
|
||||
assertTrue(found, "SAPLING_GROWTH_BOOSTED should be emitted at YOUNG_WOODLAND stage");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("emitted WorldEffectRequests all have intensity in [0, 1]")
|
||||
void worldEffectIntensitiesInRange() {
|
||||
Region region = freshRegion();
|
||||
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
SoilRegionData soil = new SoilRegionData(10.0, 0.0, 50.0, 30.0, 20.0);
|
||||
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||
tick(region, 5);
|
||||
effectsCapture.clear();
|
||||
tick(region);
|
||||
|
||||
for (WorldEffectRequest req : effectsCapture) {
|
||||
assertTrue(req.intensity() >= 0.0 && req.intensity() <= 1.0,
|
||||
"Intensity out of range: " + req.intensity() + " for " + req.type());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pipeline ordering test
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("pollution metric written in same tick is read by soil and water modules")
|
||||
void pipelineOrderVerified() {
|
||||
Region region = freshRegion();
|
||||
// High ground pollution → pollutionScore computed by PollutionModule
|
||||
// → SoilModule reads pollutionScore → contamination raised
|
||||
// → WaterModule reads soilQuality → leach applied
|
||||
PollutionRegionData pollution = new PollutionRegionData(0.0, 90.0, 0.0, 0.0);
|
||||
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||
|
||||
// Single tick — everything computed in sequence
|
||||
tick(region);
|
||||
|
||||
// pollutionScore = ground * 0.35 = 31.5 > threshold → soil contamination increases
|
||||
SoilRegionData soilAfter = region.getModuleData()
|
||||
.get(SoilModule.MODULE_ID, SoilRegionData.class).orElseThrow();
|
||||
assertTrue(soilAfter.getContamination() > 0.0,
|
||||
"SoilModule should have read the pollution metric set by PollutionModule in the same tick");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void assertMetricsInRange(RegionMetrics m) {
|
||||
assertTrue(m.getEcosystemHealth() >= 0 && m.getEcosystemHealth() <= 100, "ecosystemHealth out of range: " + m.getEcosystemHealth());
|
||||
assertTrue(m.getPollutionScore() >= 0 && m.getPollutionScore() <= 100, "pollutionScore out of range: " + m.getPollutionScore());
|
||||
assertTrue(m.getSoilQuality() >= 0 && m.getSoilQuality() <= 100, "soilQuality out of range: " + m.getSoilQuality());
|
||||
assertTrue(m.getWaterQuality() >= 0 && m.getWaterQuality() <= 100, "waterQuality out of range: " + m.getWaterQuality());
|
||||
assertTrue(m.getVegetationPressure() >= 0 && m.getVegetationPressure() <= 100, "vegetationPressure out of range: " + m.getVegetationPressure());
|
||||
assertTrue(m.getResourceDepletion() >= 0 && m.getResourceDepletion() <= 100, "resourceDepletion out of range: " + m.getResourceDepletion());
|
||||
assertTrue(m.getRecoveryPressure() >= 0 && m.getRecoveryPressure() <= 100, "recoveryPressure out of range: " + m.getRecoveryPressure());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.livingworld.modules.ecosystem;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class EcosystemRegionDataTest {
|
||||
|
||||
@Test
|
||||
void defaultsAreModerateHealth() {
|
||||
EcosystemRegionData d = EcosystemRegionData.defaults();
|
||||
assertEquals(60.0, d.getEcosystemHealth(), 1e-9);
|
||||
assertEquals(20.0, d.getStress(), 1e-9);
|
||||
assertEquals(50.0, d.getResilience(), 1e-9);
|
||||
assertEquals(5.0, d.getRecoveryRate(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyStressIncreasesStressAndDegradesResilience() {
|
||||
EcosystemRegionData d = EcosystemRegionData.defaults();
|
||||
d.applyStress(10.0);
|
||||
assertEquals(30.0, d.getStress(), 1e-9);
|
||||
assertTrue(d.getResilience() < 50.0, "resilience should decrease under stress");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyStressNegativeThrows() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> EcosystemRegionData.defaults().applyStress(-1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyStressClampsAt100() {
|
||||
EcosystemRegionData d = new EcosystemRegionData(60.0, 90.0, 50.0, 5.0);
|
||||
d.applyStress(20.0);
|
||||
assertEquals(100.0, d.getStress(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyRecoveryDecreasesStressAndIncreasesResilience() {
|
||||
EcosystemRegionData d = new EcosystemRegionData(60.0, 40.0, 50.0, 5.0);
|
||||
d.applyRecovery(10.0);
|
||||
assertEquals(30.0, d.getStress(), 1e-9);
|
||||
assertTrue(d.getResilience() > 50.0, "resilience should increase during recovery");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyRecoveryNegativeThrows() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> EcosystemRegionData.defaults().applyRecovery(-1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyRecoveryClampsBelowZero() {
|
||||
EcosystemRegionData d = new EcosystemRegionData(60.0, 5.0, 50.0, 5.0);
|
||||
d.applyRecovery(20.0);
|
||||
assertEquals(0.0, d.getStress(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorClampsAbove100() {
|
||||
EcosystemRegionData d = new EcosystemRegionData(200.0, 200.0, 200.0, 200.0);
|
||||
assertEquals(100.0, d.getEcosystemHealth(), 1e-9);
|
||||
assertEquals(100.0, d.getStress(), 1e-9);
|
||||
assertEquals(100.0, d.getResilience(), 1e-9);
|
||||
assertEquals(100.0, d.getRecoveryRate(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorClampsBelowZero() {
|
||||
EcosystemRegionData d = new EcosystemRegionData(-1.0, -1.0, -1.0, -1.0);
|
||||
assertEquals(0.0, d.getEcosystemHealth(), 1e-9);
|
||||
assertEquals(0.0, d.getStress(), 1e-9);
|
||||
assertEquals(0.0, d.getResilience(), 1e-9);
|
||||
assertEquals(0.0, d.getRecoveryRate(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyIsIndependent() {
|
||||
EcosystemRegionData original = EcosystemRegionData.defaults();
|
||||
EcosystemRegionData copy = original.copy();
|
||||
copy.applyStress(50.0);
|
||||
assertEquals(20.0, original.getStress(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settersClampValues() {
|
||||
EcosystemRegionData d = EcosystemRegionData.defaults();
|
||||
d.setStress(-10.0);
|
||||
d.setResilience(999.0);
|
||||
assertEquals(0.0, d.getStress(), 1e-9);
|
||||
assertEquals(100.0, d.getResilience(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeDoesNotChangeLegalValues() {
|
||||
EcosystemRegionData d = EcosystemRegionData.defaults();
|
||||
d.normalize();
|
||||
assertEquals(60.0, d.getEcosystemHealth(), 1e-9);
|
||||
assertEquals(20.0, d.getStress(), 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.livingworld.modules.pollution;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PollutionRegionDataTest {
|
||||
|
||||
@Test
|
||||
void defaultsHaveZeroPollution() {
|
||||
PollutionRegionData d = PollutionRegionData.defaults();
|
||||
assertEquals(0.0, d.getAirPollution(), 1e-9);
|
||||
assertEquals(0.0, d.getGroundPollution(), 1e-9);
|
||||
assertEquals(0.0, d.getWaterPollution(), 1e-9);
|
||||
assertEquals(20.0, d.getDecayResistance(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addPollutionAccumulates() {
|
||||
PollutionRegionData d = PollutionRegionData.defaults();
|
||||
d.addPollution(10.0, 20.0, 5.0);
|
||||
assertEquals(10.0, d.getAirPollution(), 1e-9);
|
||||
assertEquals(20.0, d.getGroundPollution(), 1e-9);
|
||||
assertEquals(5.0, d.getWaterPollution(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addPollutionClampsAt100() {
|
||||
PollutionRegionData d = new PollutionRegionData(90.0, 90.0, 90.0, 20.0);
|
||||
d.addPollution(20.0, 20.0, 20.0);
|
||||
assertEquals(100.0, d.getAirPollution(), 1e-9);
|
||||
assertEquals(100.0, d.getGroundPollution(), 1e-9);
|
||||
assertEquals(100.0, d.getWaterPollution(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decayReducesAllPollution() {
|
||||
PollutionRegionData d = new PollutionRegionData(50.0, 50.0, 50.0, 0.0);
|
||||
d.decay(0.02);
|
||||
// With zero resistance: effectiveRate = 0.02 * 1.0 = 0.02
|
||||
// air: 50 * (1 - 0.04) = 48
|
||||
// ground: 50 * (1 - 0.01) = 49.5
|
||||
// water: 50 * (1 - 0.006) = 49.7
|
||||
assertTrue(d.getAirPollution() < 50.0);
|
||||
assertTrue(d.getGroundPollution() < 50.0);
|
||||
assertTrue(d.getWaterPollution() < 50.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decayWithHighResistanceIsSlower() {
|
||||
PollutionRegionData low = new PollutionRegionData(50.0, 50.0, 50.0, 0.0);
|
||||
PollutionRegionData high = new PollutionRegionData(50.0, 50.0, 50.0, 100.0);
|
||||
low.decay(0.02);
|
||||
high.decay(0.02);
|
||||
assertTrue(high.getAirPollution() > low.getAirPollution(),
|
||||
"High resistance should leave more air pollution after decay");
|
||||
}
|
||||
|
||||
@Test
|
||||
void decayOnZeroPollutionStaysZero() {
|
||||
PollutionRegionData d = PollutionRegionData.defaults();
|
||||
d.decay(0.10);
|
||||
assertEquals(0.0, d.getAirPollution(), 1e-9);
|
||||
assertEquals(0.0, d.getGroundPollution(), 1e-9);
|
||||
assertEquals(0.0, d.getWaterPollution(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorClampsNegativeValues() {
|
||||
PollutionRegionData d = new PollutionRegionData(-10.0, -5.0, -1.0, -50.0);
|
||||
assertEquals(0.0, d.getAirPollution(), 1e-9);
|
||||
assertEquals(0.0, d.getGroundPollution(), 1e-9);
|
||||
assertEquals(0.0, d.getWaterPollution(), 1e-9);
|
||||
assertEquals(0.0, d.getDecayResistance(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorClampsAbove100() {
|
||||
PollutionRegionData d = new PollutionRegionData(200.0, 150.0, 110.0, 999.0);
|
||||
assertEquals(100.0, d.getAirPollution(), 1e-9);
|
||||
assertEquals(100.0, d.getGroundPollution(), 1e-9);
|
||||
assertEquals(100.0, d.getWaterPollution(), 1e-9);
|
||||
assertEquals(100.0, d.getDecayResistance(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyIsIndependent() {
|
||||
PollutionRegionData original = new PollutionRegionData(30.0, 40.0, 10.0, 25.0);
|
||||
PollutionRegionData copy = original.copy();
|
||||
copy.addPollution(50.0, 50.0, 50.0);
|
||||
assertEquals(30.0, original.getAirPollution(), 1e-9);
|
||||
assertEquals(40.0, original.getGroundPollution(), 1e-9);
|
||||
assertEquals(10.0, original.getWaterPollution(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeDoesNotChangeValidValues() {
|
||||
PollutionRegionData d = new PollutionRegionData(30.0, 40.0, 10.0, 25.0);
|
||||
d.normalize();
|
||||
assertEquals(30.0, d.getAirPollution(), 1e-9);
|
||||
assertEquals(40.0, d.getGroundPollution(), 1e-9);
|
||||
assertEquals(10.0, d.getWaterPollution(), 1e-9);
|
||||
assertEquals(25.0, d.getDecayResistance(), 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.livingworld.modules.recovery;
|
||||
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("RecoveryModule")
|
||||
class RecoveryModuleTest {
|
||||
|
||||
private RecoveryModule module;
|
||||
private RegionFactory factory;
|
||||
private Region region;
|
||||
private RegionMetrics metrics;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
module = new RecoveryModule();
|
||||
factory = new RegionFactory();
|
||||
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||
metrics = region.getMetrics();
|
||||
module.createDefaultRegionData(region);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moduleId is 'recovery'")
|
||||
void moduleId() {
|
||||
assertEquals("recovery", module.getModuleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("metadata is non-null with correct id")
|
||||
void metadata() {
|
||||
assertNotNull(module.getMetadata());
|
||||
assertEquals("recovery", module.getMetadata().moduleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("initialize throws on null context")
|
||||
void initializeNullThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDefaultRegionData populates module data at GRASSLAND stage")
|
||||
void createDefaultPopulates() {
|
||||
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||
module.createDefaultRegionData(fresh);
|
||||
RecoveryRegionData data = fresh.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||
assertEquals(SuccessionStage.GRASSLAND, data.getSuccessionStage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDefaultRegionData is idempotent")
|
||||
void createDefaultIdempotent() {
|
||||
SuccessionStage before = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow()
|
||||
.getSuccessionStage();
|
||||
module.createDefaultRegionData(region);
|
||||
SuccessionStage after = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow()
|
||||
.getSuccessionStage();
|
||||
assertEquals(before, after);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateRegion throws on null context")
|
||||
void updateNullThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("good conditions advance recovery progress")
|
||||
void goodConditionsAdvanceProgress() {
|
||||
// GRASSLAND thresholds: minSoil=25, maxPollution=70, minVeg=20
|
||||
metrics.setSoilQuality(70.0);
|
||||
metrics.setPollutionScore(10.0);
|
||||
metrics.setVegetationPressure(50.0);
|
||||
metrics.setEcosystemHealth(50.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
RecoveryRegionData data = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||
assertTrue(data.getRecoveryProgress() > 0.0,
|
||||
"Recovery progress should advance under good conditions");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bad conditions accumulate damage over several ticks")
|
||||
void badConditionsAccumulateDamage() {
|
||||
// Put region at SCRUBLAND so it CAN regress.
|
||||
// Force conditions way below SCRUBLAND minimums to trigger regression checks.
|
||||
RecoveryRegionData startData = new RecoveryRegionData(
|
||||
SuccessionStage.SCRUBLAND, 0.0, 0.0);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, startData);
|
||||
|
||||
metrics.setSoilQuality(5.0); // far below minSoil=40 (SCRUBLAND)
|
||||
metrics.setPollutionScore(95.0); // far above maxPollution=60 (SCRUBLAND)
|
||||
metrics.setVegetationPressure(2.0); // far below minVeg=35 (SCRUBLAND)
|
||||
|
||||
for (int i = 0; i < 25; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
|
||||
RecoveryRegionData result = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||
// Either damage accumulated or regression already happened
|
||||
assertTrue(result.getDamageAccumulation() > 0.0
|
||||
|| result.getSuccessionStage().ordinal() < SuccessionStage.SCRUBLAND.ordinal(),
|
||||
"Bad conditions should accumulate damage or cause regression");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enough good ticks cause succession stage advancement")
|
||||
void enoughGoodTicksAdvanceStage() {
|
||||
// Start at BARREN — advancement thresholds: minSoil=10, maxPollution=80, minVeg=10
|
||||
RecoveryRegionData data = new RecoveryRegionData(SuccessionStage.BARREN, 0.0, 0.0);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, data);
|
||||
metrics.setSoilQuality(60.0);
|
||||
metrics.setPollutionScore(5.0);
|
||||
metrics.setVegetationPressure(30.0);
|
||||
metrics.setEcosystemHealth(80.0); // triggers health bonus
|
||||
|
||||
for (int i = 0; i < 250; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
|
||||
RecoveryRegionData result = region.getModuleData()
|
||||
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||
assertTrue(result.getSuccessionStage().ordinal() > SuccessionStage.BARREN.ordinal(),
|
||||
"Stage should advance with enough good ticks");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recoveryPressure metric is written and stays in [0, 100]")
|
||||
void recoveryPressureMetricWritten() {
|
||||
metrics.setSoilQuality(70.0);
|
||||
metrics.setPollutionScore(0.0);
|
||||
metrics.setVegetationPressure(50.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
double pressure = region.getMetrics().getRecoveryPressure();
|
||||
assertTrue(pressure >= 0.0 && pressure <= 100.0,
|
||||
"Recovery pressure must be in [0,100], was: " + pressure);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MATURE_FOREST stage has zero base recovery pressure")
|
||||
void matureForestZeroPressure() {
|
||||
RecoveryRegionData data = new RecoveryRegionData(
|
||||
SuccessionStage.MATURE_FOREST, 0.0, 0.0);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, data);
|
||||
metrics.setSoilQuality(70.0);
|
||||
metrics.setPollutionScore(0.0);
|
||||
metrics.setVegetationPressure(50.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
// stagesFromPeak = 0 → base pressure = 0 + damage * 0.3 = 0
|
||||
assertEquals(0.0, region.getMetrics().getRecoveryPressure(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns changed when progress increases")
|
||||
void changedWhenProgressIncreases() {
|
||||
metrics.setSoilQuality(70.0);
|
||||
metrics.setPollutionScore(5.0);
|
||||
metrics.setVegetationPressure(50.0);
|
||||
|
||||
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertTrue(result.changedRegion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns noChange when neither advancement nor regression conditions are met")
|
||||
void noChangeWhenNeutral() {
|
||||
// GRASSLAND advancement: minSoil=25, maxPollution=70, minVeg=20
|
||||
// GRASSLAND regression (50% severity): soil<12.5, pollution>84, veg<10
|
||||
// Put values between these two sets: soil=20 (below advance, above regress),
|
||||
// pollution=10 (fine), veg=15 (below advance threshold 20, above regress threshold 10)
|
||||
metrics.setSoilQuality(20.0);
|
||||
metrics.setPollutionScore(10.0);
|
||||
metrics.setVegetationPressure(15.0);
|
||||
|
||||
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertFalse(result.changedRegion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recovery pressure stays in [0, 100] over 1000 ticks with bad conditions")
|
||||
void recoveryPressureBounded() {
|
||||
metrics.setSoilQuality(5.0);
|
||||
metrics.setPollutionScore(90.0);
|
||||
metrics.setVegetationPressure(5.0);
|
||||
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
|
||||
double p = region.getMetrics().getRecoveryPressure();
|
||||
assertTrue(p >= 0.0 && p <= 100.0, "Recovery pressure out of range: " + p);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.livingworld.modules.resources;
|
||||
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("ResourceDepletionModule")
|
||||
class ResourceDepletionModuleTest {
|
||||
|
||||
private ResourceDepletionModule module;
|
||||
private RegionFactory factory;
|
||||
private Region region;
|
||||
private RegionMetrics metrics;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
module = new ResourceDepletionModule();
|
||||
factory = new RegionFactory();
|
||||
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||
metrics = region.getMetrics();
|
||||
module.createDefaultRegionData(region);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moduleId is 'resources'")
|
||||
void moduleId() {
|
||||
assertEquals("resources", module.getModuleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("metadata is non-null with correct id")
|
||||
void metadata() {
|
||||
assertNotNull(module.getMetadata());
|
||||
assertEquals("resources", module.getMetadata().moduleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("initialize throws on null context")
|
||||
void initializeNullThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDefaultRegionData populates module data")
|
||||
void createDefaultPopulates() {
|
||||
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||
module.createDefaultRegionData(fresh);
|
||||
assertTrue(fresh.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDefaultRegionData is idempotent")
|
||||
void createDefaultIdempotent() {
|
||||
ResourceRegionData before = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||
module.createDefaultRegionData(region);
|
||||
ResourceRegionData after = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||
assertEquals(before.getMiningDepletion(), after.getMiningDepletion(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateRegion throws on null context")
|
||||
void updateNullThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mining depletion regenerates very slowly (geological timescale)")
|
||||
void miningRegeneratesSlowly() {
|
||||
ResourceRegionData data = ResourceRegionData.defaults();
|
||||
data.recordMining(50.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||
|
||||
// After 1000 ticks, mining depletion should be very close to 50 (barely moved)
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
ResourceRegionData after = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||
assertTrue(after.getMiningDepletion() > 40.0,
|
||||
"Mining depletion should recover very slowly, was: " + after.getMiningDepletion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("logging depletion regenerates — drops from 80 over 100 ticks")
|
||||
void loggingRegenerates() {
|
||||
ResourceRegionData data = ResourceRegionData.defaults();
|
||||
data.recordLogging(80.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||
metrics.setVegetationPressure(30.0); // below bonus threshold
|
||||
|
||||
for (int i = 0; i < 100; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
ResourceRegionData after = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||
assertTrue(after.getLoggingDepletion() < 80.0,
|
||||
"Logging depletion should decrease, was: " + after.getLoggingDepletion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("high vegetation pressure accelerates logging regeneration")
|
||||
void highVegetationAcceleratesLoggingRegen() {
|
||||
ResourceRegionData data = ResourceRegionData.defaults();
|
||||
data.recordLogging(80.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||
metrics.setVegetationPressure(80.0); // above threshold
|
||||
|
||||
for (int i = 0; i < 50; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
double withHighVeg = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
|
||||
.getLoggingDepletion();
|
||||
|
||||
// Reset to same starting state with low vegetation
|
||||
Region region2 = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||
module.createDefaultRegionData(region2);
|
||||
ResourceRegionData data2 = ResourceRegionData.defaults();
|
||||
data2.recordLogging(80.0);
|
||||
region2.getModuleData().put(ResourceDepletionModule.MODULE_ID, data2);
|
||||
region2.getMetrics().setVegetationPressure(0.0);
|
||||
|
||||
for (int i = 0; i < 50; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region2));
|
||||
}
|
||||
double withLowVeg = region2.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
|
||||
.getLoggingDepletion();
|
||||
|
||||
assertTrue(withHighVeg < withLowVeg,
|
||||
"Higher vegetation should accelerate logging regen");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("farming depletion regenerates — drops from 60 over 100 ticks")
|
||||
void farmingRegenerates() {
|
||||
ResourceRegionData data = ResourceRegionData.defaults();
|
||||
data.recordFarming(60.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||
metrics.setSoilQuality(30.0);
|
||||
|
||||
for (int i = 0; i < 100; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
ResourceRegionData after = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||
assertTrue(after.getFarmingDepletion() < 60.0,
|
||||
"Farming depletion should decrease, was: " + after.getFarmingDepletion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("high soil quality accelerates farming regeneration")
|
||||
void highSoilAcceleratesFarmingRegen() {
|
||||
ResourceRegionData data = ResourceRegionData.defaults();
|
||||
data.recordFarming(60.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||
metrics.setSoilQuality(80.0);
|
||||
|
||||
for (int i = 0; i < 50; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
double withHighSoil = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
|
||||
.getFarmingDepletion();
|
||||
|
||||
Region region2 = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||
module.createDefaultRegionData(region2);
|
||||
ResourceRegionData data2 = ResourceRegionData.defaults();
|
||||
data2.recordFarming(60.0);
|
||||
region2.getModuleData().put(ResourceDepletionModule.MODULE_ID, data2);
|
||||
region2.getMetrics().setSoilQuality(20.0);
|
||||
|
||||
for (int i = 0; i < 50; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region2));
|
||||
}
|
||||
double withLowSoil = region2.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
|
||||
.getFarmingDepletion();
|
||||
|
||||
assertTrue(withHighSoil < withLowSoil,
|
||||
"Higher soil quality should accelerate farming regen");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resourceDepletion metric is written after update")
|
||||
void metricWritten() {
|
||||
ResourceRegionData data = ResourceRegionData.defaults();
|
||||
data.recordMining(50.0);
|
||||
data.recordLogging(40.0);
|
||||
data.recordFarming(20.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertTrue(region.getMetrics().getResourceDepletion() > 0.0,
|
||||
"Resource depletion metric should be set");
|
||||
assertTrue(region.getMetrics().getResourceDepletion() <= 100.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns noChange for pristine (zero depletion) region")
|
||||
void noChangeWhenPristine() {
|
||||
// defaults are all-zero depletion; regen of zero is zero delta
|
||||
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||
assertFalse(result.changedRegion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns changed when depletion drops by more than threshold")
|
||||
void changedWhenDepletionDrops() {
|
||||
// High vegetation triggers bonus logging regen (0.255/tick × 30% weight = 0.077 delta > 0.01)
|
||||
metrics.setVegetationPressure(100.0);
|
||||
ResourceRegionData data = ResourceRegionData.defaults();
|
||||
data.recordLogging(100.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||
|
||||
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertTrue(result.changedRegion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all depletion values stay in [0, 100] under extreme conditions")
|
||||
void depletionsBounded() {
|
||||
ResourceRegionData data = ResourceRegionData.defaults();
|
||||
data.recordMining(100.0);
|
||||
data.recordLogging(100.0);
|
||||
data.recordFarming(100.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||
|
||||
for (int i = 0; i < 500; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
|
||||
ResourceRegionData result = region.getModuleData()
|
||||
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||
assertTrue(result.getMiningDepletion() >= 0.0 && result.getMiningDepletion() <= 100.0);
|
||||
assertTrue(result.getLoggingDepletion() >= 0.0 && result.getLoggingDepletion() <= 100.0);
|
||||
assertTrue(result.getFarmingDepletion() >= 0.0 && result.getFarmingDepletion() <= 100.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.livingworld.modules.soil;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class SoilRegionDataTest {
|
||||
|
||||
@Test
|
||||
void defaultsAreHealthyValues() {
|
||||
SoilRegionData d = SoilRegionData.defaults();
|
||||
assertEquals(60.0, d.getFertility(), 1e-9);
|
||||
assertEquals(50.0, d.getMoisture(), 1e-9);
|
||||
assertEquals(0.0, d.getContamination(), 1e-9);
|
||||
assertEquals(10.0, d.getCompaction(), 1e-9);
|
||||
assertEquals(0.0, d.getErosion(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void degradeReducesFertilityAndIncreasesContaminationAndErosion() {
|
||||
SoilRegionData d = SoilRegionData.defaults();
|
||||
d.degrade(10.0);
|
||||
assertTrue(d.getFertility() < 60.0, "fertility should decrease");
|
||||
assertTrue(d.getContamination() > 0.0, "contamination should increase");
|
||||
assertTrue(d.getErosion() > 0.0, "erosion should increase");
|
||||
}
|
||||
|
||||
@Test
|
||||
void degradeByZeroChangesNothing() {
|
||||
SoilRegionData d = SoilRegionData.defaults();
|
||||
d.degrade(0.0);
|
||||
assertEquals(60.0, d.getFertility(), 1e-9);
|
||||
assertEquals(0.0, d.getContamination(), 1e-9);
|
||||
assertEquals(0.0, d.getErosion(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void degradeNegativeAmountThrows() {
|
||||
SoilRegionData d = SoilRegionData.defaults();
|
||||
assertThrows(IllegalArgumentException.class, () -> d.degrade(-1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoverIncreasesFertilityAndReducesContaminationAndErosion() {
|
||||
SoilRegionData d = new SoilRegionData(40.0, 50.0, 20.0, 10.0, 15.0);
|
||||
d.recover(10.0);
|
||||
assertTrue(d.getFertility() > 40.0, "fertility should increase");
|
||||
assertTrue(d.getContamination() < 20.0, "contamination should decrease");
|
||||
assertTrue(d.getErosion() < 15.0, "erosion should decrease");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoverNegativeAmountThrows() {
|
||||
SoilRegionData d = SoilRegionData.defaults();
|
||||
assertThrows(IllegalArgumentException.class, () -> d.recover(-1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void valuesAreClampedAbove100() {
|
||||
SoilRegionData d = new SoilRegionData(200.0, 200.0, 200.0, 200.0, 200.0);
|
||||
assertEquals(100.0, d.getFertility(), 1e-9);
|
||||
assertEquals(100.0, d.getMoisture(), 1e-9);
|
||||
assertEquals(100.0, d.getContamination(), 1e-9);
|
||||
assertEquals(100.0, d.getCompaction(), 1e-9);
|
||||
assertEquals(100.0, d.getErosion(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void valuesAreClampedBelowZero() {
|
||||
SoilRegionData d = new SoilRegionData(-10.0, -10.0, -10.0, -10.0, -10.0);
|
||||
assertEquals(0.0, d.getFertility(), 1e-9);
|
||||
assertEquals(0.0, d.getMoisture(), 1e-9);
|
||||
assertEquals(0.0, d.getContamination(), 1e-9);
|
||||
assertEquals(0.0, d.getCompaction(), 1e-9);
|
||||
assertEquals(0.0, d.getErosion(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyIsIndependent() {
|
||||
SoilRegionData original = SoilRegionData.defaults();
|
||||
SoilRegionData copy = original.copy();
|
||||
copy.degrade(30.0);
|
||||
assertEquals(60.0, original.getFertility(), 1e-9);
|
||||
assertEquals(0.0, original.getContamination(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settersClampValues() {
|
||||
SoilRegionData d = SoilRegionData.defaults();
|
||||
d.setFertility(-5.0);
|
||||
d.setErosion(999.0);
|
||||
assertEquals(0.0, d.getFertility(), 1e-9);
|
||||
assertEquals(100.0, d.getErosion(), 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.livingworld.modules.vegetation;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class VegetationRegionDataTest {
|
||||
|
||||
@Test
|
||||
void defaultsAreHealthyMixedVegetation() {
|
||||
VegetationRegionData d = VegetationRegionData.defaults();
|
||||
assertEquals(50.0, d.getGrassPressure(), 1e-9);
|
||||
assertEquals(30.0, d.getFlowerPressure(), 1e-9);
|
||||
assertEquals(30.0, d.getShrubPressure(), 1e-9);
|
||||
assertEquals(40.0, d.getTreePressure(), 1e-9);
|
||||
assertEquals(5.0, d.getDeadVegetation(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reduceFromLoggingDecreasesTressAndShrubs() {
|
||||
VegetationRegionData d = VegetationRegionData.defaults();
|
||||
d.reduceFromLogging(20.0);
|
||||
assertTrue(d.getTreePressure() < 40.0, "tree pressure should drop");
|
||||
assertTrue(d.getShrubPressure() < 30.0, "shrub pressure should drop");
|
||||
assertTrue(d.getDeadVegetation() > 5.0, "dead vegetation should increase");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reduceFromLoggingByZeroChangesNothing() {
|
||||
VegetationRegionData d = VegetationRegionData.defaults();
|
||||
d.reduceFromLogging(0.0);
|
||||
assertEquals(40.0, d.getTreePressure(), 1e-9);
|
||||
assertEquals(30.0, d.getShrubPressure(), 1e-9);
|
||||
assertEquals(5.0, d.getDeadVegetation(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reduceFromLoggingNegativeThrows() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> VegetationRegionData.defaults().reduceFromLogging(-1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoverIncreasesAllLivingPressures() {
|
||||
VegetationRegionData d = new VegetationRegionData(10.0, 5.0, 5.0, 5.0, 30.0);
|
||||
d.recover(10.0);
|
||||
assertTrue(d.getGrassPressure() > 10.0, "grass should increase");
|
||||
assertTrue(d.getFlowerPressure() > 5.0, "flowers should increase");
|
||||
assertTrue(d.getShrubPressure() > 5.0, "shrubs should increase");
|
||||
assertTrue(d.getTreePressure() > 5.0, "trees should increase");
|
||||
assertTrue(d.getDeadVegetation() < 30.0, "dead vegetation should decrease");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recoverNegativeThrows() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> VegetationRegionData.defaults().recover(-1.0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorClampsAbove100() {
|
||||
VegetationRegionData d = new VegetationRegionData(200.0, 200.0, 200.0, 200.0, 200.0);
|
||||
assertEquals(100.0, d.getGrassPressure(), 1e-9);
|
||||
assertEquals(100.0, d.getFlowerPressure(), 1e-9);
|
||||
assertEquals(100.0, d.getShrubPressure(), 1e-9);
|
||||
assertEquals(100.0, d.getTreePressure(), 1e-9);
|
||||
assertEquals(100.0, d.getDeadVegetation(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructorClampsBelowZero() {
|
||||
VegetationRegionData d = new VegetationRegionData(-1.0, -1.0, -1.0, -1.0, -1.0);
|
||||
assertEquals(0.0, d.getGrassPressure(), 1e-9);
|
||||
assertEquals(0.0, d.getFlowerPressure(), 1e-9);
|
||||
assertEquals(0.0, d.getShrubPressure(), 1e-9);
|
||||
assertEquals(0.0, d.getTreePressure(), 1e-9);
|
||||
assertEquals(0.0, d.getDeadVegetation(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void copyIsIndependent() {
|
||||
VegetationRegionData original = VegetationRegionData.defaults();
|
||||
VegetationRegionData copy = original.copy();
|
||||
copy.reduceFromLogging(40.0);
|
||||
assertEquals(40.0, original.getTreePressure(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void settersClampValues() {
|
||||
VegetationRegionData d = VegetationRegionData.defaults();
|
||||
d.setGrassPressure(-5.0);
|
||||
d.setDeadVegetation(999.0);
|
||||
assertEquals(0.0, d.getGrassPressure(), 1e-9);
|
||||
assertEquals(100.0, d.getDeadVegetation(), 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.livingworld.modules.water;
|
||||
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("WaterModule")
|
||||
class WaterModuleTest {
|
||||
|
||||
private WaterModule module;
|
||||
private RegionFactory factory;
|
||||
private Region region;
|
||||
private RegionMetrics metrics;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
module = new WaterModule();
|
||||
factory = new RegionFactory();
|
||||
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||
metrics = region.getMetrics();
|
||||
module.createDefaultRegionData(region);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moduleId is 'water'")
|
||||
void moduleId() {
|
||||
assertEquals("water", module.getModuleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("metadata is non-null and has correct id")
|
||||
void metadata() {
|
||||
assertNotNull(module.getMetadata());
|
||||
assertEquals("water", module.getMetadata().moduleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("initialize throws on null context")
|
||||
void initializeNullContextThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDefaultRegionData populates module data")
|
||||
void createDefaultRegionDataPopulates() {
|
||||
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||
module.createDefaultRegionData(fresh);
|
||||
assertTrue(fresh.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createDefaultRegionData is idempotent")
|
||||
void createDefaultRegionDataIdempotent() {
|
||||
WaterRegionData before = region.getModuleData()
|
||||
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
|
||||
module.createDefaultRegionData(region);
|
||||
WaterRegionData after = region.getModuleData()
|
||||
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
|
||||
assertEquals(before.getPurificationCapacity(), after.getPurificationCapacity(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateRegion throws on null context")
|
||||
void updateRegionNullThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("high vegetation pressure sets purification capacity")
|
||||
void highVegetationIncreasesPurification() {
|
||||
metrics.setVegetationPressure(80.0);
|
||||
metrics.setWaterQuality(50.0);
|
||||
metrics.setSoilQuality(70.0); // above leach threshold
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
WaterRegionData data = region.getModuleData()
|
||||
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
|
||||
// purification = 80 * 0.50 = 40
|
||||
assertEquals(40.0, data.getPurificationCapacity(), 0.01);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("vegetation purification raises water quality")
|
||||
void vegetationPurificationRaisesWaterQuality() {
|
||||
metrics.setVegetationPressure(80.0);
|
||||
metrics.setWaterQuality(50.0);
|
||||
metrics.setSoilQuality(70.0); // above leach threshold — no leaching
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
// Recovery = 40 * 0.01 = 0.4 gain
|
||||
assertTrue(region.getMetrics().getWaterQuality() > 50.0,
|
||||
"Water quality should rise with vegetation purification");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("low soil quality leaches contamination into water")
|
||||
void lowSoilQualityLeachesWater() {
|
||||
metrics.setVegetationPressure(0.0); // no purification
|
||||
metrics.setWaterQuality(80.0);
|
||||
metrics.setSoilQuality(10.0); // well below threshold of 40
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
// leach = (40 - 10) * 0.005 = 0.15 reduction
|
||||
assertTrue(region.getMetrics().getWaterQuality() < 80.0,
|
||||
"Water quality should fall when soil is contaminated");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("soil above threshold causes no leaching")
|
||||
void soilAboveThresholdNoLeach() {
|
||||
metrics.setVegetationPressure(0.0);
|
||||
metrics.setWaterQuality(60.0);
|
||||
metrics.setSoilQuality(50.0); // above threshold
|
||||
|
||||
double prevWQ = metrics.getWaterQuality();
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
// No leach; no purification either (veg=0). Water quality unchanged.
|
||||
assertEquals(prevWQ, region.getMetrics().getWaterQuality(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns noChange when water quality is stable")
|
||||
void noChangeWhenStable() {
|
||||
metrics.setVegetationPressure(0.0);
|
||||
metrics.setSoilQuality(50.0);
|
||||
metrics.setWaterQuality(60.0);
|
||||
|
||||
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertFalse(result.changedRegion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns changed when water quality shifts")
|
||||
void changedWhenWaterQualityShifts() {
|
||||
metrics.setVegetationPressure(80.0);
|
||||
metrics.setSoilQuality(70.0);
|
||||
metrics.setWaterQuality(50.0);
|
||||
|
||||
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertTrue(result.changedRegion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("water quality stays in [0, 100] under extreme conditions")
|
||||
void waterQualityBounded() {
|
||||
metrics.setVegetationPressure(100.0);
|
||||
metrics.setSoilQuality(0.0);
|
||||
metrics.setWaterQuality(0.0);
|
||||
|
||||
for (int i = 0; i < 200; i++) {
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
}
|
||||
|
||||
double wq = region.getMetrics().getWaterQuality();
|
||||
assertTrue(wq >= 0.0 && wq <= 100.0,
|
||||
"Water quality must stay in [0, 100], was: " + wq);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.livingworld.modules.worldeffects;
|
||||
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.RegionUpdateContext;
|
||||
import com.livingworld.modules.recovery.RecoveryModule;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||
import com.livingworld.modules.resources.ResourceRegionData;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionMetrics;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("WorldEffectsModule")
|
||||
class WorldEffectsModuleTest {
|
||||
|
||||
private WorldEffectsModule module;
|
||||
private RegionFactory factory;
|
||||
private Region region;
|
||||
private RegionMetrics metrics;
|
||||
private List<WorldEffectRequest> captured;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
module = new WorldEffectsModule();
|
||||
captured = new ArrayList<>();
|
||||
module.registerConsumer(captured::add);
|
||||
|
||||
factory = new RegionFactory();
|
||||
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||
metrics = region.getMetrics();
|
||||
module.createDefaultRegionData(region);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("moduleId is 'worldeffects'")
|
||||
void moduleId() {
|
||||
assertEquals("worldeffects", module.getModuleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("metadata is non-null with correct id")
|
||||
void metadata() {
|
||||
assertNotNull(module.getMetadata());
|
||||
assertEquals("worldeffects", module.getMetadata().moduleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("initialize throws on null context")
|
||||
void initializeNullThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("registerConsumer throws on null")
|
||||
void registerNullThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.registerConsumer(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateRegion throws on null context")
|
||||
void updateNullThrows() {
|
||||
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no consumer registered → noChange returned")
|
||||
void noConsumerNoChange() {
|
||||
WorldEffectsModule fresh = new WorldEffectsModule();
|
||||
metrics.setPollutionScore(80.0);
|
||||
ModuleUpdateResult result = fresh.updateRegion(new RegionUpdateContext(region));
|
||||
assertFalse(result.changedRegion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GRASS_DEGRADES_TO_DIRT emitted when pollution > 60 and soil < 30")
|
||||
void grassDegrades() {
|
||||
metrics.setPollutionScore(80.0);
|
||||
metrics.setSoilQuality(15.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
|
||||
assertTrue(found, "GRASS_DEGRADES_TO_DIRT should have been emitted");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GRASS_DEGRADES_TO_DIRT not emitted when pollution is low")
|
||||
void grassDegradeNotEmittedLowPollution() {
|
||||
metrics.setPollutionScore(10.0);
|
||||
metrics.setSoilQuality(15.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
|
||||
assertFalse(found, "GRASS_DEGRADES_TO_DIRT should NOT be emitted with low pollution");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("VEGETATION_SPREADS emitted when vegetationPressure > 60 and soilQuality > 50")
|
||||
void vegetationSpreads() {
|
||||
metrics.setVegetationPressure(80.0);
|
||||
metrics.setSoilQuality(70.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
|
||||
assertTrue(found, "VEGETATION_SPREADS should have been emitted");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("VEGETATION_SPREADS not emitted when soil is poor")
|
||||
void vegetationSpreadsNotEmittedPoorSoil() {
|
||||
metrics.setVegetationPressure(80.0);
|
||||
metrics.setSoilQuality(30.0); // below threshold
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
|
||||
assertFalse(found, "VEGETATION_SPREADS should NOT be emitted with poor soil");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SAPLING_GROWTH_SLOWED emitted when logging depletion > 50")
|
||||
void saplingGrowthSlowed() {
|
||||
ResourceRegionData resources = ResourceRegionData.defaults();
|
||||
resources.recordLogging(70.0);
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
|
||||
assertTrue(found, "SAPLING_GROWTH_SLOWED should have been emitted");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SAPLING_GROWTH_SLOWED not emitted when logging depletion is low")
|
||||
void saplingSlowNotEmittedLowLogging() {
|
||||
ResourceRegionData resources = ResourceRegionData.defaults();
|
||||
resources.recordLogging(20.0); // below threshold
|
||||
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
|
||||
assertFalse(found, "SAPLING_GROWTH_SLOWED should NOT be emitted with low logging");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SAPLING_GROWTH_BOOSTED emitted when succession stage >= YOUNG_WOODLAND")
|
||||
void saplingGrowthBoostedAtYoungWoodland() {
|
||||
RecoveryRegionData recovery = new RecoveryRegionData(
|
||||
SuccessionStage.YOUNG_WOODLAND, 0.0, 0.0);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
|
||||
assertTrue(found, "SAPLING_GROWTH_BOOSTED should have been emitted at YOUNG_WOODLAND");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SAPLING_GROWTH_BOOSTED not emitted below YOUNG_WOODLAND")
|
||||
void saplingBoostNotEmittedBelowYoungWoodland() {
|
||||
RecoveryRegionData recovery = new RecoveryRegionData(
|
||||
SuccessionStage.SCRUBLAND, 0.0, 0.0);
|
||||
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
|
||||
assertFalse(found, "SAPLING_GROWTH_BOOSTED should NOT be emitted below YOUNG_WOODLAND");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POLLUTION_VISUAL_INDICATOR emitted when pollutionScore > 70")
|
||||
void pollutionVisualIndicator() {
|
||||
metrics.setPollutionScore(85.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.POLLUTION_VISUAL_INDICATOR);
|
||||
assertTrue(found, "POLLUTION_VISUAL_INDICATOR should have been emitted");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("POLLUTION_VISUAL_INDICATOR not emitted below threshold")
|
||||
void pollutionVisualNotEmitted() {
|
||||
metrics.setPollutionScore(5.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
boolean found = captured.stream()
|
||||
.anyMatch(r -> r.type() == WorldEffectType.POLLUTION_VISUAL_INDICATOR);
|
||||
assertFalse(found, "POLLUTION_VISUAL_INDICATOR should NOT be emitted below threshold");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("emitted request has intensity in [0, 1]")
|
||||
void requestIntensityInRange() {
|
||||
metrics.setPollutionScore(80.0);
|
||||
metrics.setSoilQuality(15.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
for (WorldEffectRequest request : captured) {
|
||||
assertTrue(request.intensity() >= 0.0 && request.intensity() <= 1.0,
|
||||
"Intensity out of range: " + request.intensity() + " for " + request.type());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("emitted request carries the correct region coordinate")
|
||||
void requestHasCorrectCoordinate() {
|
||||
metrics.setPollutionScore(85.0);
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertFalse(captured.isEmpty(), "Should have emitted at least one request");
|
||||
assertEquals(region.getCoordinate(), captured.get(0).region());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns noChange when no effects are triggered")
|
||||
void noChangeWhenNoEffects() {
|
||||
// Default metrics: pollution=0, veg=50, soil=60 — none of the five conditions met
|
||||
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||
assertFalse(result.changedRegion());
|
||||
assertTrue(captured.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returns changed when at least one effect is triggered")
|
||||
void changedWhenEffectTriggered() {
|
||||
metrics.setPollutionScore(85.0);
|
||||
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||
assertTrue(result.changedRegion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("multiple consumers all receive the same requests")
|
||||
void multipleConsumersAllReceive() {
|
||||
List<WorldEffectRequest> second = new ArrayList<>();
|
||||
module.registerConsumer(second::add);
|
||||
metrics.setPollutionScore(85.0);
|
||||
|
||||
module.updateRegion(new RegionUpdateContext(region));
|
||||
|
||||
assertFalse(captured.isEmpty());
|
||||
assertEquals(captured.size(), second.size(),
|
||||
"Both consumers should receive the same number of requests");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NO_OP consumer can be registered without error")
|
||||
void noOpConsumer() {
|
||||
WorldEffectsModule fresh = new WorldEffectsModule();
|
||||
assertDoesNotThrow(() -> fresh.registerConsumer(WorldEffectConsumer.NO_OP));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shutdown clears all registered consumers")
|
||||
void shutdownClearsConsumers() {
|
||||
module.shutdown();
|
||||
assertTrue(module.getConsumers().isEmpty(), "Consumers should be cleared after shutdown");
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,11 @@ class LongRunSimulationTest {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Region> getActiveRegions() {
|
||||
return orderedRegions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markDirty(Region region) {
|
||||
region.markDirty();
|
||||
|
||||
Reference in New Issue
Block a user