diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 74493c9..68554bc 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -33,9 +33,11 @@ import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.storage.LevelResource; +import net.minecraft.network.protocol.game.ClientboundGameEventPacket; import net.neoforged.neoforge.event.tick.ServerTickEvent; import com.livingworld.bootstrap.LivingWorldBootstrap; +import com.livingworld.modules.atmosphere.AtmosphereRegionData; import com.livingworld.core.LivingWorldConstants; import com.livingworld.debug.DiagnosticCategory; import com.livingworld.debug.LivingWorldLogger; @@ -85,6 +87,7 @@ public class LivingWorldMod { private int furnaceScanTick = 0; private int playerCheckTick = 0; private final Map playerRegionCache = new HashMap<>(); + private final Map playerRainState = new HashMap<>(); private final Set biomeInitialized = new HashSet<>(); public LivingWorldMod(IEventBus eventBus) { @@ -115,6 +118,7 @@ public class LivingWorldMod { bootstrap.onServerStopping(); bootstrap.setOverworldRaining(null); playerRegionCache.clear(); + playerRainState.clear(); biomeInitialized.clear(); this.minecraftServer = null; }); @@ -235,13 +239,33 @@ public class LivingWorldMod { } } - // Step 2: Compass HUD — display region health while holding a compass, or debug HUD is on. + // Regional weather — send per-player rain/thunder packets so each region + // has its own sky independently of global Minecraft weather. + AtmosphereRegionData atm = bootstrap.getRegionalWeather(coord).orElse(null); + if (atm != null) { + float rainLevel = (float) atm.getRainLevel(); + float thunderLevel = (float) atm.getThunderLevel(); + boolean wasRaining = playerRainState.getOrDefault(player.getUUID(), false); + boolean nowRaining = rainLevel > 0.1f; + if (nowRaining != wasRaining) { + player.connection.send(nowRaining + ? new ClientboundGameEventPacket(ClientboundGameEventPacket.START_RAINING, 0f) + : new ClientboundGameEventPacket(ClientboundGameEventPacket.STOP_RAINING, 0f)); + playerRainState.put(player.getUUID(), nowRaining); + } + player.connection.send(new ClientboundGameEventPacket( + ClientboundGameEventPacket.RAIN_LEVEL_CHANGE, rainLevel)); + player.connection.send(new ClientboundGameEventPacket( + ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, thunderLevel)); + } + + // Compass HUD — display region health while holding a compass, or debug HUD is on. boolean showHud = player.getMainHandItem().is(Items.COMPASS) || player.getOffhandItem().is(Items.COMPASS) || bootstrap.isHudEnabled(player.getUUID()); if (showHud) { Optional metricsOpt = bootstrap.getMetricsAt(dimId, player.getX(), player.getZ()); - metricsOpt.ifPresent(m -> player.displayClientMessage(buildHud(coord, m), true)); + metricsOpt.ifPresent(m -> player.displayClientMessage(buildHud(coord, m, atm), true)); } } } @@ -272,8 +296,8 @@ public class LivingWorldMod { } } - /** Step 2: Builds the action-bar HUD component for a player holding a compass. */ - private Component buildHud(RegionCoordinate coord, RegionMetrics m) { + /** Builds the action-bar HUD component for a player holding a compass or with /lw hud on. */ + private Component buildHud(RegionCoordinate coord, RegionMetrics m, AtmosphereRegionData atm) { double eco = m.getEcosystemHealth(); double poll = m.getPollutionScore(); double soil = m.getSoilQuality(); @@ -284,7 +308,7 @@ public class LivingWorldMod { ChatFormatting soilCol = soil > 50 ? ChatFormatting.GREEN : soil > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED; ChatFormatting watCol = wat > 50 ? ChatFormatting.GREEN : wat > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED; - return Component.empty() + var line = Component.empty() .append(Component.literal("[LW] ").withStyle(ChatFormatting.GOLD)) .append(Component.literal(String.format("(%d,%d) ", coord.x(), coord.z())).withStyle(ChatFormatting.GRAY)) .append(Component.literal("Eco:").withStyle(ChatFormatting.WHITE)) @@ -295,5 +319,19 @@ public class LivingWorldMod { .append(Component.literal(String.format("%.0f ", soil)).withStyle(soilCol)) .append(Component.literal("Wat:").withStyle(ChatFormatting.WHITE)) .append(Component.literal(String.format("%.0f", wat)).withStyle(watCol)); + + if (atm != null) { + double rain = atm.getRainLevel() * 100; + double thunder = atm.getThunderLevel() * 100; + ChatFormatting rainCol = rain > 50 ? ChatFormatting.AQUA : rain > 20 ? ChatFormatting.YELLOW : ChatFormatting.RED; + line.append(Component.literal(" Rain:").withStyle(ChatFormatting.WHITE)) + .append(Component.literal(String.format("%.0f%%", rain)).withStyle(rainCol)); + if (thunder > 15) { + ChatFormatting stormCol = thunder > 50 ? ChatFormatting.RED : ChatFormatting.YELLOW; + line.append(Component.literal(" Storm:").withStyle(ChatFormatting.WHITE)) + .append(Component.literal(String.format("%.0f%%", thunder)).withStyle(stormCol)); + } + } + return line; } } diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index e53a601..fdb0db2 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -33,6 +33,8 @@ 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.modules.atmosphere.AtmosphereModule; +import com.livingworld.modules.atmosphere.AtmosphereRegionData; import com.livingworld.modules.worldeffects.WorldEffectsModule; import com.livingworld.platform.BlockBreakInfo; import com.livingworld.platform.PlatformAdapter; @@ -63,13 +65,8 @@ import net.minecraft.commands.CommandSourceStack; */ public final class LivingWorldBootstrap { - 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; - private static final double ACID_RAIN_THRESHOLD = 20.0; - private static final double ACID_FERTILITY_DRAIN = 0.0005; - private static final double WIND_BOOST = 0.5; - private static final double WIND_DRIFT_MAX = 0.05; // radians per sim cycle + private static final double WIND_BOOST = 0.5; + private static final double WIND_DRIFT_MAX = 0.05; // radians per sim cycle private double windAngle = 0.0; private final Random windRandom = new Random(); @@ -82,6 +79,7 @@ public final class LivingWorldBootstrap { private ModuleRegistry moduleRegistry; private SimulationManager simulationManager; private WorldEffectsModule worldEffectsModule; + private AtmosphereModule atmosphereModule; private BooleanSupplier overworldRaining = () -> false; private boolean initialized; private boolean serverReady; @@ -327,7 +325,6 @@ public final class LivingWorldBootstrap { long previousSimulationTick = simulationManager.getSimulationTickCounter(); simulationManager.onMinecraftServerTick(); if (simulationManager.getSimulationTickCounter() != previousSimulationTick) { - applyWeatherFeedback(); spreadPollutionAcrossRegions(); regionManager.saveDirtyRegions(); } @@ -379,43 +376,11 @@ public final class LivingWorldBootstrap { } } - private void applyWeatherFeedback() { - boolean raining = overworldRaining.getAsBoolean(); - for (Region region : regionManager.getActiveRegions()) { - if (!"minecraft:overworld".equals(region.getCoordinate().dimensionId())) { - continue; - } - WaterRegionData water = region.getModuleData() - .get(WaterModule.MODULE_ID, WaterRegionData.class) - .orElse(null); - if (water == null) { - continue; - } - if (raining) { - water.setWaterAvailability(water.getWaterAvailability() + RAIN_MOISTURE_GAIN); - water.setDroughtRisk(water.getDroughtRisk() - RAIN_DROUGHT_RELIEF); - - // Acid rain: polluted rainfall degrades soil and water quality. - double pollutionScore = region.getMetrics().getPollutionScore(); - if (pollutionScore > ACID_RAIN_THRESHOLD) { - double acidStrength = (pollutionScore - ACID_RAIN_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)); - } - } else { - water.setDroughtRisk(water.getDroughtRisk() + DRY_DROUGHT_INCREASE); - } - region.getModuleData().put(WaterModule.MODULE_ID, water); - regionManager.markDirty(region); - } + /** Returns the current atmospheric state for a region, if it has been initialised. */ + public Optional getRegionalWeather(RegionCoordinate coord) { + if (!serverReady || coord == null) return Optional.empty(); + return regionManager.resolve(coord) + .flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class)); } /** Toggles the debug HUD for the given player. Returns true if HUD is now enabled. */ @@ -665,6 +630,18 @@ public final class LivingWorldBootstrap { r.readDouble("stress", 20.0), r.readDouble("resilience", 50.0), r.readDouble("recoveryRate", 5.0)))); + + service.registerModuleCodec( + AtmosphereModule.MODULE_ID, + (data, w) -> { + AtmosphereRegionData d = data.get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class) + .orElseGet(AtmosphereRegionData::defaults); + w.writeDouble("rainLevel", d.getRainLevel()); + w.writeDouble("thunderLevel", d.getThunderLevel()); + }, + (r, data) -> data.put(AtmosphereModule.MODULE_ID, new AtmosphereRegionData( + r.readDouble("rainLevel", 0.4), + r.readDouble("thunderLevel", 0.0)))); } /** @@ -684,10 +661,12 @@ public final class LivingWorldBootstrap { registry.register(new EcosystemModule()); worldEffectsModule = new WorldEffectsModule(); registry.register(worldEffectsModule); + atmosphereModule = new AtmosphereModule(() -> overworldRaining.getAsBoolean()); + registry.register(atmosphereModule); LivingWorldLogger.info( DiagnosticCategory.BOOTSTRAP, - "Registered 8 ecosystem modules (pollution → soil → water → vegetation" - + " → resources → recovery → ecosystem → worldeffects)."); + "Registered 9 ecosystem modules (pollution → soil → water → vegetation" + + " → resources → recovery → ecosystem → worldeffects → atmosphere)."); } private void requireInitialized() { diff --git a/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java b/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java new file mode 100644 index 0000000..6eb52d3 --- /dev/null +++ b/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java @@ -0,0 +1,184 @@ +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; + +/** + * Drives per-region weather from ecosystem state and applies atmospheric feedback + * to other simulation layers. + * + *

