diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java
index 2cd9064..d86a3a2 100644
--- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java
+++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java
@@ -33,6 +33,7 @@ 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.climate.GlobalClimateTracker;
import com.livingworld.modules.atmosphere.AtmosphereModule;
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
import com.livingworld.modules.atmosphere.Season;
@@ -82,6 +83,7 @@ public final class LivingWorldBootstrap {
private SimulationManager simulationManager;
private WorldEffectsModule worldEffectsModule;
private AtmosphereModule atmosphereModule;
+ private final GlobalClimateTracker climateTracker = new GlobalClimateTracker();
private BooleanSupplier overworldRaining = () -> false;
private LongSupplier absoluteDaySupplier = () -> 0L;
private boolean initialized;
@@ -137,6 +139,7 @@ public final class LivingWorldBootstrap {
}
this.worldSaveDirectory = worldSaveDirectory;
createServerServices(worldSaveDirectory);
+ climateTracker.load(worldSaveDirectory.resolve("living_world/global_climate.dat"));
this.serverReady = true;
LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP,
@@ -204,6 +207,7 @@ public final class LivingWorldBootstrap {
}
regionManager.flushAll();
moduleRegistry.shutdownAll();
+ climateTracker.save(worldSaveDirectory.resolve("living_world/global_climate.dat"));
hudEnabledPlayers.clear();
serverReady = false;
LivingWorldLogger.info(
@@ -338,6 +342,8 @@ public final class LivingWorldBootstrap {
if (simulationManager.getSimulationTickCounter() != previousSimulationTick) {
spreadPollutionAcrossRegions();
applySeasonalEffects();
+ climateTracker.update(regionManager.getActiveRegions());
+ applyClimateWarmingEffects();
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() {
Season season = getCurrentSeason();
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. */
public String getAtmosphereStatusFor(CommandSourceStack css) {
if (!serverReady) return "Server not ready.";
@@ -469,7 +505,8 @@ public final class LivingWorldBootstrap {
() -> requireService(moduleRegistry, "moduleRegistry"),
() -> requireService(simulationManager, "simulationManager"),
this::toggleHud,
- this::getAtmosphereStatusFor);
+ this::getAtmosphereStatusFor,
+ this::getClimateStatusFor);
}
public Path getWorldSaveDirectory() {
@@ -729,7 +766,10 @@ public final class LivingWorldBootstrap {
registry.register(new EcosystemModule());
worldEffectsModule = new WorldEffectsModule();
registry.register(worldEffectsModule);
- atmosphereModule = new AtmosphereModule(() -> overworldRaining.getAsBoolean(), this::getCurrentSeason);
+ atmosphereModule = new AtmosphereModule(
+ () -> overworldRaining.getAsBoolean(),
+ this::getCurrentSeason,
+ climateTracker::getRainPenalty);
registry.register(atmosphereModule);
LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP,
diff --git a/src/main/java/com/livingworld/climate/GlobalClimateTracker.java b/src/main/java/com/livingworld/climate/GlobalClimateTracker.java
new file mode 100644
index 0000000..dadfa2b
--- /dev/null
+++ b/src/main/java/com/livingworld/climate/GlobalClimateTracker.java
@@ -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.
+ *
+ *
Carbon cycle
+ * Each simulation cycle, the tracker aggregates:
+ *
+ * - Emissions — proportional to total air pollution across all active regions.
+ *
- Sink — proportional to total tree canopy pressure across all active regions.
+ *
+ * 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.
+ *
+ * Derived effects
+ *
+ * - {@link #getWarmingLevel()} — 0.0 (pre-industrial) to 1.0 (catastrophic, +5°C).
+ *
- {@link #getRainPenalty()} — subtracted from every region's atmospheric rain target.
+ *
- {@link #getBiodiversityIndex()} — average ecosystem health across all active regions.
+ *
+ *
+ * Persistence
+ * 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 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;
+ }
+ }
+}
diff --git a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java
index db0513f..8d0788b 100644
--- a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java
+++ b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java
@@ -39,7 +39,8 @@ public final class LivingWorldCommandRoot {
() -> moduleRegistry,
() -> simulationManager,
uuid -> false,
- css -> "Atmosphere not available.");
+ css -> "Atmosphere not available.",
+ css -> "Climate not available.");
}
public static void registerDeferred(
@@ -48,7 +49,8 @@ public final class LivingWorldCommandRoot {
Supplier moduleRegistry,
Supplier simulationManager,
Function hudToggle,
- Function atmosphereStatus) {
+ Function atmosphereStatus,
+ Function climateStatus) {
if (dispatcher == null) {
throw new IllegalArgumentException("dispatcher must not be null");
}
@@ -112,6 +114,12 @@ public final class LivingWorldCommandRoot {
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;
})));
}
diff --git a/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java b/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java
index 4f9d0f8..6b9b0fb 100644
--- a/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java
+++ b/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java
@@ -21,6 +21,7 @@ import com.livingworld.regions.Region;
import java.util.List;
import java.util.function.BooleanSupplier;
+import java.util.function.DoubleSupplier;
import java.util.function.Supplier;
/**
@@ -83,10 +84,13 @@ public final class AtmosphereModule implements SimulationModule {
private final BooleanSupplier globalRaining;
private final Supplier currentSeason;
+ private final DoubleSupplier globalWarmingPenalty;
- public AtmosphereModule(BooleanSupplier globalRaining, Supplier currentSeason) {
- this.globalRaining = globalRaining;
- this.currentSeason = currentSeason;
+ public AtmosphereModule(BooleanSupplier globalRaining, Supplier currentSeason,
+ DoubleSupplier globalWarmingPenalty) {
+ this.globalRaining = globalRaining;
+ this.currentSeason = currentSeason;
+ this.globalWarmingPenalty = globalWarmingPenalty;
}
@Override public String getModuleId() { return MODULE_ID; }
@@ -140,6 +144,7 @@ public final class AtmosphereModule implements SimulationModule {
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);