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.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.core.particles.ParticleTypes;
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 com.livingworld.bootstrap.LivingWorldBootstrap;
@@ -89,6 +93,7 @@ public class LivingWorldMod {
private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>();
private final Map<UUID, Boolean> playerRainState = new HashMap<>();
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
private final Set<RegionCoordinate> waterBodyInitialized = new HashSet<>();
public LivingWorldMod(IEventBus eventBus) {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
@@ -113,13 +118,17 @@ public class LivingWorldMod {
new NeoForgeWorldEffectExecutor(() -> minecraftServer));
bootstrap.setOverworldRaining(
() -> minecraftServer != null && minecraftServer.overworld().isRaining());
bootstrap.setAbsoluteDaySupplier(
() -> minecraftServer != null ? minecraftServer.overworld().getGameTime() / 24000L : 0L);
});
NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> {
bootstrap.onServerStopping();
bootstrap.setOverworldRaining(null);
bootstrap.setAbsoluteDaySupplier(null);
playerRegionCache.clear();
playerRainState.clear();
biomeInitialized.clear();
waterBodyInitialized.clear();
this.minecraftServer = null;
});
@@ -230,13 +239,21 @@ public class LivingWorldMod {
if (!coord.equals(previous)) {
bootstrap.notifyPlayerInRegion(coord);
// Step 6: Biome-aware succession — derive and apply cap once per region.
if (!biomeInitialized.contains(coord)
&& player.level() instanceof ServerLevel serverLevel) {
if (player.level() instanceof ServerLevel serverLevel) {
// Biome-aware succession cap — derived once per region.
if (!biomeInitialized.contains(coord)) {
SuccessionStage cap = deriveBiomeCap(serverLevel, coord);
bootstrap.setRegionBiomeCap(coord, cap);
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
@@ -259,17 +276,62 @@ public class LivingWorldMod {
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.
boolean showHud = player.getMainHandItem().is(Items.COMPASS)
|| player.getOffhandItem().is(Items.COMPASS)
|| bootstrap.isHudEnabled(player.getUUID());
if (showHud) {
Optional<RegionMetrics> metricsOpt = bootstrap.getMetricsAt(dimId, player.getX(), player.getZ());
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. */
private SuccessionStage deriveBiomeCap(ServerLevel level, RegionCoordinate coord) {
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.atmosphere.AtmosphereModule;
import com.livingworld.modules.atmosphere.AtmosphereRegionData;
import com.livingworld.modules.atmosphere.Season;
import com.livingworld.modules.worldeffects.WorldEffectsModule;
import com.livingworld.platform.BlockBreakInfo;
import com.livingworld.platform.PlatformAdapter;
@@ -52,6 +53,7 @@ import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.function.LongSupplier;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
@@ -81,6 +83,7 @@ public final class LivingWorldBootstrap {
private WorldEffectsModule worldEffectsModule;
private AtmosphereModule atmosphereModule;
private BooleanSupplier overworldRaining = () -> false;
private LongSupplier absoluteDaySupplier = () -> 0L;
private boolean initialized;
private boolean serverReady;
@@ -318,6 +321,14 @@ public final class LivingWorldBootstrap {
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() {
if (!serverReady) {
return;
@@ -326,6 +337,7 @@ public final class LivingWorldBootstrap {
simulationManager.onMinecraftServerTick();
if (simulationManager.getSimulationTickCounter() != previousSimulationTick) {
spreadPollutionAcrossRegions();
applySeasonalEffects();
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. */
public Optional<AtmosphereRegionData> getRegionalWeather(RegionCoordinate coord) {
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));
}
/** 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. */
public boolean toggleHud(UUID playerId) {
if (hudEnabledPlayers.remove(playerId)) return false;
@@ -401,7 +468,8 @@ public final class LivingWorldBootstrap {
() -> requireService(regionManager, "regionManager"),
() -> requireService(moduleRegistry, "moduleRegistry"),
() -> requireService(simulationManager, "simulationManager"),
this::toggleHud);
this::toggleHud,
this::getAtmosphereStatusFor);
}
public Path getWorldSaveDirectory() {
@@ -661,7 +729,7 @@ public final class LivingWorldBootstrap {
registry.register(new EcosystemModule());
worldEffectsModule = new WorldEffectsModule();
registry.register(worldEffectsModule);
atmosphereModule = new AtmosphereModule(() -> overworldRaining.getAsBoolean());
atmosphereModule = new AtmosphereModule(() -> overworldRaining.getAsBoolean(), this::getCurrentSeason);
registry.register(atmosphereModule);
LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP,
@@ -38,7 +38,8 @@ public final class LivingWorldCommandRoot {
() -> regionManager,
() -> moduleRegistry,
() -> simulationManager,
uuid -> false);
uuid -> false,
css -> "Atmosphere not available.");
}
public static void registerDeferred(
@@ -46,7 +47,8 @@ public final class LivingWorldCommandRoot {
Supplier<RegionManager> regionManager,
Supplier<ModuleRegistry> moduleRegistry,
Supplier<SimulationManager> simulationManager,
Function<UUID, Boolean> hudToggle) {
Function<UUID, Boolean> hudToggle,
Function<CommandSourceStack, String> atmosphereStatus) {
if (dispatcher == null) {
throw new IllegalArgumentException("dispatcher must not be null");
}
@@ -104,7 +106,13 @@ public final class LivingWorldCommandRoot {
requireService(simulationManager, "simulationManager"),
IntegerArgumentType.getInteger(context, "ticks")))))
.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) {
@@ -21,6 +21,7 @@ import com.livingworld.regions.Region;
import java.util.List;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
/**
* Drives per-region weather from ecosystem state and applies atmospheric feedback
@@ -81,9 +82,11 @@ public final class AtmosphereModule implements SimulationModule {
false);
private final BooleanSupplier globalRaining;
private final Supplier<Season> currentSeason;
public AtmosphereModule(BooleanSupplier globalRaining) {
public AtmosphereModule(BooleanSupplier globalRaining, Supplier<Season> currentSeason) {
this.globalRaining = globalRaining;
this.currentSeason = currentSeason;
}
@Override public String getModuleId() { return MODULE_ID; }
@@ -134,7 +137,9 @@ public final class AtmosphereModule implements SimulationModule {
double pollScore = region.getMetrics().getPollutionScore();
boolean mcRaining = globalRaining.getAsBoolean();
double seasonMod = currentSeason.get().rainModifier();
double targetRain = clamp01(ecosystemHealth / 100.0 * MAX_RAIN_FROM_ECO
+ seasonMod
+ (mcRaining ? GLOBAL_RAIN_BIAS : 0.0));
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.
*/
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.ServerContext;
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.RecoveryModule;
import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.modules.resources.ResourceDepletionModule;
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.RegionMetrics;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
/**
* 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;
// Smoke particles appear as soon as any meaningful pollution exists.
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(
MODULE_ID,
@@ -77,6 +86,7 @@ public final class WorldEffectsModule implements SimulationModule {
false);
private final List<WorldEffectConsumer> consumers = new ArrayList<>();
private final Random wildfireRandom = new Random();
/**
* Registers a consumer that will receive effect requests each simulation tick.
@@ -178,6 +188,24 @@ public final class WorldEffectsModule implements SimulationModule {
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();
}
@@ -15,6 +15,7 @@ import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
@@ -66,6 +67,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
case SAPLING_GROWTH_BOOSTED ->
placeSaplings(level, baseX, baseZ, request.intensity());
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(
ServerLevel level, int baseX, int baseZ, double intensity) {
int count = Math.max(1, (int) (intensity * 8));