From c82a2afc4fcf8a328e7c4d769c97a9f23b6a1203 Mon Sep 17 00:00:00 2001 From: George Date: Sun, 7 Jun 2026 20:42:41 +0100 Subject: [PATCH] Add seasons, player pollution effects, wildfire, fog, water body boost, /lw atmosphere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 atmosphereStatus parameter added to LivingWorldCommandRoot.registerDeferred(). Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/livingworld/LivingWorldMod.java | 76 +++++++++++++++++-- .../bootstrap/LivingWorldBootstrap.java | 72 +++++++++++++++++- .../commands/LivingWorldCommandRoot.java | 14 +++- .../modules/atmosphere/AtmosphereModule.java | 7 +- .../modules/atmosphere/Season.java | 37 +++++++++ .../modules/worldeffects/WorldEffectType.java | 6 ++ .../worldeffects/WorldEffectsModule.java | 28 +++++++ .../neoforge/NeoForgeWorldEffectExecutor.java | 20 +++++ 8 files changed, 247 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/livingworld/modules/atmosphere/Season.java diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 68554bc..4496b9e 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -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 playerRegionCache = new HashMap<>(); private final Map playerRainState = new HashMap<>(); private final Set biomeInitialized = new HashSet<>(); + private final Set 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,12 +239,20 @@ 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) { - SuccessionStage cap = deriveBiomeCap(serverLevel, coord); - bootstrap.setRegionBiomeCap(coord, cap); - biomeInitialized.add(coord); + 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); + } } } @@ -259,17 +276,62 @@ public class LivingWorldMod { ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE, thunderLevel)); } + // Player effects from pollution — applied/refreshed each check interval. + Optional 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 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; diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index fdb0db2..2cd9064 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -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 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, diff --git a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java index 4d3a905..db0513f 100644 --- a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java +++ b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java @@ -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, Supplier moduleRegistry, Supplier simulationManager, - Function hudToggle) { + Function hudToggle, + Function 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 hudToggle) { diff --git a/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java b/src/main/java/com/livingworld/modules/atmosphere/AtmosphereModule.java index 6eb52d3..4f9d0f8 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.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 currentSeason; - public AtmosphereModule(BooleanSupplier globalRaining) { + public AtmosphereModule(BooleanSupplier globalRaining, Supplier 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); diff --git a/src/main/java/com/livingworld/modules/atmosphere/Season.java b/src/main/java/com/livingworld/modules/atmosphere/Season.java new file mode 100644 index 0000000..2d7fee2 --- /dev/null +++ b/src/main/java/com/livingworld/modules/atmosphere/Season.java @@ -0,0 +1,37 @@ +package com.livingworld.modules.atmosphere; + +/** + * The four ecological seasons, derived from absolute Minecraft day count. + * + *

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)]; + } +} diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java index 4bdf42f..08e1b17 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -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, } diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java index 23b859a..8d5ddc7 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java @@ -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 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(); } diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index 062f129..1f4661e 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -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));