Add seasons, player pollution effects, wildfire, fog, water body boost, /lw atmosphere

Seasons (Step 1)
- Season enum (SPRING/SUMMER/AUTUMN/WINTER) derived from gameTime / 24000 / 8.
- AtmosphereModule applies seasonal rain modifier: spring +10%, summer -15%,
  winter -25%. Cascades naturally through water → soil → succession.
- Bootstrap post-sim pass: spring boosts soil fertility (+0.003/cycle),
  winter draws moisture out of topsoil (-0.002/cycle).

Player pollution effects (Step 2)
- Poll > 40: nausea (MobEffects.CONFUSION in 1.21.1); > 60: slowness; > 80: weakness.
- Duration 100 ticks, refreshed each PLAYER_CHECK_INTERVAL (1 s). Expires
  5 s after the player leaves the polluted region.

Wildfire (Step 3)
- WILDFIRE added to WorldEffectType.
- WorldEffectsModule emits WILDFIRE when droughtRisk > 70 AND thunderLevel > 0.5
  with 1% probability per sim cycle.
- NeoForgeWorldEffectExecutor places fire blocks on surface grass/leaves/logs;
  Minecraft fire spreading takes over from there.

Fog (Step 4)
- When pollutionScore > 40, 1–3 SMOKE particles sent per check interval at
  player eye-level via ServerLevel.sendParticles(player, ...) — per-player only,
  not broadcast to everyone.

Water body passive boost (Step 5)
- On first entry to a region, hasWaterBody() samples 30 surface positions.
  If ≥5 are water, applyWaterBodyBoost() permanently raises purification
  capacity +15, water availability +10, and lowers drought risk -10.

/lw atmosphere command (Step 6)
- Shows: Region (x,z) | Season: Spring | Rain: 72% | Storm: 18%
- Wired via Function<CommandSourceStack, String> atmosphereStatus parameter
  added to LivingWorldCommandRoot.registerDeferred().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George