Pipeline position

+ * Runs last (after WorldEffects) so ecosystem health and pollution scores are final + * before weather targets are computed. + * + *

Per-cycle rules

+ *
    + *
  1. Tree canopy scrubs air pollution: {@code airPollution -= treePressure × TREE_SCRUB_RATE}. + *
  2. Target rain level is derived from ecosystem health (healthy = wet, degraded = drought). + * Global Minecraft rain adds a small bias so natural weather still matters. + *
  3. Target thunder level is derived from pollution (polluted = stormy). + *
  4. Rain and thunder smoothly interpolate toward their targets each cycle. + *
  5. Rain fills water availability; drought raises drought risk. + *
  6. High thunder + high pollution triggers acid rain (drains soil fertility). + *
+ */ +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; + + public AtmosphereModule(BooleanSupplier globalRaining) { + this.globalRaining = globalRaining; + } + + @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 targetRain = clamp01(ecosystemHealth / 100.0 * MAX_RAIN_FROM_ECO + + (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)); } +} diff --git a/src/main/java/com/livingworld/modules/atmosphere/AtmosphereRegionData.java b/src/main/java/com/livingworld/modules/atmosphere/AtmosphereRegionData.java new file mode 100644 index 0000000..7706091 --- /dev/null +++ b/src/main/java/com/livingworld/modules/atmosphere/AtmosphereRegionData.java @@ -0,0 +1,44 @@ +package com.livingworld.modules.atmosphere; + +/** + * Per-region atmospheric state tracked by {@link AtmosphereModule}. + * + *

Both values smoothly interpolate toward ecosystem-derived targets each + * simulation cycle and are used to drive per-player weather packets. + * + *

    + *
  • rainLevel – 0.0 = drought, 1.0 = heavy rainfall + *
  • thunderLevel – 0.0 = calm, 1.0 = heavy storm (acid rain above 0.4) + *
+ */ +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 + "}"; + } +}