diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 6a05b06..1d1305a 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -7,6 +7,8 @@ import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.entity.living.FinalizeSpawnEvent; +import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; +import net.neoforged.neoforge.event.entity.EntityLeaveLevelEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; import net.neoforged.neoforge.event.level.BlockEvent; import net.neoforged.neoforge.event.level.ChunkEvent; @@ -30,7 +32,9 @@ import net.minecraft.world.entity.animal.Animal; import net.minecraft.world.entity.animal.AbstractFish; import net.minecraft.world.entity.animal.Dolphin; import net.minecraft.world.entity.animal.Squid; +import net.minecraft.world.entity.animal.Bee; import net.minecraft.world.entity.monster.Monster; +import net.minecraft.world.entity.MobSpawnType; import net.minecraft.world.item.Items; import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.Biomes; @@ -124,6 +128,8 @@ public class LivingWorldMod { /** Stores playerCheckTick value when a region was last water-body-scanned. */ private final Map waterBodyLastScan = new HashMap<>(); private final Set elevationInitialized = new HashSet<>(); + private final Map trackedPassiveMobs = new HashMap<>(); + private boolean migrationCompanionSpawn; /** Stores playerCheckTick when a region last played an ambient sound (per-region throttle). */ private final Map regionSoundLastTick = new HashMap<>(); @@ -153,7 +159,9 @@ public class LivingWorldMod { this.minecraftServer = event.getServer(); bootstrap.onServerStarted(); bootstrap.getWorldEffectsModule().registerConsumer( - new NeoForgeWorldEffectExecutor(() -> minecraftServer)); + new NeoForgeWorldEffectExecutor( + () -> minecraftServer, + coord -> !bootstrap.isPollinatorCollapse(coord))); bootstrap.setOverworldRaining( () -> minecraftServer != null && minecraftServer.overworld().isRaining()); bootstrap.setAbsoluteDaySupplier( @@ -240,6 +248,7 @@ public class LivingWorldMod { waterBodyLastScan.clear(); elevationInitialized.clear(); regionSoundLastTick.clear(); + trackedPassiveMobs.clear(); tideTick = 0; lastTideLevel = 0.0; riverCurrentTick = 0; @@ -298,19 +307,67 @@ public class LivingWorldMod { event.setSpawnCancelled(true); return; } + if (event.getEntity() instanceof Bee && bootstrap.isPollinatorCollapse(spawnCoord)) { + event.setSpawnCancelled(true); + return; + } if (event.getEntity() instanceof Animal) { - if (health < PASSIVE_SUPPRESS_HEALTH) { + if (migrationCompanionSpawn) { + trackedPassiveMobs.put(event.getEntity().getUUID(), spawnCoord); + bootstrap.recordPassiveMobSpawn(spawnCoord); + return; + } + if (bootstrap.isLocallyExtinct(spawnCoord)) { + event.setSpawnCancelled(true); + return; + } + double attraction = bootstrap.getMigrationAttraction(spawnCoord); + if (health < PASSIVE_SUPPRESS_HEALTH || attraction < 0.0) { double chance = (PASSIVE_SUPPRESS_HEALTH - health) / PASSIVE_SUPPRESS_HEALTH * 0.7; - if (random.nextDouble() < chance) event.setSpawnCancelled(true); + chance = Math.max(chance, -attraction * 0.5); + if (random.nextDouble() < chance) { + event.setSpawnCancelled(true); + return; + } + } + trackedPassiveMobs.put(event.getEntity().getUUID(), spawnCoord); + bootstrap.recordPassiveMobSpawn(spawnCoord); + if (attraction >= 0.5 && bootstrap.getPassiveMobPressure(spawnCoord) < 12 + && random.nextDouble() < 0.50) { + migrationCompanionSpawn = true; + try { + event.getEntity().getType().spawn( + level, + event.getEntity().blockPosition().offset( + random.nextInt(5) - 2, 0, random.nextInt(5) - 2), + MobSpawnType.NATURAL); + } finally { + migrationCompanionSpawn = false; + } } } else if (event.getEntity() instanceof Monster) { + int preyPressure = bootstrap.getPassiveMobPressure(spawnCoord); + if (preyPressure == 0 && random.nextDouble() < 0.55) { + event.setSpawnCancelled(true); + return; + } if (health > HOSTILE_SUPPRESS_HEALTH) { double chance = (health - HOSTILE_SUPPRESS_HEALTH) / (100.0 - HOSTILE_SUPPRESS_HEALTH) * 0.5; + if (preyPressure > 8) chance *= 0.5; if (random.nextDouble() < chance) event.setSpawnCancelled(true); } } }); + NeoForge.EVENT_BUS.addListener(LivingDeathEvent.class, event -> { + RegionCoordinate coord = trackedPassiveMobs.remove(event.getEntity().getUUID()); + if (coord != null) bootstrap.recordPassiveMobDeparture(coord); + }); + NeoForge.EVENT_BUS.addListener(EntityLeaveLevelEvent.class, event -> { + RegionCoordinate coord = trackedPassiveMobs.remove(event.getEntity().getUUID()); + if (coord != null) bootstrap.recordPassiveMobDeparture(coord); + }); + // Step 4: Agriculture — bone meal boosts soil fertility. NeoForge.EVENT_BUS.addListener(PlayerInteractEvent.RightClickBlock.class, event -> { if (!bootstrap.isServerReady()) return; @@ -339,6 +396,15 @@ public class LivingWorldMod { BlockPos pos = event.getPos(); bootstrap.handleCropHarvest(level.dimension().location().toString(), pos.getX(), pos.getZ()); }); + NeoForge.EVENT_BUS.addListener(BlockEvent.EntityPlaceEvent.class, event -> { + if (!bootstrap.isServerReady() || !(event.getLevel() instanceof ServerLevel level)) return; + if (!event.getPlacedBlock().is(BlockTags.SAPLINGS)) return; + RegionCoordinate coord = RegionCoordinate.fromBlock( + level.dimension().location().toString(), + event.getPos().getX(), event.getPos().getZ(), + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); + bootstrap.handleSaplingPlaced(coord, level.getGameTime() / 24000L); + }); LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully."); } diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index b1e13e2..43db1d3 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -145,6 +145,15 @@ public final class LivingWorldBootstrap { private final Map blizzardHistory = new HashMap<>(); private int hydrologyCycleTick = 0; private int dynamicSeaLevel = 62; + private final Map passiveMobPressure = new HashMap<>(); + private final Map wildlifeAbsentCycles = new HashMap<>(); + private final Set extinctRegions = new HashSet<>(); + private final Set pollinatorCollapseRegions = new HashSet<>(); + private final Map saplingPlacementDay = new HashMap<>(); + private final Map saplingPlacementCount = new HashMap<>(); + private final Map rewildingBoostCycles = new HashMap<>(); + private final Map bogWetCycles = new HashMap<>(); + private final Set waterloggedRegions = new HashSet<>(); private PlatformAdapter platformAdapter; private Path worldSaveDirectory; @@ -322,6 +331,15 @@ public final class LivingWorldBootstrap { blizzardHistory.clear(); hydrologyCycleTick = 0; dynamicSeaLevel = ecosystemTuning.getSeaLevel(); + passiveMobPressure.clear(); + wildlifeAbsentCycles.clear(); + extinctRegions.clear(); + pollinatorCollapseRegions.clear(); + saplingPlacementDay.clear(); + saplingPlacementCount.clear(); + rewildingBoostCycles.clear(); + bogWetCycles.clear(); + waterloggedRegions.clear(); simSpeedMultiplier = 1; serverReady = false; LivingWorldLogger.info( @@ -387,7 +405,8 @@ public final class LivingWorldBootstrap { .get(SoilModule.MODULE_ID, SoilRegionData.class) .orElse(null); if (soil == null) return; - soil.setFertility(Math.min(100, soil.getFertility() + 2.0)); + double gain = isPollinatorCollapse(coord) ? 1.0 : 2.0; + soil.setFertility(Math.min(100, soil.getFertility() + gain)); region.getModuleData().put(SoilModule.MODULE_ID, soil); regionManager.markDirty(region); }); @@ -488,6 +507,136 @@ public final class LivingWorldBootstrap { applyGeologicalActivity(); } applyHydrologyExpansion(); + applyEcologyExpansion(); + } + + public void recordPassiveMobSpawn(RegionCoordinate coord) { + if (coord != null) passiveMobPressure.merge(coord, 1, Integer::sum); + } + + public void recordPassiveMobDeparture(RegionCoordinate coord) { + if (coord != null) { + passiveMobPressure.compute(coord, + (ignored, pressure) -> Math.max(0, (pressure == null ? 0 : pressure) - 1)); + } + } + + public int getPassiveMobPressure(RegionCoordinate coord) { + return passiveMobPressure.getOrDefault(coord, 0); + } + + public double getMigrationAttraction(RegionCoordinate coord) { + if (coord == null || regionManager == null) return 0.0; + Region region = regionManager.resolve(coord).orElse(null); + if (region == null) return 0.0; + double ownHealth = region.getMetrics().getEcosystemHealth(); + double bestNeighbour = ownHealth; + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + for (int[] offset : offsets) { + RegionCoordinate neighbour = new RegionCoordinate( + coord.dimensionId(), coord.x() + offset[0], coord.z() + offset[1]); + Region adjacent = regionManager.resolve(neighbour).orElse(null); + if (adjacent != null) { + bestNeighbour = Math.max(bestNeighbour, adjacent.getMetrics().getEcosystemHealth()); + } + } + return Math.max(-1.0, Math.min(1.0, (ownHealth - bestNeighbour + 25.0) / 50.0)); + } + + public boolean isLocallyExtinct(RegionCoordinate coord) { + return coord != null && extinctRegions.contains(coord); + } + + public boolean isPollinatorCollapse(RegionCoordinate coord) { + return coord != null && pollinatorCollapseRegions.contains(coord); + } + + public void handleSaplingPlaced(RegionCoordinate coord, long absoluteDay) { + if (!serverReady || coord == null) return; + long previousDay = saplingPlacementDay.getOrDefault(coord, Long.MIN_VALUE); + if (previousDay != absoluteDay) { + saplingPlacementDay.put(coord, absoluteDay); + saplingPlacementCount.put(coord, 0); + } + int count = saplingPlacementCount.merge(coord, 1, Integer::sum); + if (count < 10 || rewildingBoostCycles.containsKey(coord)) return; + Region region = regionManager.resolve(coord).orElse(null); + if (region == null) return; + RecoveryRegionData recovery = region.getModuleData() + .get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElse(null); + if (recovery == null || recovery.getSuccessionStage().ordinal() + > SuccessionStage.SPARSE_GRASS.ordinal()) return; + rewildingBoostCycles.put(coord, 20); + pendingEventMessages.add("[LW] Rewilding effort detected in region (" + + coord.x() + "," + coord.z() + ") - ecosystem recovering!"); + } + + private void applyEcologyExpansion() { + for (Region region : regionManager.getActiveRegions()) { + RegionCoordinate coord = region.getCoordinate(); + RecoveryRegionData recovery = region.getModuleData() + .get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElse(null); + PollutionRegionData pollution = region.getModuleData() + .get(PollutionModule.MODULE_ID, PollutionRegionData.class).orElse(null); + VegetationRegionData vegetation = region.getModuleData() + .get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElse(null); + WaterRegionData water = region.getModuleData() + .get(WaterModule.MODULE_ID, WaterRegionData.class).orElse(null); + if (recovery == null || pollution == null || vegetation == null || water == null) continue; + + int pressure = passiveMobPressure.getOrDefault(coord, 0); + if (pressure == 0) { + int absent = wildlifeAbsentCycles.merge(coord, 1, Integer::sum); + if (absent >= 100) extinctRegions.add(coord); + } else { + wildlifeAbsentCycles.remove(coord); + } + if (extinctRegions.contains(coord) + && recovery.getSuccessionStage().ordinal() + >= SuccessionStage.YOUNG_WOODLAND.ordinal()) { + extinctRegions.remove(coord); + wildlifeAbsentCycles.remove(coord); + pendingEventMessages.add("[LW] Wildlife returning to region (" + + coord.x() + "," + coord.z() + ")."); + } + + if (pollution.getGroundPollution() > 50.0 + && vegetation.getFlowerPressure() > 5.0) { + pollinatorCollapseRegions.add(coord); + } else if (pollution.getGroundPollution() < 20.0) { + pollinatorCollapseRegions.remove(coord); + } + + Integer boost = rewildingBoostCycles.get(coord); + if (boost != null) { + if (boost == 20 || boost == 10) recovery.boostOneStage(); + pollution.addPollution(-1.5, -1.5, -1.5); + if (boost <= 1) rewildingBoostCycles.remove(coord); + else rewildingBoostCycles.put(coord, boost - 1); + } + + boolean bogCandidate = coastalWetlandRegions.contains(coord) + && waterloggedRegions.contains(coord) + && water.getWaterAvailability() > 75.0 + && recovery.getSuccessionStage().ordinal() <= SuccessionStage.GRASSLAND.ordinal(); + if (bogCandidate) { + int wetCycles = bogWetCycles.merge(coord, 1, Integer::sum); + if (wetCycles >= 30) { + recovery.setMaxSuccessionStage(SuccessionStage.SCRUBLAND); + if (worldEffectsModule != null) { + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.PEAT_FORMS, coord, + Math.min(1.0, wetCycles / 100.0))); + } + } + } else { + bogWetCycles.remove(coord); + } + + region.getModuleData().put(RecoveryModule.MODULE_ID, recovery); + region.getModuleData().put(PollutionModule.MODULE_ID, pollution); + regionManager.markDirty(region); + } } private void applyHydrologyExpansion() { @@ -2080,6 +2229,7 @@ public final class LivingWorldBootstrap { /** 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; + waterloggedRegions.add(coord); regionManager.resolve(coord).ifPresent(region -> { WaterRegionData water = region.getModuleData() .get(WaterModule.MODULE_ID, WaterRegionData.class) diff --git a/src/main/java/com/livingworld/modules/recovery/RecoveryRegionData.java b/src/main/java/com/livingworld/modules/recovery/RecoveryRegionData.java index 4200b43..afd0c79 100644 --- a/src/main/java/com/livingworld/modules/recovery/RecoveryRegionData.java +++ b/src/main/java/com/livingworld/modules/recovery/RecoveryRegionData.java @@ -72,6 +72,16 @@ public final class RecoveryRegionData { } } + /** Advances one restoration stage without bypassing the region's biome cap. */ + public void boostOneStage() { + if (successionStage.hasNext() + && successionStage.next().ordinal() <= maxSuccessionStage.ordinal()) { + successionStage = successionStage.next(); + recoveryProgress = 0.0; + damageAccumulation = Math.max(0.0, damageAccumulation - 20.0); + } + } + // ------------------------------------------------------------------ // Mutation // ------------------------------------------------------------------ diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java index 99e0aea..db984c2 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -187,4 +187,7 @@ public enum WorldEffectType { /** Falling river water deepens its impact basin. */ PLUNGE_POOL_DEEPENS, + + /** Waterlogged wetland soil slowly develops a persistent peat profile. */ + PEAT_FORMS, } diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index 22dd343..0ee3d2a 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -11,6 +11,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Supplier; +import java.util.function.Predicate; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; import net.minecraft.core.particles.ParticleTypes; @@ -42,12 +43,20 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { private final Supplier serverSupplier; private final Random random = new Random(); + private final Predicate flowersAllowed; private record TimedWater(BlockPos pos, long expiresAt) {} private final Map, List> geyserWater = new HashMap<>(); private final Map, List> floodWater = new HashMap<>(); public NeoForgeWorldEffectExecutor(Supplier serverSupplier) { + this(serverSupplier, ignored -> true); + } + + public NeoForgeWorldEffectExecutor( + Supplier serverSupplier, + Predicate flowersAllowed) { this.serverSupplier = serverSupplier; + this.flowersAllowed = flowersAllowed != null ? flowersAllowed : ignored -> true; } @Override @@ -72,7 +81,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { case GRASS_DEGRADES_TO_DIRT -> degradeGrass(level, baseX, baseZ, request.intensity()); case VEGETATION_SPREADS -> - spreadVegetation(level, baseX, baseZ, request.intensity()); + spreadVegetation(level, baseX, baseZ, request.intensity(), + flowersAllowed.test(request.region())); case POLLUTION_VISUAL_INDICATOR -> spawnPollutionParticles(level, baseX, baseZ, request.intensity()); case SAPLING_GROWTH_BOOSTED -> @@ -150,6 +160,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { polishGlacierRock(level, baseX, baseZ, request.intensity()); case PLUNGE_POOL_DEEPENS -> deepenPlungePool(level, baseX, baseZ, request.intensity()); + case PEAT_FORMS -> + formPeat(level, baseX, baseZ, request.intensity()); } } @@ -180,7 +192,8 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } } - private void spreadVegetation(ServerLevel level, int baseX, int baseZ, double intensity) { + private void spreadVegetation( + ServerLevel level, int baseX, int baseZ, double intensity, boolean allowFlowers) { 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), @@ -197,7 +210,7 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { "WorldEffect VEGETATION_SPREADS at " + pos); } else if (state.is(Blocks.GRASS_BLOCK) && brightEnough) { // Add surface plants — flowers 1-in-5 chance, short grass otherwise - Block plant = random.nextInt(5) == 0 + Block plant = allowFlowers && random.nextInt(5) == 0 ? (random.nextBoolean() ? Blocks.DANDELION : Blocks.POPPY) : Blocks.SHORT_GRASS; level.setBlock(above, plant.defaultBlockState(), Block.UPDATE_ALL); @@ -1147,6 +1160,23 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } } + private void formPeat(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.20) return; + for (int i = 0; i < Math.max(2, (int) (intensity * 8)); 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(Blocks.DIRT)) { + level.setBlock(pos, Blocks.MUD.defaultBlockState(), Block.UPDATE_ALL); + } else if (state.is(Blocks.MUD)) { + level.setBlock(pos, Blocks.PACKED_MUD.defaultBlockState(), Block.UPDATE_ALL); + } else if (state.is(Blocks.PACKED_MUD) && intensity > 0.7) { + level.setBlock(pos, Blocks.BROWN_TERRACOTTA.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + 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()) {