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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> moduleRegistry,
|
||||
Supplier<SimulationManager> simulationManager,
|
||||
Function<UUID, Boolean> hudToggle,
|
||||
Function<CommandSourceStack, String> atmosphereStatus) {
|
||||
Function<CommandSourceStack, String> atmosphereStatus,
|
||||
Function<CommandSourceStack, String> 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;
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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.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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user