Add global climate layer: carbon cycle, greenhouse warming, /lw climate

GlobalClimateTracker accumulates carbonPpm each sim cycle from total regional
air pollution (emissions) minus total tree-canopy pressure (carbon sink). The
resulting warmingLevel (0–1, representing 0–5°C anomaly) feeds three effects:

- AtmosphereModule subtracts getRainPenalty() (up to -0.15) from every
  region's rain target, creating planet-wide drought as warming rises.
- Bootstrap post-sim pass raises drought risk by warming * 0.02 per cycle,
  giving warming a second, persistent drought pathway.
- Carbon state persists across sessions in living_world/global_climate.dat
  so climate change accumulates over a playthrough.

/lw climate shows: Carbon: 342 ppm (+62 ppm) | Warming: +1.1°C |
  Biodiversity: 73% | Rain penalty: -3%

Deforestation accelerates warming; reforestation reverses it — the mod now
rewards long-term forest stewardship at a planetary scale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George
2026-06-07 21:16:14 +01:00
parent c82a2afc4f
commit 178d50883e
4 changed files with 213 additions and 7 deletions
@@ -33,6 +33,7 @@ import com.livingworld.modules.vegetation.VegetationModule;
import com.livingworld.modules.vegetation.VegetationRegionData; import com.livingworld.modules.vegetation.VegetationRegionData;
import com.livingworld.modules.water.WaterModule; import com.livingworld.modules.water.WaterModule;
import com.livingworld.modules.water.WaterRegionData; import com.livingworld.modules.water.WaterRegionData;
import com.livingworld.climate.GlobalClimateTracker;
import com.livingworld.modules.atmosphere.AtmosphereModule; import com.livingworld.modules.atmosphere.AtmosphereModule;
import com.livingworld.modules.atmosphere.AtmosphereRegionData; import com.livingworld.modules.atmosphere.AtmosphereRegionData;
import com.livingworld.modules.atmosphere.Season; import com.livingworld.modules.atmosphere.Season;
@@ -82,6 +83,7 @@ public final class LivingWorldBootstrap {
private SimulationManager simulationManager; private SimulationManager simulationManager;
private WorldEffectsModule worldEffectsModule; private WorldEffectsModule worldEffectsModule;
private AtmosphereModule atmosphereModule; private AtmosphereModule atmosphereModule;
private final GlobalClimateTracker climateTracker = new GlobalClimateTracker();
private BooleanSupplier overworldRaining = () -> false; private BooleanSupplier overworldRaining = () -> false;
private LongSupplier absoluteDaySupplier = () -> 0L; private LongSupplier absoluteDaySupplier = () -> 0L;
private boolean initialized; private boolean initialized;
@@ -137,6 +139,7 @@ public final class LivingWorldBootstrap {
} }
this.worldSaveDirectory = worldSaveDirectory; this.worldSaveDirectory = worldSaveDirectory;
createServerServices(worldSaveDirectory); createServerServices(worldSaveDirectory);
climateTracker.load(worldSaveDirectory.resolve("living_world/global_climate.dat"));
this.serverReady = true; this.serverReady = true;
LivingWorldLogger.info( LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP, DiagnosticCategory.BOOTSTRAP,
@@ -204,6 +207,7 @@ public final class LivingWorldBootstrap {
} }
regionManager.flushAll(); regionManager.flushAll();
moduleRegistry.shutdownAll(); moduleRegistry.shutdownAll();
climateTracker.save(worldSaveDirectory.resolve("living_world/global_climate.dat"));
hudEnabledPlayers.clear(); hudEnabledPlayers.clear();
serverReady = false; serverReady = false;
LivingWorldLogger.info( LivingWorldLogger.info(
@@ -338,6 +342,8 @@ public final class LivingWorldBootstrap {
if (simulationManager.getSimulationTickCounter() != previousSimulationTick) { if (simulationManager.getSimulationTickCounter() != previousSimulationTick) {
spreadPollutionAcrossRegions(); spreadPollutionAcrossRegions();
applySeasonalEffects(); applySeasonalEffects();
climateTracker.update(regionManager.getActiveRegions());
applyClimateWarmingEffects();
regionManager.saveDirtyRegions(); regionManager.saveDirtyRegions();
} }
} }
@@ -388,6 +394,22 @@ public final class LivingWorldBootstrap {
} }
} }
private void applyClimateWarmingEffects() {
double warming = climateTracker.getWarmingLevel();
if (warming < 0.05) return; // no measurable effect below 5 % warming
for (Region region : regionManager.getActiveRegions()) {
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElse(null);
if (water == null) continue;
// Warming raises drought pressure everywhere; effect is tiny per cycle
// but accumulates into persistent drought at high warming levels.
water.setDroughtRisk(Math.min(100, water.getDroughtRisk() + warming * 0.02));
region.getModuleData().put(WaterModule.MODULE_ID, water);
regionManager.markDirty(region);
}
}
private void applySeasonalEffects() { private void applySeasonalEffects() {
Season season = getCurrentSeason(); Season season = getCurrentSeason();
for (Region region : regionManager.getActiveRegions()) { for (Region region : regionManager.getActiveRegions()) {
@@ -432,6 +454,20 @@ public final class LivingWorldBootstrap {
}); });
} }
/** Returns the global climate tracker (read-only access for tests and commands). */
public GlobalClimateTracker getClimateTracker() { return climateTracker; }
/** Returns a formatted global climate status string for /lw climate. */
public String getClimateStatusFor(CommandSourceStack css) {
return String.format(
"Carbon: %.0f ppm (+%.0f ppm) | Warming: +%.2f°C | Biodiversity: %.0f%% | Rain penalty: -%.0f%%",
climateTracker.getCarbonPpm(),
climateTracker.getCarbonPpm() - 280.0,
climateTracker.getTemperatureCelsius(),
climateTracker.getBiodiversityIndex(),
climateTracker.getRainPenalty() * 100);
}
/** Returns a formatted atmosphere status string for the region at the command source's position. */ /** Returns a formatted atmosphere status string for the region at the command source's position. */
public String getAtmosphereStatusFor(CommandSourceStack css) { public String getAtmosphereStatusFor(CommandSourceStack css) {
if (!serverReady) return "Server not ready."; if (!serverReady) return "Server not ready.";
@@ -469,7 +505,8 @@ public final class LivingWorldBootstrap {
() -> requireService(moduleRegistry, "moduleRegistry"), () -> requireService(moduleRegistry, "moduleRegistry"),
() -> requireService(simulationManager, "simulationManager"), () -> requireService(simulationManager, "simulationManager"),
this::toggleHud, this::toggleHud,
this::getAtmosphereStatusFor); this::getAtmosphereStatusFor,
this::getClimateStatusFor);
} }
public Path getWorldSaveDirectory() { public Path getWorldSaveDirectory() {
@@ -729,7 +766,10 @@ public final class LivingWorldBootstrap {
registry.register(new EcosystemModule()); registry.register(new EcosystemModule());
worldEffectsModule = new WorldEffectsModule(); worldEffectsModule = new WorldEffectsModule();
registry.register(worldEffectsModule); registry.register(worldEffectsModule);
atmosphereModule = new AtmosphereModule(() -> overworldRaining.getAsBoolean(), this::getCurrentSeason); atmosphereModule = new AtmosphereModule(
() -> overworldRaining.getAsBoolean(),
this::getCurrentSeason,
climateTracker::getRainPenalty);
registry.register(atmosphereModule); registry.register(atmosphereModule);
LivingWorldLogger.info( LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP, DiagnosticCategory.BOOTSTRAP,
@@ -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; // 01
private double biodiversityIndex = 60.0; // 0100
// ------------------------------------------------------------------
// 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.01.0 (0 = baseline, 1 = +5°C equivalent). */
public double getWarmingLevel() { return warmingLevel; }
/** Temperature anomaly in degrees Celsius equivalent (05°C). */
public double getTemperatureCelsius(){ return warmingLevel * 5.0; }
/** Average ecosystem health across all active regions (0100). */
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;
}
}
}
@@ -39,7 +39,8 @@ public final class LivingWorldCommandRoot {
() -> moduleRegistry, () -> moduleRegistry,
() -> simulationManager, () -> simulationManager,
uuid -> false, uuid -> false,
css -> "Atmosphere not available."); css -> "Atmosphere not available.",
css -> "Climate not available.");
} }
public static void registerDeferred( public static void registerDeferred(
@@ -48,7 +49,8 @@ public final class LivingWorldCommandRoot {
Supplier<ModuleRegistry> moduleRegistry, Supplier<ModuleRegistry> moduleRegistry,
Supplier<SimulationManager> simulationManager, Supplier<SimulationManager> simulationManager,
Function<UUID, Boolean> hudToggle, Function<UUID, Boolean> hudToggle,
Function<CommandSourceStack, String> atmosphereStatus) { Function<CommandSourceStack, String> atmosphereStatus,
Function<CommandSourceStack, String> climateStatus) {
if (dispatcher == null) { if (dispatcher == null) {
throw new IllegalArgumentException("dispatcher must not be null"); throw new IllegalArgumentException("dispatcher must not be null");
} }
@@ -112,6 +114,12 @@ public final class LivingWorldCommandRoot {
String info = atmosphereStatus.apply(context.getSource()); String info = atmosphereStatus.apply(context.getSource());
context.getSource().sendSuccess(() -> Component.literal(info), false); context.getSource().sendSuccess(() -> Component.literal(info), false);
return 1; return 1;
}))
.then(Commands.literal("climate")
.executes(context -> {
String info = climateStatus.apply(context.getSource());
context.getSource().sendSuccess(() -> Component.literal(info), false);
return 1;
}))); })));
} }
@@ -21,6 +21,7 @@ import com.livingworld.regions.Region;
import java.util.List; import java.util.List;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
import java.util.function.DoubleSupplier;
import java.util.function.Supplier; import java.util.function.Supplier;
/** /**
@@ -83,10 +84,13 @@ public final class AtmosphereModule implements SimulationModule {
private final BooleanSupplier globalRaining; private final BooleanSupplier globalRaining;
private final Supplier<Season> currentSeason; private final Supplier<Season> currentSeason;
private final DoubleSupplier globalWarmingPenalty;
public AtmosphereModule(BooleanSupplier globalRaining, Supplier<Season> currentSeason) { public AtmosphereModule(BooleanSupplier globalRaining, Supplier<Season> currentSeason,
DoubleSupplier globalWarmingPenalty) {
this.globalRaining = globalRaining; this.globalRaining = globalRaining;
this.currentSeason = currentSeason; this.currentSeason = currentSeason;
this.globalWarmingPenalty = globalWarmingPenalty;
} }
@Override public String getModuleId() { return MODULE_ID; } @Override public String getModuleId() { return MODULE_ID; }
@@ -140,6 +144,7 @@ public final class AtmosphereModule implements SimulationModule {
double seasonMod = currentSeason.get().rainModifier(); double seasonMod = currentSeason.get().rainModifier();
double targetRain = clamp01(ecosystemHealth / 100.0 * MAX_RAIN_FROM_ECO double targetRain = clamp01(ecosystemHealth / 100.0 * MAX_RAIN_FROM_ECO
+ seasonMod + seasonMod
- globalWarmingPenalty.getAsDouble()
+ (mcRaining ? GLOBAL_RAIN_BIAS : 0.0)); + (mcRaining ? GLOBAL_RAIN_BIAS : 0.0));
double targetThunder = clamp01(pollScore / 100.0 * 0.8); double targetThunder = clamp01(pollScore / 100.0 * 0.8);