diff --git a/build.gradle b/build.gradle index b7735cf..d99d05f 100644 --- a/build.gradle +++ b/build.gradle @@ -45,4 +45,7 @@ dependencies { test { useJUnitPlatform() + scanForTestClasses = false + include '**/*Test.class' + include '**/*Test$*.class' } diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 1d1305a..91ce5d2 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -12,6 +12,7 @@ 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; +import net.neoforged.neoforge.event.level.block.CropGrowEvent; import net.minecraft.world.level.ChunkPos; import net.neoforged.neoforge.event.server.ServerStartedEvent; import net.neoforged.neoforge.event.server.ServerStartingEvent; @@ -405,6 +406,16 @@ public class LivingWorldMod { LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); bootstrap.handleSaplingPlaced(coord, level.getGameTime() / 24000L); }); + NeoForge.EVENT_BUS.addListener(CropGrowEvent.Pre.class, event -> { + if (!bootstrap.isServerReady() || !(event.getLevel() instanceof ServerLevel level)) return; + RegionCoordinate coord = RegionCoordinate.fromBlock( + level.dimension().location().toString(), + event.getPos().getX(), event.getPos().getZ(), + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); + if (bootstrap.isCropGrowthSuppressed(coord)) { + event.setResult(CropGrowEvent.Pre.Result.DO_NOT_GROW); + } + }); LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully."); } @@ -486,6 +497,7 @@ public class LivingWorldMod { if (hasWaterBody(serverLevel, coord)) { bootstrap.applyWaterBodyBoost(coord); } + bootstrap.markFarmlandRegion(coord, hasFarmland(serverLevel, coord)); waterBodyLastScan.put(coord, playerCheckTick); } } @@ -711,10 +723,24 @@ public class LivingWorldMod { } float pitch = 0.75f + random.nextFloat() * 0.5f; + if (stage != null && stage.ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal() + && pollution > 50.0) { + return false; + } if (stage != null && stage.ordinal() >= SuccessionStage.YOUNG_WOODLAND.ordinal() - && health > 50.0 && random.nextInt(10) == 0) { - // Rustling leaves — healthy forest ambience. - playAmbientSound(player, Holder.direct(SoundEvents.AZALEA_LEAVES_STEP), 0.18f, pitch); + && health > 50.0 && random.nextInt(Math.max(3, 14 - (int) (health / 10))) == 0) { + int blend = random.nextInt(100); + if (stage == SuccessionStage.MATURE_FOREST && health > 70.0 && blend < 45) { + playAmbientSound(player, Holder.direct(SoundEvents.PARROT_AMBIENT), 0.12f, pitch); + } else if (stage == SuccessionStage.MATURE_FOREST && blend < 75) { + playAmbientSound(player, Holder.direct(SoundEvents.BEE_LOOP), 0.10f, pitch); + } else { + playAmbientSound(player, Holder.direct(SoundEvents.AZALEA_LEAVES_STEP), 0.18f, pitch); + } + return true; + } + if (stage == SuccessionStage.GRASSLAND && health > 35.0 && random.nextInt(12) == 0) { + playAmbientSound(player, Holder.direct(SoundEvents.GRASS_STEP), 0.12f, pitch); return true; } if (stage != null && stage.ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal() @@ -904,6 +930,22 @@ public class LivingWorldMod { return waterCount >= 5; } + private boolean hasFarmland(ServerLevel level, RegionCoordinate coord) { + int baseX = coord.x() * REGION_BLOCKS; + int baseZ = coord.z() * REGION_BLOCKS; + 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.MOTION_BLOCKING_NO_LEAVES, x, z) - 1; + BlockPos pos = new BlockPos(x, y, z); + if (level.isLoaded(pos) + && level.getBlockState(pos).is(net.minecraft.world.level.block.Blocks.FARMLAND)) { + return true; + } + } + return false; + } + /** 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 8a9f3c5..3a8c877 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -159,6 +159,11 @@ public final class LivingWorldBootstrap { private final Map bareDryCycles = new HashMap<>(); private final Map permafrostWarmCycles = new HashMap<>(); private final Set thawedPermafrostRegions = new HashSet<>(); + private final Map lowTreeCycles = new HashMap<>(); + private final Set deforestedRegions = new HashSet<>(); + private final Set farmlandRegions = new HashSet<>(); + private final Set exhaustedFarmlandRegions = new HashSet<>(); + private final Map farmingInactiveCycles = new HashMap<>(); private PlatformAdapter platformAdapter; private Path worldSaveDirectory; @@ -350,6 +355,11 @@ public final class LivingWorldBootstrap { bareDryCycles.clear(); permafrostWarmCycles.clear(); thawedPermafrostRegions.clear(); + lowTreeCycles.clear(); + deforestedRegions.clear(); + farmlandRegions.clear(); + exhaustedFarmlandRegions.clear(); + farmingInactiveCycles.clear(); simSpeedMultiplier = 1; serverReady = false; LivingWorldLogger.info( @@ -417,6 +427,8 @@ public final class LivingWorldBootstrap { if (soil == null) return; double gain = isPollinatorCollapse(coord) ? 1.0 : 2.0; soil.setFertility(Math.min(100, soil.getFertility() + gain)); + exhaustedFarmlandRegions.remove(coord); + farmingInactiveCycles.put(coord, 0); region.getModuleData().put(SoilModule.MODULE_ID, soil); regionManager.markDirty(region); }); @@ -427,6 +439,7 @@ public final class LivingWorldBootstrap { if (!serverReady) return; RegionCoordinate coord = RegionCoordinate.fromBlock( dimensionId, x, z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); + farmingInactiveCycles.put(coord, 0); regionManager.resolve(coord).ifPresent(region -> { SoilRegionData soil = region.getModuleData() .get(SoilModule.MODULE_ID, SoilRegionData.class) @@ -519,6 +532,7 @@ public final class LivingWorldBootstrap { applyHydrologyExpansion(); applyEcologyExpansion(); applyLongCycleEffects(); + applyPlayerFeedbackLoops(); } private void applyLongCycleEffects() { @@ -735,6 +749,79 @@ public final class LivingWorldBootstrap { } } + public void markFarmlandRegion(RegionCoordinate coord, boolean present) { + if (coord == null) return; + if (present) farmlandRegions.add(coord); + else farmlandRegions.remove(coord); + } + + public boolean isCropGrowthSuppressed(RegionCoordinate coord) { + return coord != null && exhaustedFarmlandRegions.contains(coord); + } + + public boolean isDeforestedRegion(RegionCoordinate coord) { + return coord != null && deforestedRegions.contains(coord); + } + + private void applyPlayerFeedbackLoops() { + if (worldEffectsModule == null) return; + for (Region region : regionManager.getActiveRegions()) { + RegionCoordinate coord = region.getCoordinate(); + VegetationRegionData vegetation = region.getModuleData() + .get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElse(null); + SoilRegionData soil = region.getModuleData() + .get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null); + RecoveryRegionData recovery = region.getModuleData() + .get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElse(null); + ResourceRegionData resources = region.getModuleData() + .get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElse(null); + if (vegetation == null || soil == null || recovery == null || resources == null) continue; + + if (vegetation.getTreePressure() < 5.0 && resources.getLoggingDepletion() > 20.0) { + int cycles = lowTreeCycles.merge(coord, 1, Integer::sum); + if (cycles >= 10 && deforestedRegions.add(coord)) { + pendingEventMessages.add("[LW] Deforestation detected at (" + + coord.x() + "," + coord.z() + ") - soil erosion accelerating."); + } + } else if (vegetation.getTreePressure() >= 15.0) { + lowTreeCycles.remove(coord); + deforestedRegions.remove(coord); + } + + if (deforestedRegions.contains(coord)) { + recovery.setMaxSuccessionStage(SuccessionStage.SPARSE_GRASS); + soil.setContamination(Math.min(100, soil.getContamination() + 0.10)); + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.RIVER_SILTS, coord, 0.6)); + } + + if (farmlandRegions.contains(coord)) { + int inactive = farmingInactiveCycles.merge(coord, 1, Integer::sum); + boolean exhausted = soil.getFertility() < 20.0 && soil.getContamination() > 40.0; + if (exhausted) { + if (exhaustedFarmlandRegions.add(coord)) { + pendingEventMessages.add("[LW] Crop exhaustion near region (" + + coord.x() + "," + coord.z() + + ") - leave fields fallow or use bone meal."); + } + worldEffectsModule.queueEffect(new WorldEffectRequest( + WorldEffectType.CROPLAND_EXHAUSTS, coord, 0.7)); + } + if (inactive >= 50) { + soil.setFertility(Math.min(100, soil.getFertility() + 0.15)); + soil.setContamination(Math.max(0, soil.getContamination() - 0.08)); + if (soil.getFertility() >= 25.0 || soil.getContamination() <= 30.0) { + exhaustedFarmlandRegions.remove(coord); + } + } + } + + region.getModuleData().put(SoilModule.MODULE_ID, soil); + region.getModuleData().put(RecoveryModule.MODULE_ID, recovery); + regionManager.markDirty(region); + } + } + private void applyHydrologyExpansion() { Collection active = regionManager.getActiveRegions(); if (active.isEmpty() || worldEffectsModule == null) return; @@ -805,7 +892,10 @@ public final class LivingWorldBootstrap { lowestNeighbour = neighbour; } } - if (atmosphere.getRainLevel() > 0.80 && lowSuccession && maxSlope > 10.0 + double floodRainThreshold = deforestedRegions.contains(coord) ? 0.60 : 0.80; + double floodSlopeThreshold = deforestedRegions.contains(coord) ? 5.0 : 10.0; + if (atmosphere.getRainLevel() > floodRainThreshold + && lowSuccession && maxSlope > floodSlopeThreshold && windRandom.nextDouble() < 0.02 && !isClimateEventActive(coord, ClimateEventType.FLASH_FLOOD)) { ClimateEvent flood = new ClimateEvent( diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java index 21630af..9ef3d1e 100644 --- a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java +++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java @@ -205,4 +205,10 @@ public enum WorldEffectType { /** Warming frozen subsoil collapses into wet mud and meltwater. */ PERMAFROST_THAWS, + + /** Deforested runoff replaces river sand with coarse gravel silt. */ + RIVER_SILTS, + + /** Exhausted farmland fails into coarse dirt and loses its crop. */ + CROPLAND_EXHAUSTS, } diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index 3639489..5908341 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -172,6 +172,10 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { breakSoilCrust(level, baseX, baseZ, request.intensity()); case PERMAFROST_THAWS -> thawPermafrost(level, baseX, baseZ, request.intensity()); + case RIVER_SILTS -> + siltRiver(level, baseX, baseZ, request.intensity()); + case CROPLAND_EXHAUSTS -> + exhaustCropland(level, baseX, baseZ, request.intensity()); } } @@ -1274,6 +1278,35 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { } } + private void siltRiver(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++) { + 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; + BlockPos water = new BlockPos(x, y, z); + BlockPos bed = water.below(); + if (level.isLoaded(water) && level.getFluidState(water).is(FluidTags.WATER) + && level.getBlockState(bed).is(Blocks.SAND)) { + level.setBlock(bed, Blocks.GRAVEL.defaultBlockState(), Block.UPDATE_ALL); + } + } + } + + private void exhaustCropland(ServerLevel level, int baseX, int baseZ, double intensity) { + if (random.nextDouble() > 0.25) 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 || !level.getBlockState(pos).is(Blocks.FARMLAND)) continue; + BlockPos crop = pos.above(); + if (level.isLoaded(crop) && level.getBlockState(crop).is(BlockTags.CROPS)) { + level.setBlock(crop, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + level.setBlock(pos, Blocks.COARSE_DIRT.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()) {