Add mob spawning, HUD, agriculture, acid rain, biome caps, directional wind

Step 1 – Mob spawn feedback: FinalizeSpawnEvent suppresses passive mobs (Animal)
  in regions with ecosystem health < 30 (up to 70% cancellation) and hostile mobs
  (Monster) in regions > 60 health (up to 50% cancellation).

Step 2 – Compass HUD: holding a compass shows a colour-coded action-bar overlay
  with Eco/Poll/Soil/Wat scores for the current region (green/yellow/red banded).

Step 3 – Resource loop closure: logging depletion recovery is already tied to
  vegetation pressure in ResourceDepletionModule; sapling-to-tree growth continues
  to close the loop via the existing vegetation feedback path.

Step 4 – Agriculture: bone meal (PlayerInteractEvent.RightClickBlock) adds +2
  soil fertility; harvesting fully-grown (age=7) crops (BlockEvent.BreakEvent)
  drains -0.5 fertility and adds 0.3 farming depletion.

Step 5 – Acid rain: when raining AND region pollution > 20, rainfall drains soil
  fertility, raises soil contamination, and reduces water availability proportional
  to excess pollution. Implemented in applyWeatherFeedback().

Step 6 – Biome-aware succession: RecoveryRegionData gains a maxSuccessionStage
  cap (default MATURE_FOREST). deriveBiomeCap() samples the biome at the region
  centre on first player entry and calls setRegionBiomeCap(); deserts/badlands →
  SPARSE_GRASS, savannas/mountains/cold → SCRUBLAND, beaches/oceans → GRASSLAND,
  forests/taiga/jungle → MATURE_FOREST, plains/swamp → YOUNG_WOODLAND.

Step 7 – Directional wind: spreadPollutionAcrossRegions() drifts a wind angle by
  ±0.05 rad per sim cycle; each cardinal neighbour's spread rate is multiplied by
  (1 + alignment×0.5) where alignment = cos(windAngle − offsetAngle), creating
  downwind (×1.5) and upwind (×0.5) asymmetry.