2026-06-07 20:42:41 +01:00
parent 61abff52dc
commit c82a2afc4f
8 changed files with 247 additions and 13 deletions
@@ -33,7 +33,11 @@ import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource; import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.network.protocol.game.ClientboundGameEventPacket; import net.minecraft.network.protocol.game.ClientboundGameEventPacket;
import net.minecraft.tags.FluidTags;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.neoforged.neoforge.event.tick.ServerTickEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent;
import com.livingworld.bootstrap.LivingWorldBootstrap; import com.livingworld.bootstrap.LivingWorldBootstrap;
@@ -89,6 +93,7 @@ public class LivingWorldMod {
private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>(); private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>();
private final Map<UUID, Boolean> playerRainState = new HashMap<>(); private final Map<UUID, Boolean> playerRainState = new HashMap<>();
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>(); private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
private final Set<RegionCoordinate> waterBodyInitialized = new HashSet<>();
public LivingWorldMod(IEventBus eventBus) { public LivingWorldMod(IEventBus eventBus) {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting..."); LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
@@ -113,13 +118,17 @@ public class LivingWorldMod {
new NeoForgeWorldEffectExecutor(() -> minecraftServer)); new NeoForgeWorldEffectExecutor(() -> minecraftServer));
bootstrap.setOverworldRaining( bootstrap.setOverworldRaining(
() -> minecraftServer != null && minecraftServer.overworld().isRaining()); () -> minecraftServer != null && minecraftServer.overworld().isRaining());
bootstrap.setAbsoluteDaySupplier(
() -> minecraftServer != null ? minecraftServer.overworld().getGameTime() / 24000L : 0L);
}); });
NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> { NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> {
bootstrap.onServerStopping(); bootstrap.onServerStopping();
bootstrap.setOverworldRaining(null); bootstrap.setOverworldRaining(null);
bootstrap.setAbsoluteDaySupplier(null);
playerRegionCache.clear(); playerRegionCache.clear();
playerRainState.clear(); playerRainState.clear();
biomeInitialized.clear(); biomeInitialized.clear();
waterBodyInitialized.clear();
this.minecraftServer = null; this.minecraftServer = null;
}); });
@@ -230,13 +239,21 @@ public class LivingWorldMod {
if (!coord.equals(previous)) { if (!coord.equals(previous)) {
bootstrap.notifyPlayerInRegion(coord); bootstrap.notifyPlayerInRegion(coord);
// Step 6: Biome-aware succession — derive and apply cap once per region. if (player.level() instanceof ServerLevel serverLevel) {
if (!biomeInitialized.contains(coord) // Biome-aware succession cap — derived once per region.
&& player.level() instanceof ServerLevel serverLevel) { if (!biomeInitialized.contains(coord)) {
SuccessionStage cap = deriveBiomeCap(serverLevel, coord); SuccessionStage cap = deriveBiomeCap(serverLevel, coord);
bootstrap.setRegionBiomeCap(coord, cap); bootstrap.setRegionBiomeCap(coord, cap);
biomeInitialized.add(coord); biomeInitialized.add(coord);
} }
// Water body passive boost — scanned once per region.
if (!waterBodyInitialized.contains(coord)) {
if (hasWaterBody(serverLevel, coord)) {
bootstrap.applyWaterBodyBoost(coord);
}
waterBodyInitialized.add(coord);
}
}
} }
// Regional weather — send per-player rain/thunder packets so each region // Regional weather — send per-player rain/thunder packets so each region
@@ -259,17 +276,62 @@ public class LivingWorldMod {
ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, thunderLevel)); ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, thunderLevel));
} }
// Player effects from pollution — applied/refreshed each check interval.
Optional<RegionMetrics> metricsOpt = bootstrap.getMetricsAt(dimId, player.getX(), player.getZ());
metricsOpt.ifPresent(m -> {
double poll = m.getPollutionScore();
if (poll > 40) {
// CONFUSION = nausea in 1.21.1 (registry name "nausea", Java constant CONFUSION)
player.addEffect(new MobEffectInstance(MobEffects.CONFUSION, 100, 0, true, false));
}
if (poll > 60) {
player.addEffect(new MobEffectInstance(MobEffects.MOVEMENT_SLOWDOWN, 100, 0, true, false));
}
if (poll > 80) {
player.addEffect(new MobEffectInstance(MobEffects.WEAKNESS, 100, 0, true, false));
}
// Fog particles — sent only to this player via the targeted sendParticles overload.
if (poll > 40 && player.level() instanceof ServerLevel sl) {
int fogCount = Math.min(3, (int) ((poll - 40) / 20.0) + 1);
for (int f = 0; f < fogCount; f++) {
double px = player.getX() + (random.nextDouble() - 0.5) * 6;
double py = player.getEyeY() + (random.nextDouble() - 0.5);
double pz = player.getZ() + (random.nextDouble() - 0.5) * 6;
sl.sendParticles(player, ParticleTypes.SMOKE, false, px, py, pz, 1, 0.4, 0.2, 0.4, 0.01);
}
}
});
// Compass HUD — display region health while holding a compass, or debug HUD is on. // Compass HUD — display region health while holding a compass, or debug HUD is on.
boolean showHud = player.getMainHandItem().is(Items.COMPASS) boolean showHud = player.getMainHandItem().is(Items.COMPASS)
|| player.getOffhandItem().is(Items.COMPASS) || player.getOffhandItem().is(Items.COMPASS)
|| bootstrap.isHudEnabled(player.getUUID()); || bootstrap.isHudEnabled(player.getUUID());
if (showHud) { if (showHud) {
Optional<RegionMetrics> metricsOpt = bootstrap.getMetricsAt(dimId, player.getX(), player.getZ());
metricsOpt.ifPresent(m -> player.displayClientMessage(buildHud(coord, m, atm), true)); metricsOpt.ifPresent(m -> player.displayClientMessage(buildHud(coord, m, atm), true));
} }
} }
} }
private static final int REGION_BLOCKS =
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16;
/** Scans 30 random surface positions in the region; returns true if ≥5 are water. */
private boolean hasWaterBody(ServerLevel level, RegionCoordinate coord) {
int baseX = coord.x() * REGION_BLOCKS;
int baseZ = coord.z() * REGION_BLOCKS;
int waterCount = 0;
for (int i = 0; i < 30; i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z) - 1;
if (y >= level.getMinBuildHeight()
&& level.getFluidState(new BlockPos(x, y, z)).is(FluidTags.WATER)) {
waterCount++;
}
}
return waterCount >= 5;
}
/** Step 6: Samples the dominant biome at the region centre and returns a succession ceiling. */ /** Step 6: Samples the dominant biome at the region centre and returns a succession ceiling. */
private SuccessionStage deriveBiomeCap(ServerLevel level, RegionCoordinate coord) { private SuccessionStage deriveBiomeCap(ServerLevel level, RegionCoordinate coord) {
int cx = coord.x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 + 64; int cx = coord.x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 + 64;
@@ -35,6 +35,7 @@ import com.livingworld.modules.water.WaterModule;
import com.livingworld.modules.water.WaterRegionData; import com.livingworld.modules.water.WaterRegionData;
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.worldeffects.WorldEffectsModule; import com.livingworld.modules.worldeffects.WorldEffectsModule;
import com.livingworld.platform.BlockBreakInfo; import com.livingworld.platform.BlockBreakInfo;
import com.livingworld.platform.PlatformAdapter; import com.livingworld.platform.PlatformAdapter;
@@ -52,6 +53,7 @@ import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.function.LongSupplier;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Random; import java.util.Random;
@@ -81,6 +83,7 @@ public final class LivingWorldBootstrap {
private WorldEffectsModule worldEffectsModule; private WorldEffectsModule worldEffectsModule;
private AtmosphereModule atmosphereModule; private AtmosphereModule atmosphereModule;
private BooleanSupplier overworldRaining = () -> false; private BooleanSupplier overworldRaining = () -> false;
private LongSupplier absoluteDaySupplier = () -> 0L;
private boolean initialized; private boolean initialized;
private boolean serverReady; private boolean serverReady;
@@ -318,6 +321,14 @@ public final class LivingWorldBootstrap {
this.overworldRaining = supplier != null ? supplier : () -> false; this.overworldRaining = supplier != null ? supplier : () -> false;
} }
public void setAbsoluteDaySupplier(LongSupplier supplier) {
this.absoluteDaySupplier = supplier != null ? supplier : () -> 0L;
}
public Season getCurrentSeason() {
return Season.fromAbsoluteDay(absoluteDaySupplier.getAsLong());
}
public void onServerTick() { public void onServerTick() {
if (!serverReady) { if (!serverReady) {
return; return;
@@ -326,6 +337,7 @@ public final class LivingWorldBootstrap {
simulationManager.onMinecraftServerTick(); simulationManager.onMinecraftServerTick();
if (simulationManager.getSimulationTickCounter() != previousSimulationTick) { if (simulationManager.getSimulationTickCounter() != previousSimulationTick) {
spreadPollutionAcrossRegions(); spreadPollutionAcrossRegions();
applySeasonalEffects();
regionManager.saveDirtyRegions(); regionManager.saveDirtyRegions();
} }
} }
@@ -376,6 +388,27 @@ public final class LivingWorldBootstrap {
} }
} }
private void applySeasonalEffects() {
Season season = getCurrentSeason();
for (Region region : regionManager.getActiveRegions()) {
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (soil == null) continue;
switch (season) {
case SPRING ->
// Snowmelt nutrients give soil a mild fertility boost.
soil.setFertility(Math.min(100, soil.getFertility() + 0.003));
case WINTER ->
// Frost draws moisture out of the topsoil.
soil.setMoisture(Math.max(0, soil.getMoisture() - 0.002));
default -> { /* SUMMER and AUTUMN have no direct soil effect. */ }
}
region.getModuleData().put(SoilModule.MODULE_ID, soil);
regionManager.markDirty(region);
}
}
/** Returns the current atmospheric state for a region, if it has been initialised. */ /** Returns the current atmospheric state for a region, if it has been initialised. */
public Optional<AtmosphereRegionData> getRegionalWeather(RegionCoordinate coord) { public Optional<AtmosphereRegionData> getRegionalWeather(RegionCoordinate coord) {
if (!serverReady || coord == null) return Optional.empty(); if (!serverReady || coord == null) return Optional.empty();
@@ -383,6 +416,40 @@ public final class LivingWorldBootstrap {
.flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class)); .flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class));
} }
/** Applies a water quality and purification bonus to regions adjacent to or containing water bodies. */
public void applyWaterBodyBoost(RegionCoordinate coord) {
if (!serverReady || coord == null) return;
regionManager.resolve(coord).ifPresent(region -> {
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElse(null);
if (water == null) return;
water.setPurificationCapacity(Math.min(100, water.getPurificationCapacity() + 15.0));
water.setWaterAvailability(Math.min(100, water.getWaterAvailability() + 10.0));
water.setDroughtRisk(Math.max(0, water.getDroughtRisk() - 10.0));
region.getModuleData().put(WaterModule.MODULE_ID, water);
regionManager.markDirty(region);
});
}
/** 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.";
String dimId = css.getLevel().dimension().location().toString();
var pos = css.getPosition();
RegionCoordinate coord = RegionCoordinate.fromBlock(
dimId, (int) pos.x, (int) pos.z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
Season season = getCurrentSeason();
return regionManager.resolve(coord)
.flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class))
.map(atm -> String.format(
"Region (%d,%d) | Season: %s | Rain: %.0f%% | Storm: %.0f%%",
coord.x(), coord.z(), season.displayName(),
atm.getRainLevel() * 100, atm.getThunderLevel() * 100))
.orElse(String.format("Region (%d,%d) — atmosphere not yet computed. Season: %s",
coord.x(), coord.z(), season.displayName()));
}
/** Toggles the debug HUD for the given player. Returns true if HUD is now enabled. */ /** Toggles the debug HUD for the given player. Returns true if HUD is now enabled. */
public boolean toggleHud(UUID playerId) { public boolean toggleHud(UUID playerId) {
if (hudEnabledPlayers.remove(playerId)) return false; if (hudEnabledPlayers.remove(playerId)) return false;
@@ -401,7 +468,8 @@ public final class LivingWorldBootstrap {
() -> requireService(regionManager, "regionManager"), () -> requireService(regionManager, "regionManager"),
() -> requireService(moduleRegistry, "moduleRegistry"), () -> requireService(moduleRegistry, "moduleRegistry"),
() -> requireService(simulationManager, "simulationManager"), () -> requireService(simulationManager, "simulationManager"),
this::toggleHud); this::toggleHud,
this::getAtmosphereStatusFor);
} }
public Path getWorldSaveDirectory() { public Path getWorldSaveDirectory() {
@@ -661,7 +729,7 @@ 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()); atmosphereModule = new AtmosphereModule(() -> overworldRaining.getAsBoolean(), this::getCurrentSeason);
registry.register(atmosphereModule); registry.register(atmosphereModule);
LivingWorldLogger.info( LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP, DiagnosticCategory.BOOTSTRAP,
@@ -38,7 +38,8 @@ public final class LivingWorldCommandRoot {
() -> regionManager, () -> regionManager,
() -> moduleRegistry, () -> moduleRegistry,
() -> simulationManager, () -> simulationManager,
uuid -> false); uuid -> false,
css -> "Atmosphere not available.");
} }
public static void registerDeferred( public static void registerDeferred(
@@ -46,7 +47,8 @@ public final class LivingWorldCommandRoot {
Supplier<RegionManager> regionManager, Supplier<RegionManager> regionManager,
Supplier<ModuleRegistry> moduleRegistry, Supplier<ModuleRegistry> moduleRegistry,
Supplier<SimulationManager> simulationManager, Supplier<SimulationManager> simulationManager,
Function<UUID, Boolean> hudToggle) { Function<UUID, Boolean> hudToggle,
Function<CommandSourceStack, String> atmosphereStatus) {
if (dispatcher == null) { if (dispatcher == null) {
throw new IllegalArgumentException("dispatcher must not be null"); throw new IllegalArgumentException("dispatcher must not be null");
} }
@@ -104,7 +106,13 @@ public final class LivingWorldCommandRoot {
requireService(simulationManager, "simulationManager"), requireService(simulationManager, "simulationManager"),
IntegerArgumentType.getInteger(context, "ticks"))))) IntegerArgumentType.getInteger(context, "ticks")))))
.then(Commands.literal("hud") .then(Commands.literal("hud")
.executes(context -> toggleHud(context.getSource(), hudToggle)))); .executes(context -> toggleHud(context.getSource(), hudToggle)))
.then(Commands.literal("atmosphere")
.executes(context -> {
String info = atmosphereStatus.apply(context.getSource());
context.getSource().sendSuccess(() -> Component.literal(info), false);
return 1;
})));
} }
private static int toggleHud(CommandSourceStack source, Function<UUID, Boolean> hudToggle) { private static int toggleHud(CommandSourceStack source, Function<UUID, Boolean> hudToggle) {
@@ -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.Supplier;
/** /**
* Drives per-region weather from ecosystem state and applies atmospheric feedback * Drives per-region weather from ecosystem state and applies atmospheric feedback
@@ -81,9 +82,11 @@ public final class AtmosphereModule implements SimulationModule {
false); false);
private final BooleanSupplier globalRaining; private final BooleanSupplier globalRaining;
private final Supplier<Season> currentSeason;
public AtmosphereModule(BooleanSupplier globalRaining) { public AtmosphereModule(BooleanSupplier globalRaining, Supplier<Season> currentSeason) {
this.globalRaining = globalRaining; this.globalRaining = globalRaining;
this.currentSeason = currentSeason;
} }
@Override public String getModuleId() { return MODULE_ID; } @Override public String getModuleId() { return MODULE_ID; }
@@ -134,7 +137,9 @@ public final class AtmosphereModule implements SimulationModule {
double pollScore = region.getMetrics().getPollutionScore(); double pollScore = region.getMetrics().getPollutionScore();
boolean mcRaining = globalRaining.getAsBoolean(); boolean mcRaining = globalRaining.getAsBoolean();
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
+ (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);
@@ -0,0 +1,37 @@
package com.livingworld.modules.atmosphere;
/**
* The four ecological seasons, derived from absolute Minecraft day count.
*
* <p>One full year = 32 Minecraft days (8 days per season). Season drives the
* rain target modifier in {@link AtmosphereModule} and seasonal soil effects in
* the bootstrap post-sim pass.
*/
public enum Season {
SPRING(+0.10),
SUMMER(-0.15),
AUTUMN( 0.0),
WINTER(-0.25);
private static final int SEASON_LENGTH_DAYS = 8;
/** Additive modifier applied to the atmosphere rain target this season. */
private final double rainModifier;
Season(double rainModifier) {
this.rainModifier = rainModifier;
}
public double rainModifier() { return rainModifier; }
public String displayName() {
String n = name();
return n.charAt(0) + n.substring(1).toLowerCase();
}
/** Returns the season for the given absolute Minecraft day number (gameTime / 24000). */
public static Season fromAbsoluteDay(long absoluteDay) {
return values()[(int) ((absoluteDay / SEASON_LENGTH_DAYS) % 4)];
}
}
@@ -39,4 +39,10 @@ public enum WorldEffectType {
* left to the platform layer. * left to the platform layer.
*/ */
POLLUTION_VISUAL_INDICATOR, POLLUTION_VISUAL_INDICATOR,
/**
* Drought combined with a storm triggers wildfire ignition — fire is placed
* on flammable surface blocks and spreads naturally via Minecraft fire tick.
*/
WILDFIRE,
} }
@@ -9,16 +9,21 @@ import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext; import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext; import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule; import com.livingworld.modules.SimulationModule;
import com.livingworld.modules.atmosphere.AtmosphereModule;
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
import com.livingworld.modules.recovery.RecoveryRegionData; import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.recovery.RecoveryModule; import com.livingworld.modules.recovery.RecoveryModule;
import com.livingworld.modules.recovery.SuccessionStage; import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.modules.resources.ResourceDepletionModule; import com.livingworld.modules.resources.ResourceDepletionModule;
import com.livingworld.modules.resources.ResourceRegionData; import com.livingworld.modules.resources.ResourceRegionData;
import com.livingworld.modules.water.WaterModule;
import com.livingworld.modules.water.WaterRegionData;
import com.livingworld.regions.Region; import com.livingworld.regions.Region;
import com.livingworld.regions.RegionMetrics; import com.livingworld.regions.RegionMetrics;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Random;
/** /**
* Translates ecosystem simulation state into visible world change requests. * Translates ecosystem simulation state into visible world change requests.
@@ -63,6 +68,10 @@ public final class WorldEffectsModule implements SimulationModule {
private static final double SAPLING_SLOW_LOGGING_MIN = 50.0; private static final double SAPLING_SLOW_LOGGING_MIN = 50.0;
// Smoke particles appear as soon as any meaningful pollution exists. // Smoke particles appear as soon as any meaningful pollution exists.
private static final double POLLUTION_INDICATOR_MIN = 10.0; private static final double POLLUTION_INDICATOR_MIN = 10.0;
// Wildfire: lightning storm over a drought-stressed region.
private static final double WILDFIRE_DROUGHT_MIN = 70.0;
private static final double WILDFIRE_THUNDER_MIN = 0.5;
private static final double WILDFIRE_CHANCE_PER_CYCLE = 0.01; // 1 % per sim cycle
private static final ModuleMetadata METADATA = new ModuleMetadata( private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID, MODULE_ID,
@@ -77,6 +86,7 @@ public final class WorldEffectsModule implements SimulationModule {
false); false);
private final List<WorldEffectConsumer> consumers = new ArrayList<>(); private final List<WorldEffectConsumer> consumers = new ArrayList<>();
private final Random wildfireRandom = new Random();
/** /**
* Registers a consumer that will receive effect requests each simulation tick. * Registers a consumer that will receive effect requests each simulation tick.
@@ -178,6 +188,24 @@ public final class WorldEffectsModule implements SimulationModule {
emitted = true; emitted = true;
} }
// --- Effect 6: wildfire — drought + storm ignites surface vegetation ---
AtmosphereRegionData atm = region.getModuleData()
.get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class)
.orElse(null);
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElse(null);
if (atm != null && water != null
&& water.getDroughtRisk() > WILDFIRE_DROUGHT_MIN
&& atm.getThunderLevel() > WILDFIRE_THUNDER_MIN
&& wildfireRandom.nextDouble() < WILDFIRE_CHANCE_PER_CYCLE) {
double intensity = computeIntensity(atm.getThunderLevel() - WILDFIRE_THUNDER_MIN, 0.5)
* computeIntensity(water.getDroughtRisk() - WILDFIRE_DROUGHT_MIN, 30.0);
emit(new WorldEffectRequest(
WorldEffectType.WILDFIRE, region.getCoordinate(), Math.max(0.1, intensity)));
emitted = true;
}
return emitted ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange(); return emitted ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
} }
@@ -15,6 +15,7 @@ import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
@@ -66,6 +67,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
case SAPLING_GROWTH_BOOSTED -> case SAPLING_GROWTH_BOOSTED ->
placeSaplings(level, baseX, baseZ, request.intensity()); placeSaplings(level, baseX, baseZ, request.intensity());
case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred
case WILDFIRE ->
igniteVegetation(level, baseX, baseZ, request.intensity());
} }
} }
@@ -165,6 +168,23 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
} }
} }
private void igniteVegetation(ServerLevel level, int baseX, int baseZ, double intensity) {
int attempts = Math.max(1, (int) (intensity * 5));
for (int i = 0; i < attempts; i++) {
BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS),
baseZ + random.nextInt(REGION_BLOCKS));
if (pos == null) continue;
var state = level.getBlockState(pos);
if (!state.is(Blocks.GRASS_BLOCK) && !state.is(BlockTags.LEAVES)
&& !state.is(BlockTags.LOGS)) continue;
BlockPos above = pos.above();
if (!level.isLoaded(above) || !level.getBlockState(above).isAir()) continue;
level.setBlock(above, Blocks.FIRE.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"WorldEffect WILDFIRE ignited at " + above);
}
}
private void spawnPollutionParticles( private void spawnPollutionParticles(
ServerLevel level, int baseX, int baseZ, double intensity) { ServerLevel level, int baseX, int baseZ, double intensity) {
int count = Math.max(1, (int) (intensity * 8)); int count = Math.max(1, (int) (intensity * 8));