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