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: + * + * 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

+ * + * + *

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);