diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 4496b9e..d808ab9 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -79,6 +79,8 @@ public class LivingWorldMod { private static final double GROUND_POLLUTION_PER_CAMPFIRE = 0.05; private static final int PLAYER_CHECK_INTERVAL = 20; + /** Re-scan water bodies every ~10 min so player-built pools are detected promptly. */ + private static final int WATER_BODY_RESCAN_INTERVAL = 600; // × PLAYER_CHECK_INTERVAL ticks /** Passive mobs suppressed in regions with ecosystem health below this. */ private static final double PASSIVE_SUPPRESS_HEALTH = 30.0; @@ -93,7 +95,9 @@ 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<>(); + /** Stores playerCheckTick value when a region was last water-body-scanned. */ + private final Map waterBodyLastScan = new HashMap<>(); + private final Set elevationInitialized = new HashSet<>(); public LivingWorldMod(IEventBus eventBus) { LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting..."); @@ -128,7 +132,8 @@ public class LivingWorldMod { playerRegionCache.clear(); playerRainState.clear(); biomeInitialized.clear(); - waterBodyInitialized.clear(); + waterBodyLastScan.clear(); + elevationInitialized.clear(); this.minecraftServer = null; }); @@ -168,10 +173,18 @@ public class LivingWorldMod { // Step 4: Agriculture — bone meal boosts soil fertility. NeoForge.EVENT_BUS.addListener(PlayerInteractEvent.RightClickBlock.class, event -> { if (!bootstrap.isServerReady()) return; - if (!event.getItemStack().is(Items.BONE_MEAL)) return; if (!(event.getLevel() instanceof ServerLevel level)) return; BlockPos pos = event.getPos(); - bootstrap.handleBoneMeal(level.dimension().location().toString(), pos.getX(), pos.getZ()); + if (event.getItemStack().is(Items.BONE_MEAL)) { + bootstrap.handleBoneMeal(level.dimension().location().toString(), pos.getX(), pos.getZ()); + } else if (event.getItemStack().is(Items.WATER_BUCKET)) { + // Player placed a water source — invalidate the water-body scan for this region + // so the next player-check cycle detects the new water body. + RegionCoordinate coord = RegionCoordinate.fromBlock( + level.dimension().location().toString(), pos.getX(), pos.getZ(), + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); + waterBodyLastScan.remove(coord); + } }); // Step 4: Agriculture — harvesting fully-grown crops drains soil fertility. @@ -240,22 +253,34 @@ public class LivingWorldMod { bootstrap.notifyPlayerInRegion(coord); if (player.level() instanceof ServerLevel serverLevel) { - // Biome-aware succession cap — derived once per region. + // Biome-aware succession cap — derived once per region on first entry. 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); + // Elevation sampling — derived once per region; feeds water runoff physics. + if (!elevationInitialized.contains(coord)) { + double elev = sampleRegionElevation(serverLevel, coord); + bootstrap.setRegionElevation(coord, elev); + elevationInitialized.add(coord); } } } + // Water body scan — periodic so player-built pools are detected within ~10 min. + if (player.level() instanceof ServerLevel serverLevel) { + Integer lastScan = waterBodyLastScan.get(coord); + boolean needsScan = lastScan == null + || (playerCheckTick - lastScan) >= WATER_BODY_RESCAN_INTERVAL; + if (needsScan) { + if (hasWaterBody(serverLevel, coord)) { + bootstrap.applyWaterBodyBoost(coord); + } + waterBodyLastScan.put(coord, playerCheckTick); + } + } + // Regional weather — send per-player rain/thunder packets so each region // has its own sky independently of global Minecraft weather. AtmosphereRegionData atm = bootstrap.getRegionalWeather(coord).orElse(null); @@ -315,6 +340,21 @@ public class LivingWorldMod { private static final int REGION_BLOCKS = LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16; + /** Samples 25 random surface heights in the region and returns the average Y. */ + private double sampleRegionElevation(ServerLevel level, RegionCoordinate coord) { + int baseX = coord.x() * REGION_BLOCKS; + int baseZ = coord.z() * REGION_BLOCKS; + double total = 0; + int count = 0; + for (int i = 0; i < 25; i++) { + int x = baseX + random.nextInt(REGION_BLOCKS); + int z = baseZ + random.nextInt(REGION_BLOCKS); + total += level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z); + count++; + } + return count > 0 ? total / count : 64.0; + } + /** 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; diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index d86a3a2..1a3b3a1 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -88,6 +88,8 @@ public final class LivingWorldBootstrap { private LongSupplier absoluteDaySupplier = () -> 0L; private boolean initialized; private boolean serverReady; + /** Average surface elevation (block Y) per region, sampled once by the platform layer. */ + private final Map regionElevations = new HashMap<>(); /** * Called once during mod construction. @@ -209,6 +211,7 @@ public final class LivingWorldBootstrap { moduleRegistry.shutdownAll(); climateTracker.save(worldSaveDirectory.resolve("living_world/global_climate.dat")); hudEnabledPlayers.clear(); + regionElevations.clear(); serverReady = false; LivingWorldLogger.info( DiagnosticCategory.BOOTSTRAP, @@ -344,6 +347,8 @@ public final class LivingWorldBootstrap { applySeasonalEffects(); climateTracker.update(regionManager.getActiveRegions()); applyClimateWarmingEffects(); + applyWaterRunoff(); + applyDynamicCapUpdate(); regionManager.saveDirtyRegions(); } } @@ -431,6 +436,122 @@ public final class LivingWorldBootstrap { } } + /** + * Stores the sampled average surface elevation for a region so that + * {@link #applyWaterRunoff()} can model water flowing downhill between neighbours. + * Called once per region by the platform layer when a player first enters it. + */ + public void setRegionElevation(RegionCoordinate coord, double elevation) { + if (coord != null) regionElevations.put(coord, elevation); + } + + /** + * Transfers water availability from higher-elevation regions to lower neighbours. + * Simulates rainfall runoff: valleys collect water, hilltops drain. The transfer + * only occurs while regional rain is falling (rain level > 0.1) so dry seasons + * don't create permanent hydration differences from a single past rain event. + */ + private void applyWaterRunoff() { + Collection active = regionManager.getActiveRegions(); + if (active.size() < 2) 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 myElevation = regionElevations.get(coord); + if (myElevation == null) continue; + + AtmosphereRegionData myAtm = region.getModuleData() + .get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class) + .orElse(null); + if (myAtm == null || myAtm.getRainLevel() < 0.1) continue; + + WaterRegionData myWater = region.getModuleData() + .get(WaterModule.MODULE_ID, WaterRegionData.class) + .orElse(null); + if (myWater == null) continue; + + for (int[] off : offsets) { + RegionCoordinate nCoord = new RegionCoordinate( + coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]); + Region neighbour = byCoord.get(nCoord); + if (neighbour == null) continue; + Double nElevation = regionElevations.get(nCoord); + if (nElevation == null) continue; + + double heightDiff = myElevation - nElevation; + if (heightDiff <= 2.0) continue; // only meaningful slopes trigger runoff + + // Transfer rate: proportional to slope and rain, capped at a small value + // so the simulation doesn't drain a region in one cycle. + double runoff = Math.min(heightDiff / 200.0, 0.2) * myAtm.getRainLevel(); + myWater.setWaterAvailability(Math.max(0, myWater.getWaterAvailability() - runoff * 0.4)); + + WaterRegionData nWater = neighbour.getModuleData() + .get(WaterModule.MODULE_ID, WaterRegionData.class) + .orElse(null); + if (nWater == null) continue; + nWater.setWaterAvailability(Math.min(100, nWater.getWaterAvailability() + runoff)); + nWater.setDroughtRisk(Math.max(0, nWater.getDroughtRisk() - runoff * 0.5)); + neighbour.getModuleData().put(WaterModule.MODULE_ID, nWater); + regionManager.markDirty(neighbour); + } + region.getModuleData().put(WaterModule.MODULE_ID, myWater); + regionManager.markDirty(region); + } + } + + /** + * Raises the succession cap for regions where physical conditions have improved enough + * to support more advanced ecosystems. The cap can only increase — regression is handled + * separately by damage accumulation within {@link com.livingworld.modules.recovery.RecoveryModule}. + * + *

This is the mechanism that allows a desert (initial cap = SPARSE_GRASS) to eventually + * support a MATURE_FOREST if the player (or natural runoff) sustains good water and soil + * conditions for long enough. + */ + private void applyDynamicCapUpdate() { + for (Region region : regionManager.getActiveRegions()) { + RecoveryRegionData recovery = region.getModuleData() + .get(RecoveryModule.MODULE_ID, RecoveryRegionData.class) + .orElse(null); + if (recovery == null) continue; + + WaterRegionData water = region.getModuleData() + .get(WaterModule.MODULE_ID, WaterRegionData.class) + .orElse(null); + SoilRegionData soil = region.getModuleData() + .get(SoilModule.MODULE_ID, SoilRegionData.class) + .orElse(null); + if (water == null || soil == null) continue; + + SuccessionStage current = recovery.getMaxSuccessionStage(); + SuccessionStage computed = computeDynamicCap(water, soil); + if (computed.ordinal() > current.ordinal()) { + recovery.setMaxSuccessionStage(computed); + region.getModuleData().put(RecoveryModule.MODULE_ID, recovery); + regionManager.markDirty(region); + LivingWorldLogger.info(DiagnosticCategory.SIMULATION, + "Region " + region.getCoordinate() + " succession cap raised to " + computed); + } + } + } + + private static SuccessionStage computeDynamicCap(WaterRegionData water, SoilRegionData soil) { + // Net water score: availability minus half the drought penalty. + double w = water.getWaterAvailability() - water.getDroughtRisk() * 0.5; + // Net soil score: fertility minus half the contamination penalty. + double s = soil.getFertility() - soil.getContamination() * 0.5; + if (w >= 60 && s >= 60) return SuccessionStage.MATURE_FOREST; + if (w >= 42 && s >= 42) return SuccessionStage.YOUNG_WOODLAND; + if (w >= 28 && s >= 28) return SuccessionStage.GRASSLAND; + if (w >= 15 || s >= 15) return SuccessionStage.SCRUBLAND; + return SuccessionStage.SPARSE_GRASS; + } + /** 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(); @@ -476,12 +597,20 @@ public final class LivingWorldBootstrap { 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)) + Optional regionOpt = regionManager.resolve(coord); + String successionLine = regionOpt.flatMap(r -> r.getModuleData() + .get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)) + .map(rec -> String.format(" | Stage: %s (cap: %s)", + rec.getSuccessionStage().name(), rec.getMaxSuccessionStage().name())) + .orElse(""); + Double elev = regionElevations.get(coord); + String elevLine = elev != null ? String.format(" | Elev: %.0f", elev) : ""; + return regionOpt.flatMap(r -> r.getModuleData().get(AtmosphereModule.MODULE_ID, AtmosphereRegionData.class)) .map(atm -> String.format( - "Region (%d,%d) | Season: %s | Rain: %.0f%% | Storm: %.0f%%", + "Region (%d,%d) | Season: %s | Rain: %.0f%% | Storm: %.0f%%%s%s", coord.x(), coord.z(), season.displayName(), - atm.getRainLevel() * 100, atm.getThunderLevel() * 100)) + atm.getRainLevel() * 100, atm.getThunderLevel() * 100, + successionLine, elevLine)) .orElse(String.format("Region (%d,%d) — atmosphere not yet computed. Season: %s", coord.x(), coord.z(), season.displayName())); } @@ -711,14 +840,16 @@ public final class LivingWorldBootstrap { (data, w) -> { RecoveryRegionData d = data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class) .orElseGet(RecoveryRegionData::defaults); - w.writeString("successionStage", d.getSuccessionStage().name()); - w.writeDouble("recoveryProgress", d.getRecoveryProgress()); + w.writeString("successionStage", d.getSuccessionStage().name()); + w.writeDouble("recoveryProgress", d.getRecoveryProgress()); w.writeDouble("damageAccumulation", d.getDamageAccumulation()); + w.writeString("maxSuccessionStage", d.getMaxSuccessionStage().name()); }, (r, data) -> data.put(RecoveryModule.MODULE_ID, new RecoveryRegionData( SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())), r.readDouble("recoveryProgress", 0.0), - r.readDouble("damageAccumulation", 0.0)))); + r.readDouble("damageAccumulation", 0.0), + SuccessionStage.valueOf(r.readString("maxSuccessionStage", SuccessionStage.MATURE_FOREST.name()))))); service.registerModuleCodec( EcosystemModule.MODULE_ID, diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java index 08e1b17..27e4022 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -45,4 +45,12 @@ public enum WorldEffectType { * on flammable surface blocks and spreads naturally via Minecraft fire tick. */ WILDFIRE, + + /** + * Heavy regional rain on low-succession (barren / sparse-grass) terrain causes + * water to pool in low-lying spots. The platform adapter places water source blocks + * in surface depressions. Once present, the water body scan detects them and feeds + * back a hydration boost to the region, enabling succession toward fertile land. + */ + WATER_POOL_FORMS, } diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java index 8d5ddc7..0989561 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java @@ -206,6 +206,18 @@ public final class WorldEffectsModule implements SimulationModule { emitted = true; } + // --- Effect 7: rain pools in arid low-succession terrain --- + // Heavy regional rain on barren/sparse-grass land cannot drain into vegetation; + // water collects in depressions, forming puddles visible as actual water blocks. + if (recovery != null && atm != null + && recovery.getSuccessionStage().ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal() + && atm.getRainLevel() > 0.3) { + double intensity = computeIntensity(atm.getRainLevel() - 0.3, 0.5); + emit(new WorldEffectRequest( + WorldEffectType.WATER_POOL_FORMS, 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 1f4661e..19d0c60 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -8,6 +8,7 @@ import com.livingworld.modules.worldeffects.WorldEffectRequest; import java.util.Random; import java.util.function.Supplier; import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; import net.minecraft.core.particles.ParticleTypes; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; @@ -69,6 +70,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred case WILDFIRE -> igniteVegetation(level, baseX, baseZ, request.intensity()); + case WATER_POOL_FORMS -> + formWaterPool(level, baseX, baseZ, request.intensity()); } } @@ -185,6 +188,55 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } } + /** + * Places water source blocks in the lowest depression found among random surface samples. + * Fires ~5% of times it is called so pools build gradually rather than flooding the region. + * Once the water body scan detects these blocks the region gains a hydration boost that + * drives succession — the core mechanism behind desert→grassland transformation. + */ + private void formWaterPool(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.05) return; // throttle: ~1 pool placement per 20 calls + + // Find the lowest surface point among a set of random samples (depression heuristic). + int samples = Math.max(8, (int) (intensity * 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; + + // Only pool on permeable/soft surfaces — not stone floors. + var surface = level.getBlockState(lowestPos); + if (!surface.is(Blocks.SAND) && !surface.is(Blocks.RED_SAND) + && !surface.is(Blocks.GRAVEL) && !surface.is(Blocks.DIRT) + && !surface.is(Blocks.COARSE_DIRT) && !surface.is(Blocks.GRASS_BLOCK)) return; + + BlockPos poolPos = lowestPos.above(); + if (!level.isLoaded(poolPos)) return; + var poolState = level.getBlockState(poolPos); + if (!poolState.isAir() && !poolState.is(Blocks.WATER)) return; + + level.setBlock(poolPos, Blocks.WATER.defaultBlockState(), Block.UPDATE_ALL); + LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect WATER_POOL_FORMS at " + poolPos); + + // At high intensity the pool widens into adjacent depressions at the same height. + if (intensity > 0.6) { + for (Direction dir : Direction.Plane.HORIZONTAL) { + BlockPos adj = poolPos.relative(dir); + if (level.isLoaded(adj) && level.getBlockState(adj).isAir() + && level.getBlockState(adj.below()).isSolid() + && adj.getY() <= poolPos.getY()) { + level.setBlock(adj, Blocks.WATER.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + } + private void spawnPollutionParticles( ServerLevel level, int baseX, int baseZ, double intensity) { int count = Math.max(1, (int) (intensity * 8));