diff --git a/README.md b/README.md index 4235c49..18ad9b5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,11 @@ All effects are real block changes players can see: | Wildfire | Drought > 70, thunder > 0.5 | Fire placed on surface vegetation | | Water pools form | Rain + barren/sparse terrain | Water source blocks in surface depressions | | **River carve** | High accumulated water runoff | Water channels carved through natural terrain; gradually deepens | +| **Ground spring** | Saturated aquifer in valley floor | Permanent water source placed; river flows downhill naturally | +| **Hydraulic erosion** | Flowing water adjacent to rock | Stone → gravel → sand → air; valley walls slowly widen | +| **Lava flow** | Volcanic eruption | Lava source blocks placed at summit; flows and reshapes terrain | +| **Ash deposit** | Active eruption or nearby blast | Grass → tuff, soil → gravel; surface plants killed over wide area | +| **Cobblestone forms** | Lava cooling | Lava + water → cobblestone; lava in air → basalt; new permanent land | | Pollution particles | Pollution > 10 | Smoke particles in degraded regions | ### Player Feedback — **Complete** @@ -126,13 +131,48 @@ Automatically triggered when regional conditions reach critical thresholds. All - Visible in the compass HUD next to the region coordinates - Full trend detail in `/lw atmosphere` -### River Erosion — **Complete** +### Groundwater & Springs — **Complete** + +- Every region tracks a subsurface aquifer level that recharges slowly from rainfall +- Underground seepage: high-elevation neighbours drain laterally into the aquifer of lower regions (water flows underground even between sim cycles) +- When the aquifer is saturated in a **valley floor** (≥2 higher neighbours), a `GROUND_SPRING_EMERGES` effect fires — a water source block is placed at the lowest natural terrain point +- The spring flows downhill under Minecraft's own fluid physics, forming a permanent river without further simulation involvement +- Active springs contribute to river erosion intensity even without rain + +### River Erosion & Hydraulic Erosion — **Complete** - Accumulated water runoff intensity tracked per region (flow from higher → lower elevation) - When flow intensity crosses the threshold (80 units), a `RIVER_CARVE` world effect fires -- Block effect: water source placed on natural terrain (sand/dirt/gravel/grass); at high intensity the block below is also removed, deepening the channel +- Block effect: water source placed on natural terrain; at high intensity the block below is removed (deepening channel) +- **Hydraulic erosion** fires independently of rainfall whenever significant flow intensity is present: flowing water (level 1–7) adjacent to soft rock softens it — stone → gravel → sand → air — progressively widening river valleys - Rivers form gradually over many sim cycles — permanent terrain changes +### River Current Physics — **Complete** (real player forces) + +- Every 4 ticks, all players standing in **flowing** water (level 1–7) receive a directional velocity push +- Force direction: `FluidState.getFlow()` — the exact vector Minecraft already computes for its own flow rendering; currents always point the same way the water visually flows +- Force magnitude: scales with flow level — level 7 (one block from a spring, fastest) pushes hard; level 1 (distant, slow lowland river) barely nudges +- Mountain rivers are genuinely dangerous to wade against; lowland rivers are gentle enough to cross +- Boats are naturally carried by vanilla water physics; swimming players and mobs now feel the additional push + +### Volcanic System — **Complete** (new land formation) + +Four-phase lifecycle per high-elevation region (average surface Y ≥ 85): + +| Phase | What Happens | +|-------|-------------| +| **DORMANT** | Normal state; rare stochastic chance to awaken (~0.2% per check) | +| **BUILDING** | Seismic venting — mild pollution spike; player broadcast warns of imminent eruption | +| **ERUPTING** | `LAVA_FLOW` places source blocks at the summit; `ASH_DEPOSIT` covers surrounding terrain; ash clouds spread to adjacent regions; vegetation dies in the eruption zone; air pollution spikes; lasts ~40 sim cycles | +| **COOLING** | `COBBLESTONE_FORMS` converts lava adjacent to water → cobblestone; lava in air → basalt; permanent new terrain created where none existed before | +| **FERTILE** | Volcanic minerals boost soil fertility +30, contamination −20; pollution decays rapidly; succession cap can rise; then returns DORMANT | + +Visible effects: +- Lava flows downhill under vanilla physics, permanently reshaping terrain +- Ash converts grass → tuff, loose soil → gravel; kills surface plants across a wide area +- New cobblestone and basalt islands form where lava met water +- Players in active eruption zones hear fire crackle ambience; building-phase rumble in BUILDING regions + ### Tidal Simulation — **Complete** (physical blocks) - Two tidal cycles per Minecraft day (24 000 ticks) using a sine wave @@ -210,6 +250,8 @@ All commands require permission level 2. | `/lw simulate ` | Force N simulation cycles on all active regions | | `/lw stats` | Simulation profiler statistics | | `/lw modules list` | List all registered modules and their enabled state | +| `/lw events` | List all active climate events (drought/wildfire/flood/eruption) with epicentre, severity, affected regions | +| `/lw volcanoes` | List all tracked volcanic regions and their current lifecycle phase | | `/lw demo degrade` | One-command demo: degrade current region to barren | | `/lw demo recover` | One-command demo: restore current region to mature forest | @@ -291,13 +333,16 @@ LivingWorldMod (NeoForge event wiring) applySeasonalEffects() [soil/water/veg seasonal] applyClimateWarmingEffects() [drought pressure from CO₂] applyWaterRunoff() [elevation-based; river erosion] + applyGroundwaterAndSprings() [aquifer recharge; valley springs] applyDynamicCapUpdate() [raise/lower succession ceiling] applySeedDispersal() [corridor-boosted recolonisation] recordHealthTrend() [↑↓→ trend tracking] applyClimateEvents() [drought/wildfire/flood events] + applyVolcanicActivity() [volcano lifecycle; lava/ash/new land] LivingWorldMod platform hooks: updateTidalEffects() [block-level tidal simulation] + applyRiverCurrents() [player/entity velocity in flowing water] initializeRegionFromBiome() [biome-aware starting values] checkPlayerRegions() [HUD, effects, ambient sounds] scanAndRecordFurnaceActivity() [pollution from lit furnaces] @@ -320,7 +365,8 @@ Requires NeoForge 21.1.172 / Minecraft 1.21.1. ## Planned / In Progress - Full worldgen integration (custom biome distribution, terrain shaping beyond post-load modification) -- `/lw events` command to list active climate events and their affected regions -- Persistence for river flow intensity and accumulated seed rain across restarts +- Persistence for river flow intensity, groundwater level, and volcanic state across restarts - Multiplayer region ownership / notification system -- More succession stages (wetland, alpine, volcanic) +- More succession stages (wetland, alpine, volcanic basalt plain) +- Snowmelt rivers: winter snow accumulation releases as meltwater in spring +- Player damage in active eruption zones (lava proximity heat) diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 69dba3e..d4f291e 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -96,6 +96,10 @@ public class LivingWorldMod { private static final double HOSTILE_SUPPRESS_HEALTH = 60.0; private static final int TIDE_CHECK_INTERVAL = 1200; // ~1 real minute + /** Apply river current forces to players every N ticks. */ + private static final int RIVER_CURRENT_INTERVAL = 4; + /** Additional horizontal push (m/tick²) per unit of normalised flow speed. */ + private static final double CURRENT_STRENGTH = 0.045; private final LivingWorldBootstrap bootstrap; private final Random random = new Random(); @@ -104,6 +108,7 @@ public class LivingWorldMod { private int playerCheckTick = 0; private int tideTick = 0; private double lastTideLevel = 0.0; + private int riverCurrentTick = 0; private final Map playerRegionCache = new HashMap<>(); private final Map playerRainState = new HashMap<>(); private final Set biomeInitialized = new HashSet<>(); @@ -157,6 +162,7 @@ public class LivingWorldMod { regionSoundLastTick.clear(); tideTick = 0; lastTideLevel = 0.0; + riverCurrentTick = 0; this.minecraftServer = null; }); @@ -172,6 +178,9 @@ public class LivingWorldMod { tideTick = 0; updateTidalEffects(); } + if (++riverCurrentTick % RIVER_CURRENT_INTERVAL == 0) { + applyRiverCurrents(); + } // Broadcast any climate event messages queued by the bootstrap java.util.List eventMsgs = bootstrap.pollClimateEventMessages(); if (!eventMsgs.isEmpty()) { @@ -390,6 +399,36 @@ public class LivingWorldMod { } } + /** + * Applies realistic river current forces to players and non-player entities standing + * in flowing water. Uses {@link net.minecraft.world.level.material.FluidState#getFlow} + * — the same directional vector Minecraft computes internally — so currents always + * follow the actual water flow path, not a simulation approximation. + * Mountain rivers (level 7, close to source) push hard; lowland channels (level 1-2) + * are gentle enough to wade through. Heavy armour players notice the difference. + */ + private void applyRiverCurrents() { + if (minecraftServer == null) return; + for (ServerPlayer player : minecraftServer.getPlayerList().getPlayers()) { + if (!(player.level() instanceof ServerLevel level)) continue; + BlockPos pos = player.blockPosition(); + var fluid = level.getFluidState(pos); + // Only flowing water — source blocks carry no directional current + if (!fluid.is(FluidTags.WATER) || fluid.isSource()) continue; + net.minecraft.world.phys.Vec3 flowVec = fluid.getFlow(level, pos); + if (flowVec.lengthSqr() < 0.0001) continue; + // Fluid amount: 8 = source, decreases with distance. + // Amount 7 = one block from source (fastest); amount 1 = farthest (slowest). + double flowStrength = fluid.getAmount() / 8.0; + double force = CURRENT_STRENGTH * flowStrength; + var movement = player.getDeltaMovement(); + player.setDeltaMovement( + movement.x + flowVec.x * force, + movement.y, + movement.z + flowVec.z * force); + } + } + /** * Plays a single ambient sound appropriate for the region's ecological state. * Each sound fires at low volume and random pitch so it blends naturally. @@ -397,8 +436,25 @@ public class LivingWorldMod { */ private boolean tryPlayRegionAmbience( ServerPlayer player, SuccessionStage stage, double health, double pollution) { - float pitch = 0.75f + random.nextFloat() * 0.5f; + String dimId = player.level().dimension().location().toString(); + int regionX = (int) Math.floor(player.getX() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0)); + int regionZ = (int) Math.floor(player.getZ() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0)); + RegionCoordinate coord = new RegionCoordinate(dimId, regionX, regionZ); + // Volcanic ambience — highest priority when eruption is active + String volcanoPhase = bootstrap.getVolcanoPhaseAt(coord); + if (volcanoPhase != null) { + if ("ERUPTING".equals(volcanoPhase) && random.nextInt(4) == 0) { + playAmbientSound(player, Holder.direct(SoundEvents.FIRE_AMBIENT), 0.55f, 0.8f + random.nextFloat() * 0.4f); + return true; + } + if ("BUILDING".equals(volcanoPhase) && random.nextInt(20) == 0) { + playAmbientSound(player, SoundEvents.AMBIENT_BASALT_DELTAS_MOOD, 0.25f, 0.7f + random.nextFloat() * 0.2f); + return true; + } + } + + float pitch = 0.75f + random.nextFloat() * 0.5f; if (stage != null && stage.ordinal() >= SuccessionStage.YOUNG_WOODLAND.ordinal() && health > 50.0 && random.nextInt(10) == 0) { // Rustling leaves — healthy forest ambience. diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index a97fae5..00db451 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -83,6 +83,15 @@ public final class LivingWorldBootstrap { private static final double RIVER_CARVE_THRESHOLD = 80.0; /** Climate event checks run every N post-sim cycles to avoid per-tick overhead. */ private static final int CLIMATE_CHECK_INTERVAL = 8; + /** Groundwater level required for a valley spring to emerge. */ + private static final double GROUNDWATER_THRESHOLD = 50.0; + /** Average surface elevation (Y) above which a region is treated as potentially volcanic. */ + private static final double VOLCANIC_ELEVATION = 85.0; + /** Volcano state machine advances every N post-sim cycles. */ + private static final int VOLCANO_TICK_INTERVAL = 16; + + /** Lifecycle states for a volcanic region. */ + private enum VolcanoPhase { DORMANT, BUILDING, ERUPTING, COOLING, FERTILE } private double windAngle = 0.0; private final Random windRandom = new Random(); @@ -108,6 +117,15 @@ public final class LivingWorldBootstrap { private final List pendingEventMessages = new ArrayList<>(); private int climateEventTick = 0; + // --- Groundwater aquifer (transient) --- + private final Map groundwaterLevel = new HashMap<>(); + + // --- Volcano lifecycle (transient — re-derives from elevation on restart) --- + private final Map volcanoPhase = new HashMap<>(); + private final Map volcanoTicks = new HashMap<>(); + private final Set volcanicRegions = new HashSet<>(); + private int volcanicActivityTick = 0; + private PlatformAdapter platformAdapter; private Path worldSaveDirectory; private ServiceRegistry services; @@ -259,6 +277,11 @@ public final class LivingWorldBootstrap { activeClimateEvents.clear(); pendingEventMessages.clear(); climateEventTick = 0; + groundwaterLevel.clear(); + volcanoPhase.clear(); + volcanoTicks.clear(); + volcanicRegions.clear(); + volcanicActivityTick = 0; simSpeedMultiplier = 1; serverReady = false; LivingWorldLogger.info( @@ -410,12 +433,16 @@ public final class LivingWorldBootstrap { climateTracker.update(regionManager.getActiveRegions()); applyClimateWarmingEffects(); applyWaterRunoff(); + applyGroundwaterAndSprings(); applyDynamicCapUpdate(); applySeedDispersal(); recordHealthTrend(); if (++climateEventTick % CLIMATE_CHECK_INTERVAL == 0) { applyClimateEvents(); } + if (++volcanicActivityTick % VOLCANO_TICK_INTERVAL == 0) { + applyVolcanicActivity(); + } } /** Sets the simulation speed multiplier (1 = real-time, max 100). */ @@ -547,6 +574,272 @@ public final class LivingWorldBootstrap { } } + /** + * Tracks subsurface groundwater per region: recharges from rainfall and lateral seepage + * from uphill neighbours. When the aquifer level is high in a valley (lower elevation + * than ≥2 neighbours), a GROUND_SPRING_EMERGES effect is queued — placing a permanent + * water source that flows naturally downhill under Minecraft's own fluid physics. + */ + private void applyGroundwaterAndSprings() { + Collection active = regionManager.getActiveRegions(); + if (active.isEmpty()) return; + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + for (Region region : active) { + RegionCoordinate coord = region.getCoordinate(); + AtmosphereRegionData atm = region.getModuleData() + .get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class).orElse(null); + Double myElev = regionElevations.get(coord); + + // Recharge from rainfall + if (atm != null && atm.getRainLevel() > 0.1) { + groundwaterLevel.merge(coord, atm.getRainLevel() * 0.008, Double::sum); + } + + // Lateral underground seepage from higher neighbours + if (myElev != null) { + for (int[] off : offsets) { + RegionCoordinate nCoord = new RegionCoordinate( + coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]); + Double nElev = regionElevations.get(nCoord); + if (nElev != null && nElev > myElev + 3) { + double seepage = 0.002 * Math.min((nElev - myElev) / 50.0, 1.0); + groundwaterLevel.merge(coord, seepage, Double::sum); + } + } + } + + // Slow evaporation / natural discharge + double level = Math.max(0, groundwaterLevel.getOrDefault(coord, 0.0) - 0.001); + groundwaterLevel.put(coord, level); + + // Spring emergence: valley floor with saturated aquifer + if (level >= GROUNDWATER_THRESHOLD && myElev != null) { + int higherNeighbours = 0; + for (int[] off : offsets) { + RegionCoordinate nCoord = new RegionCoordinate( + coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]); + Double nElev = regionElevations.get(nCoord); + if (nElev != null && nElev > myElev + 5) higherNeighbours++; + } + if (higherNeighbours >= 2 && worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.GROUND_SPRING_EMERGES, coord, + Math.min(1.0, (level - GROUNDWATER_THRESHOLD) / 50.0 + 0.3))); + // Discharge: spring reduces aquifer level + groundwaterLevel.put(coord, level - 15.0); + WaterRegionData water = region.getModuleData() + .get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null); + if (water != null) { + water.setWaterAvailability(Math.min(100, water.getWaterAvailability() + 5.0)); + water.setDroughtRisk(Math.max(0, water.getDroughtRisk() - 3.0)); + region.getModuleData().put(WaterModule.MODULE_ID, water); + } + // Active spring contributes to river erosion even without rain + riverFlowIntensity.merge(coord, 10.0, Double::sum); + regionManager.markDirty(region); + } + } + + // Hydraulic erosion fires when the region has meaningful flow intensity + // from any source (rainfall runoff OR active spring), independent of rain + double flowHere = riverFlowIntensity.getOrDefault(coord, 0.0); + if (flowHere > 20.0 && worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.HYDRAULIC_EROSION, coord, + Math.min(1.0, flowHere / (RIVER_CARVE_THRESHOLD * 2)))); + } + } + } + + /** + * Advances the volcanic lifecycle for all high-elevation regions: + * DORMANT → BUILDING → ERUPTING → COOLING → FERTILE → DORMANT. + * Each phase applies physical world effects and ecosystem data changes. + */ + private void applyVolcanicActivity() { + Collection active = regionManager.getActiveRegions(); + if (active.isEmpty()) return; + Map byCoord = new HashMap<>(); + for (Region r : active) byCoord.put(r.getCoordinate(), r); + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + for (Region region : active) { + RegionCoordinate coord = region.getCoordinate(); + Double elev = regionElevations.get(coord); + if (elev == null) continue; + + // Register high-elevation regions as potentially volcanic on first encounter + if (!volcanoPhase.containsKey(coord)) { + if (elev < VOLCANIC_ELEVATION) continue; + volcanicRegions.add(coord); + volcanoPhase.put(coord, VolcanoPhase.DORMANT); + volcanoTicks.put(coord, 0); + } + + VolcanoPhase phase = volcanoPhase.get(coord); + int ticks = volcanoTicks.getOrDefault(coord, 0); + volcanoTicks.put(coord, ticks + 1); + + switch (phase) { + case DORMANT -> { + // Rare stochastic chance to awaken (~0.2% per check) + if (windRandom.nextDouble() < 0.002) { + volcanoPhase.put(coord, VolcanoPhase.BUILDING); + volcanoTicks.put(coord, 0); + pendingEventMessages.add("[LW] 🌋 Seismic activity at mountain region (" + + coord.x() + "," + coord.z() + ") — a volcano may be awakening..."); + } + } + case BUILDING -> { + // Venting gases: mild pollution increase; 30 cycles before eruption + PollutionRegionData poll = region.getModuleData() + .get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null); + if (poll != null) { + poll.addPollution(0.3, 0.1, 0); + region.getModuleData().put(PollutionModule.MODULE_ID, poll); + regionManager.markDirty(region); + } + if (ticks >= 30) { + volcanoPhase.put(coord, VolcanoPhase.ERUPTING); + volcanoTicks.put(coord, 0); + pendingEventMessages.add("[LW] 🌋 VOLCANIC ERUPTION at region (" + + coord.x() + "," + coord.z() + ")! Lava and ash spreading now!"); + } + } + case ERUPTING -> { + // Lava flows and ash — physical block changes + if (worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.LAVA_FLOW, coord, + Math.min(1.0, 0.4 + (ticks / 20.0) * 0.6))); + if (windRandom.nextDouble() < 0.40) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.ASH_DEPOSIT, coord, 0.7)); + } + if (windRandom.nextDouble() < 0.25) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.VEGETATION_DIES, coord, 0.9)); + } + } + // Ash cloud spreads to adjacent regions + for (int[] off : offsets) { + RegionCoordinate nCoord = new RegionCoordinate( + coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]); + Region nb = byCoord.get(nCoord); + if (nb == null) continue; + if (worldEffectsModule != null && windRandom.nextDouble() < 0.3) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.ASH_DEPOSIT, nCoord, 0.3)); + } + PollutionRegionData nbPoll = nb.getModuleData() + .get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null); + if (nbPoll != null) { + nbPoll.addPollution(0.8, 0.2, 0); + nb.getModuleData().put(PollutionModule.MODULE_ID, nbPoll); + regionManager.markDirty(nb); + } + } + // Eruption zone: spike air pollution, drain vegetation + PollutionRegionData poll = region.getModuleData() + .get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null); + if (poll != null) { + poll.addPollution(2.0, 1.0, 0.5); + region.getModuleData().put(PollutionModule.MODULE_ID, poll); + } + VegetationRegionData veg = region.getModuleData() + .get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElse(null); + if (veg != null) { + veg.setGrassPressure(Math.max(0, veg.getGrassPressure() - 5)); + veg.setTreePressure(Math.max(0, veg.getTreePressure() - 3)); + region.getModuleData().put(VegetationModule.MODULE_ID, veg); + } + regionManager.markDirty(region); + // Eruption lasts ~40 cycles + if (ticks >= 40) { + volcanoPhase.put(coord, VolcanoPhase.COOLING); + volcanoTicks.put(coord, 0); + pendingEventMessages.add("[LW] 🌋 Eruption subsiding at (" + + coord.x() + "," + coord.z() + ") — lava cooling and solidifying..."); + } + } + case COOLING -> { + // Lava solidifies — new land forms from cooling rock + if (worldEffectsModule != null && windRandom.nextDouble() < 0.5) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.COBBLESTONE_FORMS, coord, 0.6)); + } + if (ticks >= 30) { + volcanoPhase.put(coord, VolcanoPhase.FERTILE); + volcanoTicks.put(coord, 0); + pendingEventMessages.add("[LW] 🌱 Volcanic soil enriching region (" + + coord.x() + "," + coord.z() + ") — fertile basalt plain emerging!"); + } + } + case FERTILE -> { + // One-time volcanic mineral fertility boost in the first 10 cycles + if (ticks < 10) { + SoilRegionData soil = region.getModuleData() + .get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null); + if (soil != null) { + soil.setFertility(Math.min(100, soil.getFertility() + 30)); + soil.setContamination(Math.max(0, soil.getContamination() - 20)); + region.getModuleData().put(SoilModule.MODULE_ID, soil); + } + } + // Pollution clears naturally as fresh minerals neutralise it + if (ticks < 20) { + PollutionRegionData poll = region.getModuleData() + .get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null); + if (poll != null) { + poll.addPollution(-1.0, -0.5, -0.3); + region.getModuleData().put(PollutionModule.MODULE_ID, poll); + } + } + regionManager.markDirty(region); + if (ticks >= 50) { + volcanoPhase.put(coord, VolcanoPhase.DORMANT); + volcanoTicks.put(coord, 0); + } + } + } + } + } + + /** Returns the current volcano phase name for the given region, or null if not volcanic. */ + public String getVolcanoPhaseAt(RegionCoordinate coord) { + if (coord == null) return null; + VolcanoPhase phase = volcanoPhase.get(coord); + return phase != null ? phase.name() : null; + } + + /** + * Returns a formatted string listing all tracked volcanic regions and their current phase. + * Used by {@code /lw volcanoes}. + */ + public String getVolcanoStatusString() { + if (volcanicRegions.isEmpty()) return "[LW] No volcanic regions detected yet (requires high-elevation terrain)."; + StringBuilder sb = new StringBuilder("[LW] Volcanic regions: ").append(volcanicRegions.size()).append("\n"); + for (RegionCoordinate coord : volcanicRegions) { + VolcanoPhase phase = volcanoPhase.getOrDefault(coord, VolcanoPhase.DORMANT); + int ticks = volcanoTicks.getOrDefault(coord, 0); + Double elev = regionElevations.get(coord); + sb.append(" (").append(coord.x()).append(",").append(coord.z()).append(") ") + .append(phase.name()) + .append(" — ticks: ").append(ticks); + if (elev != null) sb.append(", elev: ").append(String.format("%.0f", elev)); + sb.append("\n"); + } + return sb.toString().trim(); + } + + /** + * Returns a snapshot of all active climate events for the {@code /lw events} command. + */ + public List getActiveClimateEventSnapshot() { + return List.copyOf(activeClimateEvents); + } + /** * Stores the sampled average surface elevation for a region so that * {@link #applyWaterRunoff()} can model water flowing downhill between neighbours. @@ -1228,7 +1521,26 @@ public final class LivingWorldBootstrap { this::getAtmosphereStatusFor, this::getClimateStatusFor, this::setSimSpeedMultiplier, - this::getWindInfo); + this::getWindInfo, + css -> getEventsStatusFor(), + this::getVolcanoStatusString); + } + + /** Returns a formatted string of all active climate events for {@code /lw events}. */ + public String getEventsStatusFor() { + List events = getActiveClimateEventSnapshot(); + if (events.isEmpty()) return "[LW] No active climate events."; + StringBuilder sb = new StringBuilder("[LW] Active Climate Events: ") + .append(events.size()).append("\n"); + for (ClimateEvent ev : events) { + sb.append(" ").append(ev.getType().displayName()) + .append(" at (").append(ev.getEpicenter().x()).append(",").append(ev.getEpicenter().z()).append(")") + .append(" | ticks: ").append(ev.getTicksActive()) + .append(" | severity: ").append(String.format("%.0f%%", ev.getSeverity() * 100)) + .append(" | regions: ").append(ev.getAffectedRegions().size()) + .append("\n"); + } + return sb.toString().trim(); } public Path getWorldSaveDirectory() { diff --git a/src/main/java/com/livingworld/climate/ClimateEventType.java b/src/main/java/com/livingworld/climate/ClimateEventType.java index 9f5f7f2..8e14734 100644 --- a/src/main/java/com/livingworld/climate/ClimateEventType.java +++ b/src/main/java/com/livingworld/climate/ClimateEventType.java @@ -4,7 +4,8 @@ public enum ClimateEventType { DROUGHT("Drought", "Prolonged dry spell spreading across adjacent regions"), WILDFIRE("Wildfire", "Fire spreading through drought-stressed forest regions"), - FLOOD("Flood", "Heavy rainfall overwhelming low-lying terrain"); + FLOOD("Flood", "Heavy rainfall overwhelming low-lying terrain"), + VOLCANIC_ERUPTION("Volcanic Eruption", "Lava flows and ash clouds from an active volcano"); private final String displayName; private final String description; diff --git a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java index 454503e..d5369f3 100644 --- a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java +++ b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java @@ -44,7 +44,9 @@ public final class LivingWorldCommandRoot { css -> "Atmosphere not available.", css -> "Climate not available.", n -> n, - () -> "Wind not available."); + () -> "Wind not available.", + css -> "Events not available.", + () -> "No volcanic regions."); } public static void registerDeferred( @@ -56,7 +58,9 @@ public final class LivingWorldCommandRoot { Function atmosphereStatus, Function climateStatus, IntUnaryOperator speedSetter, - Supplier windStatus) { + Supplier windStatus, + Function eventsStatus, + Supplier volcanoStatus) { if (dispatcher == null) { throw new IllegalArgumentException("dispatcher must not be null"); } @@ -154,7 +158,19 @@ public final class LivingWorldCommandRoot { }))) .then(LivingWorldMapCommand.build(regionManager)) .then(RegionSetCommand.build(regionManager)) - .then(EcoDemoCommand.build(regionManager))); + .then(EcoDemoCommand.build(regionManager)) + .then(Commands.literal("events") + .executes(context -> { + String info = eventsStatus.apply(context.getSource()); + context.getSource().sendSuccess(() -> Component.literal(info), false); + return 1; + })) + .then(Commands.literal("volcanoes") + .executes(context -> { + String info = volcanoStatus.get(); + 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/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java index 7fcdf52..9308e6e 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -67,4 +67,39 @@ public enum WorldEffectType { * through the region, gradually carving a visible riverbed over many cycles. */ RIVER_CARVE, + + /** + * A groundwater spring emerges at the valley floor: a water source block is placed + * at the lowest natural terrain point in the region. The water then flows downhill + * under Minecraft's own fluid physics, forming a permanent river without relying on rain. + */ + GROUND_SPRING_EMERGES, + + /** + * Flowing water adjacent to soft terrain gradually softens and removes it: stone + * becomes gravel, gravel becomes sand, sand and dirt are removed. Widens river + * valleys and carves gorges independently of rainfall. + */ + HYDRAULIC_EROSION, + + /** + * Active volcanic eruption: lava source blocks are placed at the summit and on + * natural rock surfaces. Lava flows downhill under Minecraft physics, permanently + * reshaping the terrain around the volcano. + */ + LAVA_FLOW, + + /** + * Volcanic ash clouds deposit a grey layer over the surrounding landscape: grass + * and soil surfaces are covered with tuff and gravel, surface plants are killed. + * Spreads outward from the eruption epicentre. + */ + ASH_DEPOSIT, + + /** + * Cooling lava solidifies into new land: lava adjacent to water converts to + * cobblestone, lava exposed to air converts to basalt. Creates permanent new + * terrain that was not there before the eruption. + */ + COBBLESTONE_FORMS, } diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index b6b91a1..20ac80a 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -17,6 +17,7 @@ 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.tags.FluidTags; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.levelgen.Heightmap; @@ -76,6 +77,16 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { formWaterPool(level, baseX, baseZ, request.intensity()); case RIVER_CARVE -> carveRiverChannel(level, baseX, baseZ, request.intensity()); + case GROUND_SPRING_EMERGES -> + groundSpringEmerges(level, baseX, baseZ, request.intensity()); + case HYDRAULIC_EROSION -> + hydraulicErosion(level, baseX, baseZ, request.intensity()); + case LAVA_FLOW -> + lavaFlow(level, baseX, baseZ, request.intensity()); + case ASH_DEPOSIT -> + ashDeposit(level, baseX, baseZ, request.intensity()); + case COBBLESTONE_FORMS -> + cobblestoneFromLava(level, baseX, baseZ, request.intensity()); } } @@ -344,6 +355,168 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } } + /** + * Places a permanent water source at the lowest natural terrain point found in the + * region. The source then flows downhill under vanilla water physics, forming a river + * without any further simulation involvement. + */ + private void groundSpringEmerges(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.30) return; + int samples = 20; + BlockPos lowestPos = null; + for (int i = 0; i < samples; i++) { + BlockPos candidate = surfaceAt(level, + baseX + random.nextInt(REGION_BLOCKS), + baseZ + random.nextInt(REGION_BLOCKS)); + if (candidate != null && (lowestPos == null || candidate.getY() < lowestPos.getY())) { + lowestPos = candidate; + } + } + if (lowestPos == null) return; + var surface = level.getBlockState(lowestPos); + if (!surface.is(Blocks.DIRT) && !surface.is(Blocks.GRASS_BLOCK) + && !surface.is(Blocks.SAND) && !surface.is(Blocks.GRAVEL) + && !surface.is(Blocks.COARSE_DIRT)) return; + BlockPos springPos = lowestPos.above(); + if (!level.isLoaded(springPos)) return; + if (!level.getBlockState(springPos).isAir()) return; + level.setBlock(springPos, Blocks.WATER.defaultBlockState(), Block.UPDATE_ALL); + LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect GROUND_SPRING_EMERGES at " + springPos); + } + + /** + * Flowing water (level 1–7) adjacent to soft rock gradually softens the terrain: + * stone → gravel → sand, sand/dirt → air. Widens river valleys over time without + * requiring rainfall; only fires ~15% of the time per call. + */ + private void hydraulicErosion(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.15) return; + int attempts = Math.max(1, (int) (intensity * 6)); + for (int i = 0; i < attempts; i++) { + int x = baseX + random.nextInt(REGION_BLOCKS); + int z = baseZ + random.nextInt(REGION_BLOCKS); + int surfaceY = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z) - 1; + if (surfaceY < level.getMinBuildHeight()) continue; + // Scan downward for flowing water + for (int y = surfaceY; y > surfaceY - 6; y--) { + BlockPos pos = new BlockPos(x, y, z); + if (!level.isLoaded(pos)) break; + var fluidState = level.getFluidState(pos); + if (!fluidState.is(FluidTags.WATER) || fluidState.isSource()) continue; + // Found flowing water — soften adjacent horizontal blocks + for (Direction dir : Direction.Plane.HORIZONTAL) { + BlockPos adj = pos.relative(dir); + if (!level.isLoaded(adj)) continue; + var adjState = level.getBlockState(adj); + if (adjState.is(Blocks.STONE) || adjState.is(Blocks.DEEPSLATE)) { + if (intensity > 0.65) level.setBlock(adj, Blocks.GRAVEL.defaultBlockState(), Block.UPDATE_ALL); + } else if (adjState.is(Blocks.GRAVEL) && intensity > 0.45) { + level.setBlock(adj, Blocks.SAND.defaultBlockState(), Block.UPDATE_ALL); + } else if ((adjState.is(Blocks.SAND) || adjState.is(Blocks.DIRT) + || adjState.is(Blocks.COARSE_DIRT)) && intensity > 0.75) { + level.setBlock(adj, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + } + break; + } + } + } + + /** + * Active volcanic eruption: places lava source blocks at random high-surface positions. + * Lava flows downhill under vanilla physics, permanently reshaping terrain. + * Fires ~20% of the time per call so flows build gradually. + */ + private void lavaFlow(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.20) return; + int attempts = Math.max(1, (int) (intensity * 3)); + for (int i = 0; i < attempts; 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()) continue; + BlockPos pos = new BlockPos(x, y, z); + if (!level.isLoaded(pos)) continue; + var state = level.getBlockState(pos); + boolean naturalRock = state.is(Blocks.STONE) || state.is(Blocks.DEEPSLATE) + || state.is(Blocks.ANDESITE) || state.is(Blocks.DIORITE) + || state.is(Blocks.GRANITE) || state.is(Blocks.GRAVEL) + || state.is(Blocks.GRASS_BLOCK) || state.is(Blocks.DIRT) + || state.is(Blocks.TUFF); + if (!naturalRock) continue; + BlockPos above = pos.above(); + if (!level.isLoaded(above) || !level.getBlockState(above).isAir()) continue; + level.setBlock(above, Blocks.LAVA.defaultBlockState(), Block.UPDATE_ALL); + LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect LAVA_FLOW at " + above); + } + } + + /** + * Deposits volcanic ash: grass → tuff, loose soil → gravel; surface plants are killed. + * Fires ~25% of the time; applied over wide areas during eruptions. + */ + private void ashDeposit(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.25) return; + int attempts = Math.max(1, (int) (intensity * BLOCK_ATTEMPTS)); + 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)) { + level.setBlock(pos, Blocks.TUFF.defaultBlockState(), Block.UPDATE_ALL); + } else if (state.is(Blocks.DIRT) || state.is(Blocks.COARSE_DIRT)) { + level.setBlock(pos, Blocks.GRAVEL.defaultBlockState(), Block.UPDATE_ALL); + } + BlockPos above = pos.above(); + if (!level.isLoaded(above)) continue; + var aboveState = level.getBlockState(above); + if (aboveState.is(Blocks.SHORT_GRASS) || aboveState.is(Blocks.TALL_GRASS) + || aboveState.is(Blocks.FERN) || aboveState.is(BlockTags.SAPLINGS) + || aboveState.is(Blocks.DANDELION) || aboveState.is(Blocks.POPPY)) { + level.setBlock(above, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + + /** + * Solidifies cooling lava into new permanent land: lava adjacent to water → cobblestone; + * lava exposed to air → basalt. This creates terrain that did not exist before the eruption. + * Fires ~35% of the time per call. + */ + private void cobblestoneFromLava(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.35) return; + int attempts = Math.max(2, (int) (intensity * 8)); + for (int i = 0; i < attempts; i++) { + int x = baseX + random.nextInt(REGION_BLOCKS); + int z = baseZ + random.nextInt(REGION_BLOCKS); + int surfY = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z); + for (int y = surfY + 2; y >= Math.max(level.getMinBuildHeight(), surfY - 4); y--) { + BlockPos pos = new BlockPos(x, y, z); + if (!level.isLoaded(pos)) continue; + if (!level.getBlockState(pos).is(Blocks.LAVA)) continue; + boolean adjacentWater = false; + for (Direction dir : new Direction[]{ + Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST, Direction.UP}) { + BlockPos adj = pos.relative(dir); + if (level.isLoaded(adj) && level.getFluidState(adj).is(FluidTags.WATER)) { + adjacentWater = true; + break; + } + } + if (adjacentWater) { + level.setBlock(pos, Blocks.COBBLESTONE.defaultBlockState(), Block.UPDATE_ALL); + LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect COBBLESTONE_FORMS at " + pos); + } else if (random.nextDouble() < 0.40) { + level.setBlock(pos, Blocks.BASALT.defaultBlockState(), Block.UPDATE_ALL); + LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect COBBLESTONE_FORMS basalt at " + pos); + } + break; + } + } + } + private BlockPos surfaceAt(ServerLevel level, int x, int z) { int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1; if (y < level.getMinBuildHeight()) {