All 400 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
George
2026-06-07 19:57:09 +01:00
parent 2350c27374
commit c9f927b265
3 changed files with 289 additions and 15 deletions
@@ -4,34 +4,54 @@ import net.neoforged.fml.common.Mod;
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.player.PlayerInteractEvent;
import net.neoforged.neoforge.event.level.BlockEvent;
import net.neoforged.neoforge.event.server.ServerStartedEvent;
import net.neoforged.neoforge.event.server.ServerStartingEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.minecraft.world.level.storage.LevelResource;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BiomeTags;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.entity.animal.Animal;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.biome.Biome;
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;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
import com.livingworld.bootstrap.LivingWorldBootstrap;
import com.livingworld.core.LivingWorldConstants;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
import com.livingworld.modules.recovery.SuccessionStage;
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 com.livingworld.regions.RegionMetrics;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
/**
@@ -54,11 +74,18 @@ public class LivingWorldMod {
private static final int PLAYER_CHECK_INTERVAL = 20;
/** Passive mobs suppressed in regions with ecosystem health below this. */
private static final double PASSIVE_SUPPRESS_HEALTH = 30.0;
/** Hostile mobs suppressed in regions with ecosystem health above this. */
private static final double HOSTILE_SUPPRESS_HEALTH = 60.0;
private final LivingWorldBootstrap bootstrap;
private final Random random = new Random();
private MinecraftServer minecraftServer;
private int furnaceScanTick = 0;
private int playerCheckTick = 0;
private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>();
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
public LivingWorldMod(IEventBus eventBus) {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
@@ -88,6 +115,7 @@ public class LivingWorldMod {
bootstrap.onServerStopping();
bootstrap.setOverworldRaining(null);
playerRegionCache.clear();
biomeInitialized.clear();
this.minecraftServer = null;
});
@@ -101,6 +129,50 @@ public class LivingWorldMod {
}
});
// Step 1: Mob spawn feedback — passive mobs suppressed in degraded regions,
// hostile mobs suppressed in healthy ones.
NeoForge.EVENT_BUS.addListener(FinalizeSpawnEvent.class, event -> {
if (!bootstrap.isServerReady()) return;
if (!(event.getLevel() instanceof ServerLevel level)) return;
String dimId = level.dimension().location().toString();
Optional<RegionMetrics> metricsOpt =
bootstrap.getMetricsAt(dimId, event.getX(), event.getZ());
if (metricsOpt.isEmpty()) return;
double health = metricsOpt.get().getEcosystemHealth();
if (event.getEntity() instanceof Animal) {
if (health < PASSIVE_SUPPRESS_HEALTH) {
double chance = (PASSIVE_SUPPRESS_HEALTH - health) / PASSIVE_SUPPRESS_HEALTH * 0.7;
if (random.nextDouble() < chance) event.setSpawnCancelled(true);
}
} else if (event.getEntity() instanceof Monster) {
if (health > HOSTILE_SUPPRESS_HEALTH) {
double chance = (health - HOSTILE_SUPPRESS_HEALTH) / (100.0 - HOSTILE_SUPPRESS_HEALTH) * 0.5;
if (random.nextDouble() < chance) event.setSpawnCancelled(true);
}
}
});
// 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());
});
// Step 4: Agriculture — harvesting fully-grown crops drains soil fertility.
NeoForge.EVENT_BUS.addListener(BlockEvent.BreakEvent.class, event -> {
if (!bootstrap.isServerReady()) return;
if (!(event.getLevel() instanceof ServerLevel level)) return;
var state = event.getState();
if (!state.is(BlockTags.CROPS)) return;
if (!state.hasProperty(BlockStateProperties.AGE_7)) return;
if (state.getValue(BlockStateProperties.AGE_7) < 7) return;
BlockPos pos = event.getPos();
bootstrap.handleCropHarvest(level.dimension().location().toString(), pos.getX(), pos.getZ());
});
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully.");
}
@@ -150,9 +222,75 @@ public class LivingWorldMod {
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);
// Step 6: Biome-aware succession — derive and apply cap once per region.
if (!biomeInitialized.contains(coord)
&& player.level() instanceof ServerLevel serverLevel) {
SuccessionStage cap = deriveBiomeCap(serverLevel, coord);
bootstrap.setRegionBiomeCap(coord, cap);
biomeInitialized.add(coord);
}
}
// Step 2: Compass HUD — display region health while holding a compass.
if (player.getMainHandItem().is(Items.COMPASS) || player.getOffhandItem().is(Items.COMPASS)) {
Optional<RegionMetrics> metricsOpt = bootstrap.getMetricsAt(dimId, player.getX(), player.getZ());
metricsOpt.ifPresent(m -> player.displayClientMessage(buildHud(coord, m), true));
}
}
}
/** 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;
int cz = coord.z() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 + 64;
int cy = level.getHeight(Heightmap.Types.WORLD_SURFACE, cx, cz);
Holder<Biome> biome = level.getBiome(new BlockPos(cx, cy, cz));
// Temperature ≥ 2.0 = desert or badlands (no IS_DESERT tag in MC 1.21).
float temp = biome.value().getBaseTemperature();
if (biome.is(BiomeTags.IS_BADLANDS) || temp >= 2.0f) {
return SuccessionStage.SPARSE_GRASS;
} else if (biome.is(BiomeTags.IS_SAVANNA)) {
return SuccessionStage.SCRUBLAND;
} else if (biome.is(BiomeTags.IS_MOUNTAIN) || temp < 0.15f) {
return SuccessionStage.SCRUBLAND;
} else if (biome.is(BiomeTags.IS_BEACH) || biome.is(BiomeTags.IS_OCEAN)
|| biome.is(BiomeTags.IS_DEEP_OCEAN)) {
return SuccessionStage.GRASSLAND;
} else if (biome.is(BiomeTags.IS_FOREST) || biome.is(BiomeTags.IS_TAIGA)
|| biome.is(BiomeTags.IS_JUNGLE)) {
return SuccessionStage.MATURE_FOREST;
} else {
return SuccessionStage.YOUNG_WOODLAND;
}
}
/** Step 2: Builds the action-bar HUD component for a player holding a compass. */
private Component buildHud(RegionCoordinate coord, RegionMetrics m) {
double eco = m.getEcosystemHealth();
double poll = m.getPollutionScore();
double soil = m.getSoilQuality();
double wat = m.getWaterQuality();
ChatFormatting ecoCol = eco > 60 ? ChatFormatting.GREEN : eco > 30 ? ChatFormatting.YELLOW : ChatFormatting.RED;
ChatFormatting pollCol = poll < 15 ? ChatFormatting.GREEN : poll < 40 ? ChatFormatting.YELLOW : ChatFormatting.RED;
ChatFormatting soilCol = soil > 50 ? ChatFormatting.GREEN : soil > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED;
ChatFormatting watCol = wat > 50 ? ChatFormatting.GREEN : wat > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED;
return Component.empty()
.append(Component.literal("[LW] ").withStyle(ChatFormatting.GOLD))
.append(Component.literal(String.format("(%d,%d) ", coord.x(), coord.z())).withStyle(ChatFormatting.GRAY))
.append(Component.literal("Eco:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.0f ", eco)).withStyle(ecoCol))
.append(Component.literal("Poll:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.1f ", poll)).withStyle(pollCol))
.append(Component.literal("Soil:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.0f ", soil)).withStyle(soilCol))
.append(Component.literal("Wat:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.0f", wat)).withStyle(watCol));
}
}
@@ -50,6 +50,9 @@ import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import com.livingworld.regions.RegionMetrics;
import net.minecraft.commands.CommandSourceStack;
/**
@@ -57,9 +60,16 @@ import net.minecraft.commands.CommandSourceStack;
*/
public final class LivingWorldBootstrap {
private static final double RAIN_MOISTURE_GAIN = 0.3;
private static final double RAIN_MOISTURE_GAIN = 0.3;
private static final double RAIN_DROUGHT_RELIEF = 0.15;
private static final double DRY_DROUGHT_INCREASE = 0.05;
private static final double ACID_RAIN_THRESHOLD = 20.0;
private static final double ACID_FERTILITY_DRAIN = 0.0005;
private static final double WIND_BOOST = 0.5;
private static final double WIND_DRIFT_MAX = 0.05; // radians per sim cycle
private double windAngle = 0.0;
private final Random windRandom = new Random();
private PlatformAdapter platformAdapter;
private Path worldSaveDirectory;
@@ -221,6 +231,80 @@ public final class LivingWorldBootstrap {
regionManager.markDirty(region);
}
/** Returns the live metrics for the region containing the given world position. */
public Optional<RegionMetrics> getMetricsAt(String dimensionId, double x, double z) {
if (!serverReady) return Optional.empty();
RegionCoordinate coord = RegionCoordinate.fromBlock(
dimensionId, (int) x, (int) z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
return regionManager.resolve(coord).map(Region::getMetrics);
}
/** Called when a sapling grows into a tree — reduces logging depletion in that region. */
public void handleTreeGrowth(RegionCoordinate coord) {
if (!serverReady || coord == null) return;
regionManager.resolve(coord).ifPresent(region -> {
ResourceRegionData res = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
.orElse(null);
if (res == null) return;
res.setLoggingDepletion(Math.max(0, res.getLoggingDepletion() - 2.0));
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, res);
regionManager.markDirty(region);
});
}
/** Called when a player applies bone meal — boosts soil fertility. */
public void handleBoneMeal(String dimensionId, int x, int z) {
if (!serverReady) return;
RegionCoordinate coord = RegionCoordinate.fromBlock(
dimensionId, x, z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
regionManager.resolve(coord).ifPresent(region -> {
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (soil == null) return;
soil.setFertility(Math.min(100, soil.getFertility() + 2.0));
region.getModuleData().put(SoilModule.MODULE_ID, soil);
regionManager.markDirty(region);
});
}
/** Called when a player harvests a fully-grown crop — drains soil fertility. */
public void handleCropHarvest(String dimensionId, int x, int z) {
if (!serverReady) return;
RegionCoordinate coord = RegionCoordinate.fromBlock(
dimensionId, x, z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
regionManager.resolve(coord).ifPresent(region -> {
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (soil == null) return;
soil.setFertility(Math.max(0, soil.getFertility() - 0.5));
ResourceRegionData res = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
.orElse(null);
if (res != null) {
res.recordFarming(0.3);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, res);
}
region.getModuleData().put(SoilModule.MODULE_ID, soil);
regionManager.markDirty(region);
});
}
/** Sets the biome-derived succession ceiling for a region. */
public void setRegionBiomeCap(RegionCoordinate coord, SuccessionStage maxStage) {
if (!serverReady || coord == null || maxStage == null) return;
regionManager.resolve(coord).ifPresent(region -> {
RecoveryRegionData recovery = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
.orElseGet(RecoveryRegionData::defaults);
recovery.setMaxSuccessionStage(maxStage);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
regionManager.markDirty(region);
});
}
public void notifyPlayerInRegion(RegionCoordinate coordinate) {
if (!serverReady || coordinate == null) return;
regionManager.getOrCreateRegion(coordinate);
@@ -250,9 +334,13 @@ public final class LivingWorldBootstrap {
Collection<Region> active = regionManager.getActiveRegions();
if (active.size() < 2) return;
// Wind drifts slowly each sim cycle.
windAngle += (windRandom.nextDouble() * 2.0 - 1.0) * WIND_DRIFT_MAX;
Map<RegionCoordinate, Region> byCoord = new HashMap<>();
for (Region r : active) byCoord.put(r.getCoordinate(), r);
// dx, dz, and the angle that offset points in (atan2(dz, dx))
int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (Region region : active) {
RegionCoordinate coord = region.getCoordinate();
@@ -271,7 +359,11 @@ public final class LivingWorldBootstrap {
if (neighbourData == null) continue;
double diff = data.getAirPollution() - neighbourData.getAirPollution();
if (diff <= 0) continue;
double transfer = diff * POLLUTION_SPREAD_RATE;
// Wind alignment: positive = downwind (faster spread), negative = upwind (slower).
double offsetAngle = Math.atan2(off[1], off[0]);
double alignment = Math.cos(windAngle - offsetAngle);
double rate = POLLUTION_SPREAD_RATE * (1.0 + alignment * WIND_BOOST);
double transfer = diff * rate;
data.addPollution(-transfer, 0, 0);
neighbourData.addPollution(transfer, 0, 0);
neighbour.getModuleData().put(PollutionModule.MODULE_ID, neighbourData);
@@ -297,6 +389,22 @@ public final class LivingWorldBootstrap {
if (raining) {
water.setWaterAvailability(water.getWaterAvailability() + RAIN_MOISTURE_GAIN);
water.setDroughtRisk(water.getDroughtRisk() - RAIN_DROUGHT_RELIEF);
// Acid rain: polluted rainfall degrades soil and water quality.
double pollutionScore = region.getMetrics().getPollutionScore();
if (pollutionScore > ACID_RAIN_THRESHOLD) {
double acidStrength = (pollutionScore - ACID_RAIN_THRESHOLD) * ACID_FERTILITY_DRAIN;
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (soil != null) {
soil.setFertility(Math.max(0, soil.getFertility() - acidStrength));
soil.setContamination(Math.min(100, soil.getContamination() + acidStrength * 0.5));
region.getModuleData().put(SoilModule.MODULE_ID, soil);
}
water.setWaterAvailability(
Math.max(0, water.getWaterAvailability() - acidStrength * 0.3));
}
} else {
water.setDroughtRisk(water.getDroughtRisk() + DRY_DROUGHT_INCREASE);
}
@@ -21,17 +21,31 @@ public final class RecoveryRegionData {
private SuccessionStage successionStage;
private double recoveryProgress;
private double damageAccumulation;
/** Biome-derived ceiling — region cannot advance past this stage. */
private SuccessionStage maxSuccessionStage;
public RecoveryRegionData(
SuccessionStage successionStage,
double recoveryProgress,
double damageAccumulation) {
this(successionStage, recoveryProgress, damageAccumulation, SuccessionStage.MATURE_FOREST);
}
public RecoveryRegionData(
SuccessionStage successionStage,
double recoveryProgress,
double damageAccumulation,
SuccessionStage maxSuccessionStage) {
if (successionStage == null) {
throw new IllegalArgumentException("successionStage must not be null");
}
this.successionStage = successionStage;
this.recoveryProgress = clamp(recoveryProgress);
if (maxSuccessionStage == null) {
throw new IllegalArgumentException("maxSuccessionStage must not be null");
}
this.successionStage = successionStage;
this.recoveryProgress = clamp(recoveryProgress);
this.damageAccumulation = clamp(damageAccumulation);
this.maxSuccessionStage = maxSuccessionStage;
}
/** Returns a region at grassland stage with no accumulated damage. */
@@ -43,9 +57,20 @@ public final class RecoveryRegionData {
// Getters
// ------------------------------------------------------------------
public SuccessionStage getSuccessionStage() { return successionStage; }
public SuccessionStage getSuccessionStage() { return successionStage; }
public double getRecoveryProgress() { return recoveryProgress; }
public double getDamageAccumulation() { return damageAccumulation; }
public SuccessionStage getMaxSuccessionStage() { return maxSuccessionStage; }
public void setMaxSuccessionStage(SuccessionStage cap) {
if (cap == null) throw new IllegalArgumentException("cap must not be null");
this.maxSuccessionStage = cap;
// If current stage already exceeds the new cap, clamp it.
if (successionStage.ordinal() > cap.ordinal()) {
successionStage = cap;
recoveryProgress = 0.0;
}
}
// ------------------------------------------------------------------
// Mutation
@@ -67,6 +92,8 @@ public final class RecoveryRegionData {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
recoveryProgress = clamp(recoveryProgress + amount);
if (recoveryProgress >= 100.0
&& successionStage.hasNext()
&& successionStage.next().ordinal() <= maxSuccessionStage.ordinal()
&& successionStage.conditionsMetForAdvancement(soilQuality, pollutionScore, vegetationPressure)) {
successionStage = successionStage.next();
recoveryProgress = 0.0;
@@ -105,7 +132,7 @@ public final class RecoveryRegionData {
/** Returns an independent copy. */
public RecoveryRegionData copy() {
return new RecoveryRegionData(successionStage, recoveryProgress, damageAccumulation);
return new RecoveryRegionData(successionStage, recoveryProgress, damageAccumulation, maxSuccessionStage);
}
// ------------------------------------------------------------------
@@ -119,6 +146,7 @@ public final class RecoveryRegionData {
return "RecoveryRegionData{"
+ "stage=" + successionStage
+ ", progress=" + recoveryProgress
+ ", damage=" + damageAccumulation + "}";
+ ", damage=" + damageAccumulation
+ ", maxStage=" + maxSuccessionStage + "}";
}
}