diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 55d6463..9ebba7f 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -10,9 +10,11 @@ import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.minecraft.world.level.storage.LevelResource; import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity; import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.CampfireBlockEntity; import net.minecraft.world.level.block.state.properties.BlockStateProperties; import net.minecraft.world.level.chunk.LevelChunk; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; import net.minecraft.core.registries.Registries; @@ -24,9 +26,14 @@ import com.livingworld.debug.LivingWorldLogger; import com.livingworld.platform.neoforge.NeoForgePlatformAdapter; import com.livingworld.platform.neoforge.NeoForgeWorldEffectExecutor; import com.livingworld.regions.Region; +import com.livingworld.regions.RegionCoordinate; import net.minecraft.server.MinecraftServer; import net.neoforged.neoforge.event.tick.ServerTickEvent; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + /** * Mod entrypoint for Living World. *

@@ -38,14 +45,20 @@ public class LivingWorldMod { public static final String MOD_ID = LivingWorldConstants.MOD_ID; - private static final int FURNACE_SCAN_INTERVAL = 100; - private static final double AIR_POLLUTION_PER_FURNACE = 0.5; - private static final double GROUND_POLLUTION_PER_FURNACE = 0.1; - private static final double WATER_POLLUTION_PER_FURNACE = 0.1; + private static final int FURNACE_SCAN_INTERVAL = 50; + private static final double AIR_POLLUTION_PER_FURNACE = 0.5; + private static final double GROUND_POLLUTION_PER_FURNACE = 0.1; + private static final double WATER_POLLUTION_PER_FURNACE = 0.1; + private static final double AIR_POLLUTION_PER_CAMPFIRE = 0.2; + private static final double GROUND_POLLUTION_PER_CAMPFIRE = 0.05; + + private static final int PLAYER_CHECK_INTERVAL = 20; private final LivingWorldBootstrap bootstrap; private MinecraftServer minecraftServer; private int furnaceScanTick = 0; + private int playerCheckTick = 0; + private final Map playerRegionCache = new HashMap<>(); public LivingWorldMod(IEventBus eventBus) { LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting..."); @@ -74,13 +87,18 @@ public class LivingWorldMod { NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> { bootstrap.onServerStopping(); bootstrap.setOverworldRaining(null); + playerRegionCache.clear(); this.minecraftServer = null; }); NeoForge.EVENT_BUS.addListener(ServerTickEvent.Post.class, event -> { if (minecraftServer == null || !bootstrap.isServerReady()) return; - if (++furnaceScanTick % FURNACE_SCAN_INTERVAL != 0) return; - scanAndRecordFurnaceActivity(); + if (++furnaceScanTick % FURNACE_SCAN_INTERVAL == 0) { + scanAndRecordFurnaceActivity(); + } + if (++playerCheckTick % PLAYER_CHECK_INTERVAL == 0) { + checkPlayerRegions(); + } }); LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully."); @@ -92,33 +110,49 @@ public class LivingWorldMod { ServerLevel level = minecraftServer.getLevel( ResourceKey.create(Registries.DIMENSION, ResourceLocation.parse(dimensionId))); if (level == null) continue; - int litCount = countLitFurnaces(level, region); - if (litCount > 0) { - bootstrap.handleFurnaceActivity(region, - litCount * AIR_POLLUTION_PER_FURNACE, - litCount * GROUND_POLLUTION_PER_FURNACE, - litCount * WATER_POLLUTION_PER_FURNACE); + + int baseChunkX = region.getCoordinate().x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; + int baseChunkZ = region.getCoordinate().z() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; + + int furnaces = 0; + int campfires = 0; + for (int cx = baseChunkX; cx < baseChunkX + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; cx++) { + for (int cz = baseChunkZ; cz < baseChunkZ + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; cz++) { + LevelChunk chunk = level.getChunkSource().getChunkNow(cx, cz); + if (chunk == null) continue; + for (BlockEntity be : chunk.getBlockEntities().values()) { + if (be instanceof AbstractFurnaceBlockEntity + && be.getBlockState().hasProperty(BlockStateProperties.LIT) + && Boolean.TRUE.equals(be.getBlockState().getValue(BlockStateProperties.LIT))) { + furnaces++; + } else if (be instanceof CampfireBlockEntity + && be.getBlockState().hasProperty(BlockStateProperties.LIT) + && Boolean.TRUE.equals(be.getBlockState().getValue(BlockStateProperties.LIT))) { + campfires++; + } + } + } + } + + double air = furnaces * AIR_POLLUTION_PER_FURNACE + campfires * AIR_POLLUTION_PER_CAMPFIRE; + double ground = furnaces * GROUND_POLLUTION_PER_FURNACE + campfires * GROUND_POLLUTION_PER_CAMPFIRE; + double water = furnaces * WATER_POLLUTION_PER_FURNACE; + if (air > 0 || ground > 0) { + bootstrap.handleFurnaceActivity(region, air, ground, water); } } } - private int countLitFurnaces(ServerLevel level, Region region) { - int count = 0; - int baseChunkX = region.getCoordinate().x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; - int baseChunkZ = region.getCoordinate().z() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; - for (int cx = baseChunkX; cx < baseChunkX + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; cx++) { - for (int cz = baseChunkZ; cz < baseChunkZ + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; cz++) { - LevelChunk chunk = level.getChunkSource().getChunkNow(cx, cz); - if (chunk == null) continue; - for (BlockEntity be : chunk.getBlockEntities().values()) { - if (!(be instanceof AbstractFurnaceBlockEntity)) continue; - if (be.getBlockState().hasProperty(BlockStateProperties.LIT) - && Boolean.TRUE.equals(be.getBlockState().getValue(BlockStateProperties.LIT))) { - count++; - } - } + private void checkPlayerRegions() { + for (ServerPlayer player : minecraftServer.getPlayerList().getPlayers()) { + 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); + RegionCoordinate previous = playerRegionCache.put(player.getUUID(), coord); + if (!coord.equals(previous)) { + bootstrap.notifyPlayerInRegion(coord); } } - return count; } } diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index 309d779..017393c 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -47,7 +47,9 @@ import com.livingworld.regions.query.RegionQueryEngine; import com.mojang.brigadier.CommandDispatcher; import java.nio.file.Path; import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import net.minecraft.commands.CommandSourceStack; /** @@ -219,6 +221,12 @@ public final class LivingWorldBootstrap { regionManager.markDirty(region); } + public void notifyPlayerInRegion(RegionCoordinate coordinate) { + if (!serverReady || coordinate == null) return; + regionManager.getOrCreateRegion(coordinate); + simulationManager.queueRegionForUpdate(coordinate, 10, com.livingworld.core.simulation.UpdateReason.PLAYER_NEARBY); + } + public void setOverworldRaining(BooleanSupplier supplier) { this.overworldRaining = supplier != null ? supplier : () -> false; } @@ -231,10 +239,49 @@ public final class LivingWorldBootstrap { simulationManager.onMinecraftServerTick(); if (simulationManager.getSimulationTickCounter() != previousSimulationTick) { applyWeatherFeedback(); + spreadPollutionAcrossRegions(); regionManager.saveDirtyRegions(); } } + private static final double POLLUTION_SPREAD_RATE = 0.02; + + private void spreadPollutionAcrossRegions() { + 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(); + PollutionRegionData data = region.getModuleData() + .get(PollutionModule.MODULE_ID, PollutionRegionData.class) + .orElse(null); + if (data == null) continue; + for (int[] off : offsets) { + RegionCoordinate neighbourCoord = new RegionCoordinate( + coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]); + Region neighbour = byCoord.get(neighbourCoord); + if (neighbour == null) continue; + PollutionRegionData neighbourData = neighbour.getModuleData() + .get(PollutionModule.MODULE_ID, PollutionRegionData.class) + .orElse(null); + if (neighbourData == null) continue; + double diff = data.getAirPollution() - neighbourData.getAirPollution(); + if (diff <= 0) continue; + double transfer = diff * POLLUTION_SPREAD_RATE; + data.addPollution(-transfer, 0, 0); + neighbourData.addPollution(transfer, 0, 0); + neighbour.getModuleData().put(PollutionModule.MODULE_ID, neighbourData); + regionManager.markDirty(neighbour); + } + region.getModuleData().put(PollutionModule.MODULE_ID, data); + regionManager.markDirty(region); + } + } + private void applyWeatherFeedback() { boolean raining = overworldRaining.getAsBoolean(); for (Region region : regionManager.getActiveRegions()) { diff --git a/src/main/java/com/livingworld/config/SimulationConfig.java b/src/main/java/com/livingworld/config/SimulationConfig.java index 0329465..e89edbc 100644 --- a/src/main/java/com/livingworld/config/SimulationConfig.java +++ b/src/main/java/com/livingworld/config/SimulationConfig.java @@ -18,7 +18,7 @@ public final class SimulationConfig { private int regionSizeChunks = 8; /** Interval between simulation cycles, in game ticks (must be >= 1). */ - private int simulationIntervalTicks = 100; + private int simulationIntervalTicks = 50; /** Maximum number of regions processed per cycle (must be >= 1). */ private int maxRegionsPerCycle = 50; diff --git a/src/main/java/com/livingworld/core/simulation/SimulationManager.java b/src/main/java/com/livingworld/core/simulation/SimulationManager.java index bbb9d09..8571601 100644 --- a/src/main/java/com/livingworld/core/simulation/SimulationManager.java +++ b/src/main/java/com/livingworld/core/simulation/SimulationManager.java @@ -227,6 +227,14 @@ public final class SimulationManager { } } + /** Enqueues a region for a priority update, e.g. because a player entered it. */ + public void queueRegionForUpdate(RegionCoordinate coordinate, int priority, UpdateReason reason) { + if (coordinate == null) throw new IllegalArgumentException("coordinate must not be null"); + if (reason == null) throw new IllegalArgumentException("reason must not be null"); + long tick = getSimulationTickCounter(); + this.scheduler.queueRegion(new RegionUpdateJob(coordinate, priority, tick, null, reason)); + } + /** Returns a profile snapshot of the last completed simulation cycle. */ public SimulationProfileSnapshot createProfileSnapshot() { if (profiler instanceof com.livingworld.debug.SimulationProfiler concrete) { diff --git a/src/main/java/com/livingworld/modules/pollution/PollutionModule.java b/src/main/java/com/livingworld/modules/pollution/PollutionModule.java index 7b7fddb..c8a6f8f 100644 --- a/src/main/java/com/livingworld/modules/pollution/PollutionModule.java +++ b/src/main/java/com/livingworld/modules/pollution/PollutionModule.java @@ -42,7 +42,7 @@ public final class PollutionModule implements SimulationModule { public static final String MODULE_ID = "pollution"; - private static final double BASE_DECAY_RATE = 0.02; + private static final double BASE_DECAY_RATE = 0.008; private static final double GROUND_TO_WATER_LEACH = 0.005; private static final double WATER_QUALITY_IMPACT = 0.05; private static final double CHANGE_THRESHOLD = 0.01; diff --git a/src/main/java/com/livingworld/modules/soil/SoilModule.java b/src/main/java/com/livingworld/modules/soil/SoilModule.java index 2273e94..110a7b5 100644 --- a/src/main/java/com/livingworld/modules/soil/SoilModule.java +++ b/src/main/java/com/livingworld/modules/soil/SoilModule.java @@ -33,11 +33,11 @@ public final class SoilModule implements SimulationModule { public static final String MODULE_ID = "soil"; /** Pollution score above which contamination begins accumulating per tick. */ - private static final double POLLUTION_CONTAMINATION_THRESHOLD = 30.0; + private static final double POLLUTION_CONTAMINATION_THRESHOLD = 10.0; /** Fraction of excess pollution score that becomes contamination per tick. */ private static final double POLLUTION_TO_CONTAMINATION_RATE = 0.003; /** Fertility reduction per unit of contamination per tick. */ - private static final double CONTAMINATION_FERTILITY_DRAIN = 0.002; + private static final double CONTAMINATION_FERTILITY_DRAIN = 0.005; /** Vegetation pressure threshold for fertility recovery to kick in. */ private static final double VEGETATION_RECOVERY_THRESHOLD = 40.0; /** Fertility gained per unit of excess vegetation pressure per tick. */ diff --git a/src/main/java/com/livingworld/modules/vegetation/VegetationModule.java b/src/main/java/com/livingworld/modules/vegetation/VegetationModule.java index 75fbfa0..c978aea 100644 --- a/src/main/java/com/livingworld/modules/vegetation/VegetationModule.java +++ b/src/main/java/com/livingworld/modules/vegetation/VegetationModule.java @@ -52,7 +52,7 @@ public final class VegetationModule implements SimulationModule { // --- die-off thresholds --- private static final double DIEOFF_SOIL_THRESHOLD = 20.0; - private static final double DIEOFF_POLLUTION_THRESHOLD = 60.0; + private static final double DIEOFF_POLLUTION_THRESHOLD = 30.0; private static final double GRASS_DIEOFF_RATE = 0.30; private static final double DEAD_ACCUMULATION_RATE = 0.20; diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java index 1f729c8..a361de2 100644 --- a/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java +++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgeWorldEffectExecutor.java @@ -30,7 +30,7 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { private static final int REGION_BLOCKS = LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16; - private static final int BLOCK_ATTEMPTS = 8; + private static final int BLOCK_ATTEMPTS = 20; private static final int MIN_GRASS_LIGHT = 9; private final Supplier serverSupplier; @@ -63,9 +63,9 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { spreadVegetation(level, baseX, baseZ, request.intensity()); case POLLUTION_VISUAL_INDICATOR -> spawnPollutionParticles(level, baseX, baseZ, request.intensity()); - case SAPLING_GROWTH_SLOWED, SAPLING_GROWTH_BOOSTED -> { - // Sapling growth manipulation requires mixin hooks; deferred. - } + case SAPLING_GROWTH_BOOSTED -> + placeSaplings(level, baseX, baseZ, request.intensity()); + case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred } } @@ -74,10 +74,24 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { for (int i = 0; i < attempts; i++) { BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS), baseZ + random.nextInt(REGION_BLOCKS)); - if (pos != null && level.getBlockState(pos).is(Blocks.GRASS_BLOCK)) { + if (pos == null) continue; + BlockPos above = pos.above(); + // Clear plants sitting on top of the block first + if (level.isLoaded(above)) { + var aboveState = level.getBlockState(above); + if (aboveState.is(Blocks.SHORT_GRASS) || aboveState.is(Blocks.TALL_GRASS) + || aboveState.is(Blocks.DANDELION) || aboveState.is(Blocks.POPPY) + || aboveState.is(Blocks.OAK_SAPLING)) { + level.setBlock(above, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL); + } + } + var state = level.getBlockState(pos); + if (state.is(Blocks.GRASS_BLOCK)) { level.setBlock(pos, Blocks.DIRT.defaultBlockState(), Block.UPDATE_ALL); LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "WorldEffect GRASS_DEGRADES_TO_DIRT at " + pos); + } else if (state.is(Blocks.DIRT) && intensity > 0.5) { + level.setBlock(pos, Blocks.COARSE_DIRT.defaultBlockState(), Block.UPDATE_ALL); } } } @@ -87,22 +101,45 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer { for (int i = 0; i < attempts; i++) { BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS), baseZ + random.nextInt(REGION_BLOCKS)); - if (pos == null || !level.getBlockState(pos).is(Blocks.DIRT)) { - continue; - } + if (pos == null) continue; BlockPos above = pos.above(); - if (level.getBlockState(above).isAir() - && level.getRawBrightness(above, 0) >= MIN_GRASS_LIGHT) { + boolean aboveAir = level.isLoaded(above) && level.getBlockState(above).isAir(); + boolean brightEnough = aboveAir && level.getRawBrightness(above, 0) >= MIN_GRASS_LIGHT; + + var state = level.getBlockState(pos); + if (state.is(Blocks.DIRT) && brightEnough) { level.setBlock(pos, Blocks.GRASS_BLOCK.defaultBlockState(), Block.UPDATE_ALL); LivingWorldLogger.info(DiagnosticCategory.SIMULATION, "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 + ? (random.nextBoolean() ? Blocks.DANDELION : Blocks.POPPY) + : Blocks.SHORT_GRASS; + level.setBlock(above, plant.defaultBlockState(), Block.UPDATE_ALL); } } } + private void placeSaplings(ServerLevel level, int baseX, int baseZ, double intensity) { + 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) && !state.is(Blocks.DIRT)) continue; + BlockPos above = pos.above(); + if (!level.isLoaded(above) || !level.getBlockState(above).isAir()) continue; + level.setBlock(above, Blocks.OAK_SAPLING.defaultBlockState(), Block.UPDATE_ALL); + LivingWorldLogger.info(DiagnosticCategory.SIMULATION, + "WorldEffect SAPLING_GROWTH_BOOSTED at " + pos); + } + } + private void spawnPollutionParticles( ServerLevel level, int baseX, int baseZ, double intensity) { - int count = Math.max(1, (int) (intensity * 5)); + int count = Math.max(1, (int) (intensity * 8)); for (int i = 0; i < count; i++) { double x = baseX + random.nextDouble() * REGION_BLOCKS; double z = baseZ + random.nextDouble() * REGION_BLOCKS; diff --git a/src/test/java/com/livingworld/bootstrap/LivingWorldBootstrapTest.java b/src/test/java/com/livingworld/bootstrap/LivingWorldBootstrapTest.java index 5ecb7b7..1659aed 100644 --- a/src/test/java/com/livingworld/bootstrap/LivingWorldBootstrapTest.java +++ b/src/test/java/com/livingworld/bootstrap/LivingWorldBootstrapTest.java @@ -41,7 +41,7 @@ class LivingWorldBootstrapTest { assertTrue(bootstrap.getServices().isRegistered(CoreServices.TIME)); assertTrue(bootstrap.getServices().isRegistered(CoreServices.DEBUG)); - for (int tick = 0; tick < 100; tick++) { + for (int tick = 0; tick < 50; tick++) { bootstrap.onServerTick(); } TimeService timeService = bootstrap.getServices().get(CoreServices.TIME); diff --git a/src/test/java/com/livingworld/config/SimulationConfigTest.java b/src/test/java/com/livingworld/config/SimulationConfigTest.java index 9d76f70..e59a318 100644 --- a/src/test/java/com/livingworld/config/SimulationConfigTest.java +++ b/src/test/java/com/livingworld/config/SimulationConfigTest.java @@ -31,7 +31,7 @@ class SimulationConfigTest { @Test void defaultSimulationIntervalTicks() { final SimulationConfig config = new SimulationConfig(); - assertEquals(100, config.getSimulationIntervalTicks()); + assertEquals(50, config.getSimulationIntervalTicks()); } @Test @@ -276,6 +276,6 @@ class SimulationConfigTest { final String result = config.toString(); assertTrue(result.contains("SimulationConfig")); assertTrue(result.contains("regionSizeChunks=8")); - assertTrue(result.contains("simulationIntervalTicks=100")); + assertTrue(result.contains("simulationIntervalTicks=50")); } } \ No newline at end of file