Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67a1e07b82 | |||
| c9f927b265 | |||
| 2350c27374 | |||
| 773fb0223f | |||
| 577c14b6ea | |||
| dfa84a9347 | |||
| 881f716115 | |||
| 6e6de00f0d | |||
| 4fd9bb97aa | |||
| 6427677db5 |
@@ -4,16 +4,55 @@ import net.neoforged.fml.common.Mod;
|
|||||||
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
|
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
|
||||||
import net.neoforged.bus.api.IEventBus;
|
import net.neoforged.bus.api.IEventBus;
|
||||||
import net.neoforged.neoforge.common.NeoForge;
|
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.ServerStartedEvent;
|
||||||
import net.neoforged.neoforge.event.server.ServerStartingEvent;
|
import net.neoforged.neoforge.event.server.ServerStartingEvent;
|
||||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||||
|
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.world.level.levelgen.Heightmap;
|
||||||
import net.minecraft.world.level.storage.LevelResource;
|
import net.minecraft.world.level.storage.LevelResource;
|
||||||
|
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||||
|
|
||||||
import com.livingworld.bootstrap.LivingWorldBootstrap;
|
import com.livingworld.bootstrap.LivingWorldBootstrap;
|
||||||
import com.livingworld.core.LivingWorldConstants;
|
import com.livingworld.core.LivingWorldConstants;
|
||||||
import com.livingworld.debug.DiagnosticCategory;
|
import com.livingworld.debug.DiagnosticCategory;
|
||||||
import com.livingworld.debug.LivingWorldLogger;
|
import com.livingworld.debug.LivingWorldLogger;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
import com.livingworld.platform.neoforge.NeoForgePlatformAdapter;
|
import com.livingworld.platform.neoforge.NeoForgePlatformAdapter;
|
||||||
|
import com.livingworld.platform.neoforge.NeoForgeWorldEffectExecutor;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
|
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mod entrypoint for Living World.
|
* Mod entrypoint for Living World.
|
||||||
@@ -25,7 +64,28 @@ import com.livingworld.platform.neoforge.NeoForgePlatformAdapter;
|
|||||||
public class LivingWorldMod {
|
public class LivingWorldMod {
|
||||||
|
|
||||||
public static final String MOD_ID = LivingWorldConstants.MOD_ID;
|
public static final String MOD_ID = LivingWorldConstants.MOD_ID;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** 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 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) {
|
public LivingWorldMod(IEventBus eventBus) {
|
||||||
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
|
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
|
||||||
@@ -34,7 +94,8 @@ public class LivingWorldMod {
|
|||||||
NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter(
|
NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter(
|
||||||
bootstrap::getWorldSaveDirectory,
|
bootstrap::getWorldSaveDirectory,
|
||||||
bootstrap::registerCommands,
|
bootstrap::registerCommands,
|
||||||
bootstrap::onServerTick);
|
bootstrap::onServerTick,
|
||||||
|
bootstrap::handleBlockBreak);
|
||||||
this.bootstrap.initialize(platformAdapter);
|
this.bootstrap.initialize(platformAdapter);
|
||||||
|
|
||||||
eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup());
|
eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup());
|
||||||
@@ -42,11 +103,197 @@ public class LivingWorldMod {
|
|||||||
ServerStartingEvent.class,
|
ServerStartingEvent.class,
|
||||||
event -> bootstrap.onServerStarting(
|
event -> bootstrap.onServerStarting(
|
||||||
event.getServer().getWorldPath(LevelResource.ROOT)));
|
event.getServer().getWorldPath(LevelResource.ROOT)));
|
||||||
NeoForge.EVENT_BUS.addListener(
|
NeoForge.EVENT_BUS.addListener(ServerStartedEvent.class, event -> {
|
||||||
ServerStartedEvent.class, event -> bootstrap.onServerStarted());
|
this.minecraftServer = event.getServer();
|
||||||
NeoForge.EVENT_BUS.addListener(
|
bootstrap.onServerStarted();
|
||||||
ServerStoppingEvent.class, event -> bootstrap.onServerStopping());
|
bootstrap.getWorldEffectsModule().registerConsumer(
|
||||||
|
new NeoForgeWorldEffectExecutor(() -> minecraftServer));
|
||||||
|
bootstrap.setOverworldRaining(
|
||||||
|
() -> minecraftServer != null && minecraftServer.overworld().isRaining());
|
||||||
|
});
|
||||||
|
NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> {
|
||||||
|
bootstrap.onServerStopping();
|
||||||
|
bootstrap.setOverworldRaining(null);
|
||||||
|
playerRegionCache.clear();
|
||||||
|
biomeInitialized.clear();
|
||||||
|
this.minecraftServer = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
NeoForge.EVENT_BUS.addListener(ServerTickEvent.Post.class, event -> {
|
||||||
|
if (minecraftServer == null || !bootstrap.isServerReady()) return;
|
||||||
|
if (++furnaceScanTick % FURNACE_SCAN_INTERVAL == 0) {
|
||||||
|
scanAndRecordFurnaceActivity();
|
||||||
|
}
|
||||||
|
if (++playerCheckTick % PLAYER_CHECK_INTERVAL == 0) {
|
||||||
|
checkPlayerRegions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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.");
|
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void scanAndRecordFurnaceActivity() {
|
||||||
|
for (Region region : bootstrap.getActiveRegions()) {
|
||||||
|
String dimensionId = region.getCoordinate().dimensionId();
|
||||||
|
ServerLevel level = minecraftServer.getLevel(
|
||||||
|
ResourceKey.create(Registries.DIMENSION, ResourceLocation.parse(dimensionId)));
|
||||||
|
if (level == null) continue;
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
// 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, or debug HUD is on.
|
||||||
|
boolean showHud = player.getMainHandItem().is(Items.COMPASS)
|
||||||
|
|| player.getOffhandItem().is(Items.COMPASS)
|
||||||
|
|| bootstrap.isHudEnabled(player.getUUID());
|
||||||
|
if (showHud) {
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,27 @@ import com.livingworld.events.LivingWorldEventBus;
|
|||||||
import com.livingworld.modules.ModuleContext;
|
import com.livingworld.modules.ModuleContext;
|
||||||
import com.livingworld.modules.ModuleRegistry;
|
import com.livingworld.modules.ModuleRegistry;
|
||||||
import com.livingworld.modules.ServerContext;
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||||
|
import com.livingworld.modules.pollution.PollutionModule;
|
||||||
|
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryModule;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
|
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||||
|
import com.livingworld.modules.resources.ResourceRegionData;
|
||||||
|
import com.livingworld.modules.soil.SoilModule;
|
||||||
|
import com.livingworld.modules.soil.SoilRegionData;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationModule;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||||
|
import com.livingworld.modules.water.WaterModule;
|
||||||
|
import com.livingworld.modules.water.WaterRegionData;
|
||||||
|
import com.livingworld.modules.worldeffects.WorldEffectsModule;
|
||||||
|
import com.livingworld.platform.BlockBreakInfo;
|
||||||
import com.livingworld.platform.PlatformAdapter;
|
import com.livingworld.platform.PlatformAdapter;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
import com.livingworld.regions.RegionFactory;
|
import com.livingworld.regions.RegionFactory;
|
||||||
import com.livingworld.regions.RegionLifecycleController;
|
import com.livingworld.regions.RegionLifecycleController;
|
||||||
import com.livingworld.regions.RegionManager;
|
import com.livingworld.regions.RegionManager;
|
||||||
@@ -26,6 +46,16 @@ import com.livingworld.regions.cache.RegionCache;
|
|||||||
import com.livingworld.regions.query.RegionQueryEngine;
|
import com.livingworld.regions.query.RegionQueryEngine;
|
||||||
import com.mojang.brigadier.CommandDispatcher;
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
import net.minecraft.commands.CommandSourceStack;
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,12 +63,26 @@ import net.minecraft.commands.CommandSourceStack;
|
|||||||
*/
|
*/
|
||||||
public final class LivingWorldBootstrap {
|
public final class LivingWorldBootstrap {
|
||||||
|
|
||||||
|
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 final Set<UUID> hudEnabledPlayers = new HashSet<>();
|
||||||
|
|
||||||
private PlatformAdapter platformAdapter;
|
private PlatformAdapter platformAdapter;
|
||||||
private Path worldSaveDirectory;
|
private Path worldSaveDirectory;
|
||||||
private ServiceRegistry services;
|
private ServiceRegistry services;
|
||||||
private RegionManager regionManager;
|
private RegionManager regionManager;
|
||||||
private ModuleRegistry moduleRegistry;
|
private ModuleRegistry moduleRegistry;
|
||||||
private SimulationManager simulationManager;
|
private SimulationManager simulationManager;
|
||||||
|
private WorldEffectsModule worldEffectsModule;
|
||||||
|
private BooleanSupplier overworldRaining = () -> false;
|
||||||
private boolean initialized;
|
private boolean initialized;
|
||||||
private boolean serverReady;
|
private boolean serverReady;
|
||||||
|
|
||||||
@@ -110,6 +154,46 @@ public final class LivingWorldBootstrap {
|
|||||||
"onServerStarted - server started.");
|
"onServerStarted - server started.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a block-break event forwarded from the platform adapter.
|
||||||
|
*
|
||||||
|
* <p>Determines whether the block represents mining, logging, or farming activity
|
||||||
|
* and records the corresponding depletion on the region's
|
||||||
|
* {@link ResourceRegionData}. Safe to call before the server is ready (returns
|
||||||
|
* immediately in that case).</p>
|
||||||
|
*
|
||||||
|
* @param info platform-neutral description of the broken block
|
||||||
|
*/
|
||||||
|
public void handleBlockBreak(BlockBreakInfo info) {
|
||||||
|
if (!serverReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RegionCoordinate coord = RegionCoordinate.fromBlock(
|
||||||
|
info.dimensionId(),
|
||||||
|
info.blockX(),
|
||||||
|
info.blockZ(),
|
||||||
|
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
|
||||||
|
|
||||||
|
Region region = regionManager.getOrCreateRegion(coord);
|
||||||
|
ResourceRegionData resources = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
|
||||||
|
.orElseGet(ResourceRegionData::defaults);
|
||||||
|
|
||||||
|
String block = info.blockRegistryName();
|
||||||
|
if (isLog(block) || isLeaves(block) || isWood(block)) {
|
||||||
|
resources.recordLogging(1.0);
|
||||||
|
} else if (isOre(block) || isStone(block)) {
|
||||||
|
resources.recordMining(1.0);
|
||||||
|
} else if (isCrop(block) || isFarmland(block)) {
|
||||||
|
resources.recordFarming(1.0);
|
||||||
|
} else {
|
||||||
|
return; // not a tracked resource — don't dirty the region
|
||||||
|
}
|
||||||
|
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||||
|
regionManager.markDirty(region);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the server is stopping.
|
* Called when the server is stopping.
|
||||||
*/
|
*/
|
||||||
@@ -119,12 +203,123 @@ public final class LivingWorldBootstrap {
|
|||||||
}
|
}
|
||||||
regionManager.flushAll();
|
regionManager.flushAll();
|
||||||
moduleRegistry.shutdownAll();
|
moduleRegistry.shutdownAll();
|
||||||
|
hudEnabledPlayers.clear();
|
||||||
serverReady = false;
|
serverReady = false;
|
||||||
LivingWorldLogger.info(
|
LivingWorldLogger.info(
|
||||||
DiagnosticCategory.BOOTSTRAP,
|
DiagnosticCategory.BOOTSTRAP,
|
||||||
"onServerStopping - persistence flushed and modules stopped.");
|
"onServerStopping - persistence flushed and modules stopped.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the supplier that reports whether it is currently raining in the overworld.
|
||||||
|
* Called from the platform layer after the server starts and cleared on stop.
|
||||||
|
*/
|
||||||
|
/** Returns all currently cached (active) regions. Safe to call from the platform layer. */
|
||||||
|
public Collection<Region> getActiveRegions() {
|
||||||
|
if (!serverReady) return List.of();
|
||||||
|
return regionManager.getActiveRegions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records pollution produced by burning activity (e.g. furnaces) in a region.
|
||||||
|
* Called from the platform layer once per simulation interval.
|
||||||
|
*
|
||||||
|
* @param waterAmount acid-rain component — SO₂ from coal dissolves in atmospheric moisture
|
||||||
|
*/
|
||||||
|
public void handleFurnaceActivity(Region region, double airAmount, double groundAmount, double waterAmount) {
|
||||||
|
if (!serverReady || region == null) return;
|
||||||
|
PollutionRegionData data = region.getModuleData()
|
||||||
|
.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
|
||||||
|
.orElseGet(PollutionRegionData::defaults);
|
||||||
|
data.addPollution(airAmount, groundAmount, waterAmount);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, data);
|
||||||
|
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);
|
||||||
|
simulationManager.queueRegionForUpdate(coordinate, 10, com.livingworld.core.simulation.UpdateReason.PLAYER_NEARBY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOverworldRaining(BooleanSupplier supplier) {
|
||||||
|
this.overworldRaining = supplier != null ? supplier : () -> false;
|
||||||
|
}
|
||||||
|
|
||||||
public void onServerTick() {
|
public void onServerTick() {
|
||||||
if (!serverReady) {
|
if (!serverReady) {
|
||||||
return;
|
return;
|
||||||
@@ -132,17 +327,116 @@ public final class LivingWorldBootstrap {
|
|||||||
long previousSimulationTick = simulationManager.getSimulationTickCounter();
|
long previousSimulationTick = simulationManager.getSimulationTickCounter();
|
||||||
simulationManager.onMinecraftServerTick();
|
simulationManager.onMinecraftServerTick();
|
||||||
if (simulationManager.getSimulationTickCounter() != previousSimulationTick) {
|
if (simulationManager.getSimulationTickCounter() != previousSimulationTick) {
|
||||||
|
applyWeatherFeedback();
|
||||||
|
spreadPollutionAcrossRegions();
|
||||||
regionManager.saveDirtyRegions();
|
regionManager.saveDirtyRegions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final double POLLUTION_SPREAD_RATE = 0.02;
|
||||||
|
|
||||||
|
private void spreadPollutionAcrossRegions() {
|
||||||
|
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();
|
||||||
|
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;
|
||||||
|
// 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);
|
||||||
|
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()) {
|
||||||
|
if (!"minecraft:overworld".equals(region.getCoordinate().dimensionId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
WaterRegionData water = region.getModuleData()
|
||||||
|
.get(WaterModule.MODULE_ID, WaterRegionData.class)
|
||||||
|
.orElse(null);
|
||||||
|
if (water == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
region.getModuleData().put(WaterModule.MODULE_ID, water);
|
||||||
|
regionManager.markDirty(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles the debug HUD for the given player. Returns true if HUD is now enabled. */
|
||||||
|
public boolean toggleHud(UUID playerId) {
|
||||||
|
if (hudEnabledPlayers.remove(playerId)) return false;
|
||||||
|
hudEnabledPlayers.add(playerId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHudEnabled(UUID playerId) {
|
||||||
|
return hudEnabledPlayers.contains(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
public void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher) {
|
public void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher) {
|
||||||
requireInitialized();
|
requireInitialized();
|
||||||
LivingWorldCommandRoot.registerDeferred(
|
LivingWorldCommandRoot.registerDeferred(
|
||||||
dispatcher,
|
dispatcher,
|
||||||
() -> requireService(regionManager, "regionManager"),
|
() -> requireService(regionManager, "regionManager"),
|
||||||
() -> requireService(moduleRegistry, "moduleRegistry"),
|
() -> requireService(moduleRegistry, "moduleRegistry"),
|
||||||
() -> requireService(simulationManager, "simulationManager"));
|
() -> requireService(simulationManager, "simulationManager"),
|
||||||
|
this::toggleHud);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Path getWorldSaveDirectory() {
|
public Path getWorldSaveDirectory() {
|
||||||
@@ -165,6 +459,7 @@ public final class LivingWorldBootstrap {
|
|||||||
new FileRegionPersistenceService(
|
new FileRegionPersistenceService(
|
||||||
saveDirectory.resolve("living_world"),
|
saveDirectory.resolve("living_world"),
|
||||||
LivingWorldConstants.MOD_VERSION);
|
LivingWorldConstants.MOD_VERSION);
|
||||||
|
registerModuleCodecs(persistenceService);
|
||||||
RegionCache regionCache = new RegionCache();
|
RegionCache regionCache = new RegionCache();
|
||||||
RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache);
|
RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache);
|
||||||
regionManager = new RegionManager(
|
regionManager = new RegionManager(
|
||||||
@@ -175,6 +470,7 @@ public final class LivingWorldBootstrap {
|
|||||||
new RegionLifecycleController(),
|
new RegionLifecycleController(),
|
||||||
config);
|
config);
|
||||||
moduleRegistry = new ModuleRegistry();
|
moduleRegistry = new ModuleRegistry();
|
||||||
|
registerEcosystemModules(moduleRegistry);
|
||||||
LivingWorldEventBus eventBus = new LivingWorldEventBus();
|
LivingWorldEventBus eventBus = new LivingWorldEventBus();
|
||||||
DefaultTimeService timeService = new DefaultTimeService();
|
DefaultTimeService timeService = new DefaultTimeService();
|
||||||
SimulationScheduler scheduler = new SimulationScheduler(config);
|
SimulationScheduler scheduler = new SimulationScheduler(config);
|
||||||
@@ -202,6 +498,198 @@ public final class LivingWorldBootstrap {
|
|||||||
moduleRegistry.initializeAll(new ModuleContext(services));
|
moduleRegistry.initializeAll(new ModuleContext(services));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Block categorisation helpers (registry-name based, no Minecraft imports)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static boolean isLog(String block) {
|
||||||
|
return block.contains("_log") || block.contains("_stem");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isWood(String block) {
|
||||||
|
return block.contains("_wood") || block.contains("_hyphae");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isLeaves(String block) {
|
||||||
|
return block.contains("_leaves");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isOre(String block) {
|
||||||
|
return block.contains("_ore");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isStone(String block) {
|
||||||
|
return block.equals("minecraft:stone")
|
||||||
|
|| block.equals("minecraft:deepslate")
|
||||||
|
|| block.equals("minecraft:cobblestone")
|
||||||
|
|| block.equals("minecraft:gravel")
|
||||||
|
|| block.equals("minecraft:sand");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isCrop(String block) {
|
||||||
|
return block.contains("wheat") || block.contains("carrot")
|
||||||
|
|| block.contains("potato") || block.contains("beetroot")
|
||||||
|
|| block.contains("sugar_cane") || block.contains("pumpkin")
|
||||||
|
|| block.contains("melon");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isFarmland(String block) {
|
||||||
|
return block.contains("farmland");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the {@link WorldEffectsModule} so the platform adapter can register a
|
||||||
|
* block-change consumer after bootstrap.
|
||||||
|
*
|
||||||
|
* <p>Call only after {@link #onServerStarting(Path)} has returned.</p>
|
||||||
|
*/
|
||||||
|
public WorldEffectsModule getWorldEffectsModule() {
|
||||||
|
requireServerReady();
|
||||||
|
return worldEffectsModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers per-region data codecs for all 7 data-bearing ecosystem modules.
|
||||||
|
*
|
||||||
|
* <p>WorldEffects has no persistent per-region data and is intentionally omitted.</p>
|
||||||
|
*/
|
||||||
|
private static void registerModuleCodecs(FileRegionPersistenceService service) {
|
||||||
|
service.registerModuleCodec(
|
||||||
|
PollutionModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
PollutionRegionData d = data.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
|
||||||
|
.orElseGet(PollutionRegionData::defaults);
|
||||||
|
w.writeDouble("airPollution", d.getAirPollution());
|
||||||
|
w.writeDouble("groundPollution", d.getGroundPollution());
|
||||||
|
w.writeDouble("waterPollution", d.getWaterPollution());
|
||||||
|
w.writeDouble("decayResistance", d.getDecayResistance());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(PollutionModule.MODULE_ID, new PollutionRegionData(
|
||||||
|
r.readDouble("airPollution", 0.0),
|
||||||
|
r.readDouble("groundPollution", 0.0),
|
||||||
|
r.readDouble("waterPollution", 0.0),
|
||||||
|
r.readDouble("decayResistance", 20.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
SoilModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
SoilRegionData d = data.get(SoilModule.MODULE_ID, SoilRegionData.class)
|
||||||
|
.orElseGet(SoilRegionData::defaults);
|
||||||
|
w.writeDouble("fertility", d.getFertility());
|
||||||
|
w.writeDouble("moisture", d.getMoisture());
|
||||||
|
w.writeDouble("contamination", d.getContamination());
|
||||||
|
w.writeDouble("compaction", d.getCompaction());
|
||||||
|
w.writeDouble("erosion", d.getErosion());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(SoilModule.MODULE_ID, new SoilRegionData(
|
||||||
|
r.readDouble("fertility", 60.0),
|
||||||
|
r.readDouble("moisture", 50.0),
|
||||||
|
r.readDouble("contamination", 0.0),
|
||||||
|
r.readDouble("compaction", 10.0),
|
||||||
|
r.readDouble("erosion", 0.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
WaterModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
WaterRegionData d = data.get(WaterModule.MODULE_ID, WaterRegionData.class)
|
||||||
|
.orElseGet(WaterRegionData::defaults);
|
||||||
|
w.writeDouble("waterAvailability", d.getWaterAvailability());
|
||||||
|
w.writeDouble("purificationCapacity", d.getPurificationCapacity());
|
||||||
|
w.writeDouble("droughtRisk", d.getDroughtRisk());
|
||||||
|
w.writeDouble("floodRisk", d.getFloodRisk());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(WaterModule.MODULE_ID, new WaterRegionData(
|
||||||
|
r.readDouble("waterAvailability", 60.0),
|
||||||
|
r.readDouble("purificationCapacity", 50.0),
|
||||||
|
r.readDouble("droughtRisk", 10.0),
|
||||||
|
r.readDouble("floodRisk", 10.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
VegetationModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
VegetationRegionData d = data.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
|
||||||
|
.orElseGet(VegetationRegionData::defaults);
|
||||||
|
w.writeDouble("grassPressure", d.getGrassPressure());
|
||||||
|
w.writeDouble("flowerPressure", d.getFlowerPressure());
|
||||||
|
w.writeDouble("shrubPressure", d.getShrubPressure());
|
||||||
|
w.writeDouble("treePressure", d.getTreePressure());
|
||||||
|
w.writeDouble("deadVegetation", d.getDeadVegetation());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(VegetationModule.MODULE_ID, new VegetationRegionData(
|
||||||
|
r.readDouble("grassPressure", 50.0),
|
||||||
|
r.readDouble("flowerPressure", 30.0),
|
||||||
|
r.readDouble("shrubPressure", 30.0),
|
||||||
|
r.readDouble("treePressure", 40.0),
|
||||||
|
r.readDouble("deadVegetation", 5.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
ResourceDepletionModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
ResourceRegionData d = data.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
|
||||||
|
.orElseGet(ResourceRegionData::defaults);
|
||||||
|
w.writeDouble("miningDepletion", d.getMiningDepletion());
|
||||||
|
w.writeDouble("loggingDepletion", d.getLoggingDepletion());
|
||||||
|
w.writeDouble("farmingDepletion", d.getFarmingDepletion());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(ResourceDepletionModule.MODULE_ID, new ResourceRegionData(
|
||||||
|
r.readDouble("miningDepletion", 0.0),
|
||||||
|
r.readDouble("loggingDepletion", 0.0),
|
||||||
|
r.readDouble("farmingDepletion", 0.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
RecoveryModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
RecoveryRegionData d = data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
|
||||||
|
.orElseGet(RecoveryRegionData::defaults);
|
||||||
|
w.writeString("successionStage", d.getSuccessionStage().name());
|
||||||
|
w.writeDouble("recoveryProgress", d.getRecoveryProgress());
|
||||||
|
w.writeDouble("damageAccumulation", d.getDamageAccumulation());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(RecoveryModule.MODULE_ID, new RecoveryRegionData(
|
||||||
|
SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
|
||||||
|
r.readDouble("recoveryProgress", 0.0),
|
||||||
|
r.readDouble("damageAccumulation", 0.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
EcosystemModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
EcosystemRegionData d = data.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class)
|
||||||
|
.orElseGet(EcosystemRegionData::defaults);
|
||||||
|
w.writeDouble("ecosystemHealth", d.getEcosystemHealth());
|
||||||
|
w.writeDouble("stress", d.getStress());
|
||||||
|
w.writeDouble("resilience", d.getResilience());
|
||||||
|
w.writeDouble("recoveryRate", d.getRecoveryRate());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(EcosystemModule.MODULE_ID, new EcosystemRegionData(
|
||||||
|
r.readDouble("ecosystemHealth", 60.0),
|
||||||
|
r.readDouble("stress", 20.0),
|
||||||
|
r.readDouble("resilience", 50.0),
|
||||||
|
r.readDouble("recoveryRate", 5.0))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers all ecosystem simulation modules with the registry in pipeline order.
|
||||||
|
*
|
||||||
|
* <p>Order matters: each module may read metrics written by earlier modules in the
|
||||||
|
* same tick. The declared order here is the execution order inside
|
||||||
|
* {@link SimulationManager}.</p>
|
||||||
|
*/
|
||||||
|
private void registerEcosystemModules(ModuleRegistry registry) {
|
||||||
|
registry.register(new PollutionModule());
|
||||||
|
registry.register(new SoilModule());
|
||||||
|
registry.register(new WaterModule());
|
||||||
|
registry.register(new VegetationModule());
|
||||||
|
registry.register(new ResourceDepletionModule());
|
||||||
|
registry.register(new RecoveryModule());
|
||||||
|
registry.register(new EcosystemModule());
|
||||||
|
worldEffectsModule = new WorldEffectsModule();
|
||||||
|
registry.register(worldEffectsModule);
|
||||||
|
LivingWorldLogger.info(
|
||||||
|
DiagnosticCategory.BOOTSTRAP,
|
||||||
|
"Registered 8 ecosystem modules (pollution → soil → water → vegetation"
|
||||||
|
+ " → resources → recovery → ecosystem → worldeffects).");
|
||||||
|
}
|
||||||
|
|
||||||
private void requireInitialized() {
|
private void requireInitialized() {
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
throw new IllegalStateException("bootstrap has not been initialized");
|
throw new IllegalStateException("bootstrap has not been initialized");
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.livingworld.commands;
|
||||||
|
|
||||||
|
import com.livingworld.core.simulation.SimulationManager;
|
||||||
|
import com.livingworld.regions.RegionManager;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces the full module pipeline to run on a single region immediately,
|
||||||
|
* bypassing the scheduler. Useful for observing simulation effects without
|
||||||
|
* waiting for the next scheduled tick.
|
||||||
|
*/
|
||||||
|
public final class ForceUpdateCommand {
|
||||||
|
|
||||||
|
private ForceUpdateCommand() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int executeAtSelf(
|
||||||
|
CommandSourceStack source,
|
||||||
|
RegionManager regionManager,
|
||||||
|
SimulationManager simulationManager) {
|
||||||
|
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||||
|
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
|
||||||
|
if (simulationManager == null) throw new IllegalArgumentException("simulationManager must not be null");
|
||||||
|
|
||||||
|
Vec3 pos = source.getPosition();
|
||||||
|
String dimensionId = source.getLevel().dimension().location().toString();
|
||||||
|
// Ensure the region is loaded/created before asking the simulation manager to update it.
|
||||||
|
var region = regionManager.getOrCreateRegionAtBlock(
|
||||||
|
dimensionId, (int) Math.floor(pos.x), (int) Math.floor(pos.z));
|
||||||
|
simulationManager.forceUpdateRegion(region.getCoordinate());
|
||||||
|
|
||||||
|
String msg = "Forced update on region " + region.getCoordinate().stableId()
|
||||||
|
+ " — run '/lw region info' to see the result.";
|
||||||
|
source.sendSuccess(() -> Component.literal(msg), true);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
package com.livingworld.commands;
|
package com.livingworld.commands;
|
||||||
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.UUID;
|
||||||
|
import java.util.function.Function;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import com.mojang.brigadier.CommandDispatcher;
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||||
|
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||||
|
|
||||||
import net.minecraft.commands.CommandSourceStack;
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
import net.minecraft.commands.Commands;
|
import net.minecraft.commands.Commands;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
|
||||||
import com.livingworld.core.simulation.SimulationManager;
|
import com.livingworld.core.simulation.SimulationManager;
|
||||||
import com.livingworld.modules.ModuleRegistry;
|
import com.livingworld.modules.ModuleRegistry;
|
||||||
@@ -33,14 +37,16 @@ public final class LivingWorldCommandRoot {
|
|||||||
dispatcher,
|
dispatcher,
|
||||||
() -> regionManager,
|
() -> regionManager,
|
||||||
() -> moduleRegistry,
|
() -> moduleRegistry,
|
||||||
() -> simulationManager);
|
() -> simulationManager,
|
||||||
|
uuid -> false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void registerDeferred(
|
public static void registerDeferred(
|
||||||
CommandDispatcher<CommandSourceStack> dispatcher,
|
CommandDispatcher<CommandSourceStack> dispatcher,
|
||||||
Supplier<RegionManager> regionManager,
|
Supplier<RegionManager> regionManager,
|
||||||
Supplier<ModuleRegistry> moduleRegistry,
|
Supplier<ModuleRegistry> moduleRegistry,
|
||||||
Supplier<SimulationManager> simulationManager) {
|
Supplier<SimulationManager> simulationManager,
|
||||||
|
Function<UUID, Boolean> hudToggle) {
|
||||||
if (dispatcher == null) {
|
if (dispatcher == null) {
|
||||||
throw new IllegalArgumentException("dispatcher must not be null");
|
throw new IllegalArgumentException("dispatcher must not be null");
|
||||||
}
|
}
|
||||||
@@ -64,14 +70,30 @@ public final class LivingWorldCommandRoot {
|
|||||||
requireService(simulationManager, "simulationManager"))))
|
requireService(simulationManager, "simulationManager"))))
|
||||||
.then(Commands.literal("region")
|
.then(Commands.literal("region")
|
||||||
.then(Commands.literal("info")
|
.then(Commands.literal("info")
|
||||||
.executes(context -> RegionInfoCommand.execute(
|
.executes(context -> RegionInfoCommand.executeAtSelf(
|
||||||
context.getSource(),
|
context.getSource(),
|
||||||
requireService(regionManager, "regionManager")))))
|
requireService(regionManager, "regionManager")))
|
||||||
|
.then(Commands.argument("x", IntegerArgumentType.integer())
|
||||||
|
.then(Commands.argument("z", IntegerArgumentType.integer())
|
||||||
|
.executes(context -> RegionInfoCommand.executeAt(
|
||||||
|
context.getSource(),
|
||||||
|
requireService(regionManager, "regionManager"),
|
||||||
|
IntegerArgumentType.getInteger(context, "x"),
|
||||||
|
IntegerArgumentType.getInteger(context, "z"))))))
|
||||||
|
.then(Commands.literal("force-update")
|
||||||
|
.executes(context -> ForceUpdateCommand.executeAtSelf(
|
||||||
|
context.getSource(),
|
||||||
|
requireService(regionManager, "regionManager"),
|
||||||
|
requireService(simulationManager, "simulationManager")))))
|
||||||
.then(Commands.literal("modules")
|
.then(Commands.literal("modules")
|
||||||
.then(Commands.literal("list")
|
.then(Commands.literal("list")
|
||||||
.executes(context -> listModules(
|
.executes(context -> listModules(
|
||||||
context.getSource(),
|
context.getSource(),
|
||||||
requireService(moduleRegistry, "moduleRegistry")))))
|
requireService(moduleRegistry, "moduleRegistry")))))
|
||||||
|
.then(Commands.literal("stats")
|
||||||
|
.executes(context -> StatsCommand.execute(
|
||||||
|
context.getSource(),
|
||||||
|
requireService(simulationManager, "simulationManager"))))
|
||||||
.then(Commands.literal("simulate")
|
.then(Commands.literal("simulate")
|
||||||
.then(Commands.argument(
|
.then(Commands.argument(
|
||||||
"ticks",
|
"ticks",
|
||||||
@@ -80,7 +102,24 @@ public final class LivingWorldCommandRoot {
|
|||||||
.executes(context -> SimulateCommand.execute(
|
.executes(context -> SimulateCommand.execute(
|
||||||
context.getSource(),
|
context.getSource(),
|
||||||
requireService(simulationManager, "simulationManager"),
|
requireService(simulationManager, "simulationManager"),
|
||||||
IntegerArgumentType.getInteger(context, "ticks"))))));
|
IntegerArgumentType.getInteger(context, "ticks")))))
|
||||||
|
.then(Commands.literal("hud")
|
||||||
|
.executes(context -> toggleHud(context.getSource(), hudToggle))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int toggleHud(CommandSourceStack source, Function<UUID, Boolean> hudToggle) {
|
||||||
|
ServerPlayer player;
|
||||||
|
try {
|
||||||
|
player = source.getPlayerOrException();
|
||||||
|
} catch (CommandSyntaxException e) {
|
||||||
|
source.sendFailure(Component.literal("/lw hud can only be used by a player"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
boolean nowEnabled = hudToggle.apply(player.getUUID());
|
||||||
|
source.sendSuccess(
|
||||||
|
() -> Component.literal("Region HUD " + (nowEnabled ? "enabled" : "disabled")),
|
||||||
|
false);
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static <T> T requireService(Supplier<T> supplier, String name) {
|
private static <T> T requireService(Supplier<T> supplier, String name) {
|
||||||
|
|||||||
@@ -1,44 +1,55 @@
|
|||||||
package com.livingworld.commands;
|
package com.livingworld.commands;
|
||||||
|
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionManager;
|
||||||
import net.minecraft.commands.CommandSourceStack;
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.world.phys.Vec3;
|
import net.minecraft.world.phys.Vec3;
|
||||||
|
|
||||||
import com.livingworld.regions.Region;
|
|
||||||
import com.livingworld.regions.RegionManager;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prints the Living World region state at the command source position.
|
* Prints the Living World region state — either at the caller's position or
|
||||||
|
* at explicit block coordinates.
|
||||||
*/
|
*/
|
||||||
public final class RegionInfoCommand {
|
public final class RegionInfoCommand {
|
||||||
|
|
||||||
private RegionInfoCommand() {
|
private RegionInfoCommand() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int execute(
|
/** Uses the command-source position (player standing location). */
|
||||||
|
public static int executeAtSelf(
|
||||||
CommandSourceStack source,
|
CommandSourceStack source,
|
||||||
RegionManager regionManager) {
|
RegionManager regionManager) {
|
||||||
if (source == null) {
|
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||||
throw new IllegalArgumentException("source must not be null");
|
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
|
||||||
}
|
|
||||||
if (regionManager == null) {
|
|
||||||
throw new IllegalArgumentException("regionManager must not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
Vec3 position = source.getPosition();
|
Vec3 position = source.getPosition();
|
||||||
String dimensionId = source.getLevel().dimension().location().toString();
|
String dimensionId = source.getLevel().dimension().location().toString();
|
||||||
Region region = regionManager.getOrCreateRegionAtBlock(
|
Region region = regionManager.getOrCreateRegionAtBlock(
|
||||||
dimensionId,
|
dimensionId,
|
||||||
floorToBlock(position.x),
|
(int) Math.floor(position.x),
|
||||||
floorToBlock(position.z));
|
(int) Math.floor(position.z));
|
||||||
|
|
||||||
|
return sendLines(source, region);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uses explicit block X/Z coordinates in the caller's current dimension. */
|
||||||
|
public static int executeAt(
|
||||||
|
CommandSourceStack source,
|
||||||
|
RegionManager regionManager,
|
||||||
|
int blockX,
|
||||||
|
int blockZ) {
|
||||||
|
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||||
|
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
|
||||||
|
|
||||||
|
String dimensionId = source.getLevel().dimension().location().toString();
|
||||||
|
Region region = regionManager.getOrCreateRegionAtBlock(dimensionId, blockX, blockZ);
|
||||||
|
return sendLines(source, region);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int sendLines(CommandSourceStack source, Region region) {
|
||||||
for (String line : RegionInfoFormatter.format(region)) {
|
for (String line : RegionInfoFormatter.format(region)) {
|
||||||
source.sendSuccess(() -> Component.literal(line), false);
|
source.sendSuccess(() -> Component.literal(line), false);
|
||||||
}
|
}
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int floorToBlock(double coordinate) {
|
|
||||||
return (int) Math.floor(coordinate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
package com.livingworld.commands;
|
package com.livingworld.commands;
|
||||||
|
|
||||||
import java.util.List;
|
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||||
import java.util.Set;
|
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||||
import java.util.TreeSet;
|
import com.livingworld.modules.pollution.PollutionModule;
|
||||||
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryModule;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||||
|
import com.livingworld.modules.resources.ResourceRegionData;
|
||||||
|
import com.livingworld.modules.soil.SoilModule;
|
||||||
|
import com.livingworld.modules.soil.SoilRegionData;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationModule;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||||
|
import com.livingworld.modules.water.WaterModule;
|
||||||
|
import com.livingworld.modules.water.WaterRegionData;
|
||||||
import com.livingworld.regions.Region;
|
import com.livingworld.regions.Region;
|
||||||
import com.livingworld.regions.RegionFlags;
|
import com.livingworld.regions.RegionFlags;
|
||||||
import com.livingworld.regions.RegionMetrics;
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import com.livingworld.regions.RegionModuleData;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats region diagnostics without depending on Minecraft classes.
|
* Formats region diagnostics without depending on Minecraft classes.
|
||||||
@@ -21,26 +34,84 @@ public final class RegionInfoFormatter {
|
|||||||
throw new IllegalArgumentException("region must not be null");
|
throw new IllegalArgumentException("region must not be null");
|
||||||
}
|
}
|
||||||
|
|
||||||
RegionMetrics metrics = region.getMetrics();
|
RegionMetrics m = region.getMetrics();
|
||||||
RegionFlags flags = region.getFlags();
|
RegionFlags f = region.getFlags();
|
||||||
Set<String> moduleIds = new TreeSet<>(region.getModuleData().moduleIds());
|
RegionModuleData data = region.getModuleData();
|
||||||
|
|
||||||
return List.of(
|
List<String> lines = new ArrayList<>();
|
||||||
"Region: " + region.getCoordinate().stableId(),
|
lines.add("Region: " + region.getCoordinate().stableId()
|
||||||
"Lifecycle: " + region.getLifecycleState() + ", dirty=" + region.isDirty(),
|
+ " lifecycle=" + region.getLifecycleState()
|
||||||
"Metrics: ecosystemHealth=" + metrics.getEcosystemHealth()
|
+ " dirty=" + region.isDirty()
|
||||||
+ ", pollution=" + metrics.getPollutionScore()
|
+ " tick=" + region.getLastUpdatedSimulationTick());
|
||||||
+ ", soilQuality=" + metrics.getSoilQuality()
|
lines.add("Metrics:"
|
||||||
+ ", waterQuality=" + metrics.getWaterQuality()
|
+ " health=" + fmt(m.getEcosystemHealth())
|
||||||
+ ", vegetationPressure=" + metrics.getVegetationPressure()
|
+ " poll=" + fmt(m.getPollutionScore())
|
||||||
+ ", resourceDepletion=" + metrics.getResourceDepletion()
|
+ " soil=" + fmt(m.getSoilQuality())
|
||||||
+ ", recoveryPressure=" + metrics.getRecoveryPressure(),
|
+ " water=" + fmt(m.getWaterQuality())
|
||||||
"Flags: playerActivity=" + flags.isHasPlayerActivity()
|
+ " veg=" + fmt(m.getVegetationPressure())
|
||||||
+ ", highPollution=" + flags.isHasHighPollution()
|
+ " res=" + fmt(m.getResourceDepletion())
|
||||||
+ ", lowSoilQuality=" + flags.isHasLowSoilQuality()
|
+ " recov=" + fmt(m.getRecoveryPressure()));
|
||||||
+ ", activeEcosystemEvent=" + flags.isHasActiveEcosystemEvent()
|
lines.add("Flags:"
|
||||||
+ ", forceLoaded=" + flags.isForceLoadedBySimulation()
|
+ " playerActivity=" + f.isHasPlayerActivity()
|
||||||
+ ", corrupted=" + flags.isCorrupted(),
|
+ " highPollution=" + f.isHasHighPollution()
|
||||||
"Module data: " + (moduleIds.isEmpty() ? "none" : String.join(", ", moduleIds)));
|
+ " lowSoil=" + f.isHasLowSoilQuality()
|
||||||
|
+ " ecoEvent=" + f.isHasActiveEcosystemEvent()
|
||||||
|
+ " forceLoaded=" + f.isForceLoadedBySimulation());
|
||||||
|
|
||||||
|
lines.add("--- Module Data ---");
|
||||||
|
data.get(PollutionModule.MODULE_ID, PollutionRegionData.class).ifPresentOrElse(
|
||||||
|
d -> lines.add(" pollution: air=" + fmt(d.getAirPollution())
|
||||||
|
+ " ground=" + fmt(d.getGroundPollution())
|
||||||
|
+ " water=" + fmt(d.getWaterPollution())
|
||||||
|
+ " decay=" + fmt(d.getDecayResistance())),
|
||||||
|
() -> lines.add(" pollution: (no data)"));
|
||||||
|
|
||||||
|
data.get(SoilModule.MODULE_ID, SoilRegionData.class).ifPresentOrElse(
|
||||||
|
d -> lines.add(" soil: fertility=" + fmt(d.getFertility())
|
||||||
|
+ " moisture=" + fmt(d.getMoisture())
|
||||||
|
+ " contam=" + fmt(d.getContamination())
|
||||||
|
+ " compact=" + fmt(d.getCompaction())
|
||||||
|
+ " erosion=" + fmt(d.getErosion())),
|
||||||
|
() -> lines.add(" soil: (no data)"));
|
||||||
|
|
||||||
|
data.get(WaterModule.MODULE_ID, WaterRegionData.class).ifPresentOrElse(
|
||||||
|
d -> lines.add(" water: avail=" + fmt(d.getWaterAvailability())
|
||||||
|
+ " purif=" + fmt(d.getPurificationCapacity())
|
||||||
|
+ " drought=" + fmt(d.getDroughtRisk())
|
||||||
|
+ " flood=" + fmt(d.getFloodRisk())),
|
||||||
|
() -> lines.add(" water: (no data)"));
|
||||||
|
|
||||||
|
data.get(VegetationModule.MODULE_ID, VegetationRegionData.class).ifPresentOrElse(
|
||||||
|
d -> lines.add(" vegetation: grass=" + fmt(d.getGrassPressure())
|
||||||
|
+ " flower=" + fmt(d.getFlowerPressure())
|
||||||
|
+ " shrub=" + fmt(d.getShrubPressure())
|
||||||
|
+ " tree=" + fmt(d.getTreePressure())
|
||||||
|
+ " dead=" + fmt(d.getDeadVegetation())),
|
||||||
|
() -> lines.add(" vegetation: (no data)"));
|
||||||
|
|
||||||
|
data.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).ifPresentOrElse(
|
||||||
|
d -> lines.add(" resources: mining=" + fmt(d.getMiningDepletion())
|
||||||
|
+ " logging=" + fmt(d.getLoggingDepletion())
|
||||||
|
+ " farming=" + fmt(d.getFarmingDepletion())),
|
||||||
|
() -> lines.add(" resources: (no data)"));
|
||||||
|
|
||||||
|
data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).ifPresentOrElse(
|
||||||
|
d -> lines.add(" recovery: stage=" + d.getSuccessionStage()
|
||||||
|
+ " progress=" + fmt(d.getRecoveryProgress())
|
||||||
|
+ " damage=" + fmt(d.getDamageAccumulation())),
|
||||||
|
() -> lines.add(" recovery: (no data)"));
|
||||||
|
|
||||||
|
data.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class).ifPresentOrElse(
|
||||||
|
d -> lines.add(" ecosystem: health=" + fmt(d.getEcosystemHealth())
|
||||||
|
+ " stress=" + fmt(d.getStress())
|
||||||
|
+ " resilience=" + fmt(d.getResilience())
|
||||||
|
+ " rate=" + fmt(d.getRecoveryRate())),
|
||||||
|
() -> lines.add(" ecosystem: (no data)"));
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fmt(double v) {
|
||||||
|
return String.format("%.1f", v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.livingworld.commands;
|
||||||
|
|
||||||
|
import com.livingworld.core.simulation.SimulationManager;
|
||||||
|
import com.livingworld.debug.SimulationProfileSnapshot;
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import java.util.StringJoiner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the profiler snapshot from the last simulation cycle.
|
||||||
|
*/
|
||||||
|
public final class StatsCommand {
|
||||||
|
|
||||||
|
private StatsCommand() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int execute(CommandSourceStack source, SimulationManager simulationManager) {
|
||||||
|
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||||
|
if (simulationManager == null) throw new IllegalArgumentException("simulationManager must not be null");
|
||||||
|
|
||||||
|
SimulationProfileSnapshot snap = simulationManager.createProfileSnapshot();
|
||||||
|
|
||||||
|
String cycleMs = String.format("%.2f", snap.totalCycleNanos() / 1_000_000.0);
|
||||||
|
String header = "LW stats:"
|
||||||
|
+ " cycle=" + cycleMs + "ms"
|
||||||
|
+ " events=" + snap.eventsPublished()
|
||||||
|
+ " regions=" + snap.regionsUpdated()
|
||||||
|
+ " saves=" + snap.savesPerformed()
|
||||||
|
+ " budget_overrun=" + snap.budgetExceeded()
|
||||||
|
+ " sim_tick=" + simulationManager.getSimulationTickCounter();
|
||||||
|
source.sendSuccess(() -> Component.literal(header), false);
|
||||||
|
|
||||||
|
if (!snap.moduleTimings().isEmpty()) {
|
||||||
|
StringJoiner timings = new StringJoiner(" ");
|
||||||
|
snap.moduleTimings().forEach((id, nanos) ->
|
||||||
|
timings.add(id + "=" + String.format("%.2f", nanos / 1_000_000.0) + "ms"));
|
||||||
|
String moduleLine = "Modules: " + timings;
|
||||||
|
source.sendSuccess(() -> Component.literal(moduleLine), false);
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ public final class SimulationConfig {
|
|||||||
private int regionSizeChunks = 8;
|
private int regionSizeChunks = 8;
|
||||||
|
|
||||||
/** Interval between simulation cycles, in game ticks (must be >= 1). */
|
/** 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). */
|
/** Maximum number of regions processed per cycle (must be >= 1). */
|
||||||
private int maxRegionsPerCycle = 50;
|
private int maxRegionsPerCycle = 50;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.livingworld.core.services;
|
package com.livingworld.core.services;
|
||||||
|
|
||||||
import com.livingworld.data.saved.SaveMetadata;
|
import com.livingworld.data.saved.SaveMetadata;
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.data.serialization.PropertiesPersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PropertiesPersistenceWriter;
|
||||||
import com.livingworld.debug.DiagnosticCategory;
|
import com.livingworld.debug.DiagnosticCategory;
|
||||||
import com.livingworld.debug.LivingWorldLogger;
|
import com.livingworld.debug.LivingWorldLogger;
|
||||||
import com.livingworld.regions.Region;
|
import com.livingworld.regions.Region;
|
||||||
@@ -18,12 +22,15 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File-backed persistence for the Volume 1 region model.
|
* File-backed persistence for the Volume 1 region model.
|
||||||
@@ -40,8 +47,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
private final String modVersion;
|
private final String modVersion;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
|
private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
|
||||||
|
private final List<ModuleCodecEntry> moduleCodecs = new ArrayList<>();
|
||||||
private SaveMetadata metadata;
|
private SaveMetadata metadata;
|
||||||
|
|
||||||
|
private record ModuleCodecEntry(
|
||||||
|
String moduleId,
|
||||||
|
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
|
||||||
|
BiConsumer<PersistenceReader, RegionModuleData> decoder) {}
|
||||||
|
|
||||||
public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
|
public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
|
||||||
this(rootDirectory, modVersion, Clock.systemUTC());
|
this(rootDirectory, modVersion, Clock.systemUTC());
|
||||||
}
|
}
|
||||||
@@ -136,6 +149,25 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
return dirtyRegions.size();
|
return dirtyRegions.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a codec that serialises one module's per-region data.
|
||||||
|
*
|
||||||
|
* <p>Must be called before any region is saved or loaded. Codecs are applied
|
||||||
|
* in registration order during both encode and decode. Each codec writes to /
|
||||||
|
* reads from keys prefixed with {@code "mod.<moduleId>."} so modules cannot
|
||||||
|
* collide.</p>
|
||||||
|
*
|
||||||
|
* @param moduleId the unique module identifier (used as key namespace)
|
||||||
|
* @param encoder writes module data from {@link RegionModuleData} to a writer
|
||||||
|
* @param decoder reads module data from a reader and populates {@link RegionModuleData}
|
||||||
|
*/
|
||||||
|
public synchronized void registerModuleCodec(
|
||||||
|
String moduleId,
|
||||||
|
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
|
||||||
|
BiConsumer<PersistenceReader, RegionModuleData> decoder) {
|
||||||
|
moduleCodecs.add(new ModuleCodecEntry(moduleId, encoder, decoder));
|
||||||
|
}
|
||||||
|
|
||||||
private void initializeStorage() {
|
private void initializeStorage() {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(regionsDirectory);
|
Files.createDirectories(regionsDirectory);
|
||||||
@@ -242,6 +274,13 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
properties.setProperty(
|
properties.setProperty(
|
||||||
"metric.recoveryPressure",
|
"metric.recoveryPressure",
|
||||||
Double.toString(metrics.getRecoveryPressure()));
|
Double.toString(metrics.getRecoveryPressure()));
|
||||||
|
|
||||||
|
for (ModuleCodecEntry codec : moduleCodecs) {
|
||||||
|
String prefix = "mod." + codec.moduleId() + ".";
|
||||||
|
codec.encoder().accept(
|
||||||
|
region.getModuleData(),
|
||||||
|
new PropertiesPersistenceWriter(properties, prefix));
|
||||||
|
}
|
||||||
return properties;
|
return properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +309,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
|
metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
|
||||||
metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure"));
|
metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure"));
|
||||||
|
|
||||||
|
RegionModuleData moduleData = new RegionModuleData();
|
||||||
|
for (ModuleCodecEntry codec : moduleCodecs) {
|
||||||
|
String prefix = "mod." + codec.moduleId() + ".";
|
||||||
|
codec.decoder().accept(
|
||||||
|
new PropertiesPersistenceReader(properties, prefix),
|
||||||
|
moduleData);
|
||||||
|
}
|
||||||
|
|
||||||
Region region = new Region(
|
Region region = new Region(
|
||||||
UUID.fromString(required(properties, "id")),
|
UUID.fromString(required(properties, "id")),
|
||||||
new RegionCoordinate(
|
new RegionCoordinate(
|
||||||
@@ -282,7 +329,7 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
false,
|
false,
|
||||||
flags,
|
flags,
|
||||||
metrics,
|
metrics,
|
||||||
new RegionModuleData());
|
moduleData);
|
||||||
region.validate();
|
region.validate();
|
||||||
return region;
|
return region;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.livingworld.core.simulation;
|
package com.livingworld.core.simulation;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ public interface RegionManager {
|
|||||||
*/
|
*/
|
||||||
List<Region> resolveAll(List<RegionCoordinate> coordinates);
|
List<Region> resolveAll(List<RegionCoordinate> coordinates);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all currently active (cached/loaded) regions.
|
||||||
|
*/
|
||||||
|
Collection<Region> getActiveRegions();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks a region as dirty, indicating unsaved changes.
|
* Marks a region as dirty, indicating unsaved changes.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.livingworld.core.simulation;
|
package com.livingworld.core.simulation;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -8,6 +9,8 @@ import com.livingworld.core.services.PersistenceService;
|
|||||||
import com.livingworld.core.services.TimeService;
|
import com.livingworld.core.services.TimeService;
|
||||||
import com.livingworld.debug.DiagnosticCategory;
|
import com.livingworld.debug.DiagnosticCategory;
|
||||||
import com.livingworld.debug.LivingWorldLogger;
|
import com.livingworld.debug.LivingWorldLogger;
|
||||||
|
import com.livingworld.debug.SimulationProfileSnapshot;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
import com.livingworld.events.LivingWorldEventBus;
|
import com.livingworld.events.LivingWorldEventBus;
|
||||||
import com.livingworld.modules.ModuleUpdateResult;
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
import com.livingworld.modules.ModuleRegistry;
|
import com.livingworld.modules.ModuleRegistry;
|
||||||
@@ -61,6 +64,11 @@ public final class SimulationManager {
|
|||||||
this.scheduler.onMinecraftTick();
|
this.scheduler.onMinecraftTick();
|
||||||
|
|
||||||
if (this.scheduler.shouldRunSimulationCycle()) {
|
if (this.scheduler.shouldRunSimulationCycle()) {
|
||||||
|
long tick = this.timeService.getSimulationTick();
|
||||||
|
for (Region r : this.regionManager.getActiveRegions()) {
|
||||||
|
this.scheduler.queueRegion(new RegionUpdateJob(
|
||||||
|
r.getCoordinate(), 0, tick, null, UpdateReason.NORMAL_ROLLING_UPDATE));
|
||||||
|
}
|
||||||
runSimulationCycle();
|
runSimulationCycle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +200,49 @@ public final class SimulationManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces the full module pipeline to run on a single region immediately,
|
||||||
|
* bypassing the scheduler. Intended for debug commands only.
|
||||||
|
*/
|
||||||
|
public void forceUpdateRegion(RegionCoordinate coordinate) {
|
||||||
|
if (coordinate == null) {
|
||||||
|
throw new IllegalArgumentException("coordinate must not be null");
|
||||||
|
}
|
||||||
|
Optional<Region> region = regionManager.resolve(coordinate);
|
||||||
|
if (region.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long tick = getSimulationTickCounter();
|
||||||
|
RegionUpdateJob job = new RegionUpdateJob(
|
||||||
|
coordinate, 100, tick, null, UpdateReason.FORCED_DEBUG_COMMAND);
|
||||||
|
if (this.profiler != null) {
|
||||||
|
this.profiler.startCycle(tick);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
runModulesForRegion(region.get(), job, tick);
|
||||||
|
} finally {
|
||||||
|
if (this.profiler != null) {
|
||||||
|
this.profiler.endCycle(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
||||||
|
return concrete.createSnapshot();
|
||||||
|
}
|
||||||
|
return new SimulationProfileSnapshot(0L, java.util.Map.of(), 0, 0, 0, false);
|
||||||
|
}
|
||||||
|
|
||||||
public long getMinecraftTickCounter() {
|
public long getMinecraftTickCounter() {
|
||||||
return this.scheduler.getMinecraftTickCounter();
|
return this.scheduler.getMinecraftTickCounter();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.livingworld.data.migration;
|
||||||
|
|
||||||
|
import com.livingworld.debug.DiagnosticCategory;
|
||||||
|
import com.livingworld.debug.LivingWorldLogger;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the schema version of region save data and applies registered
|
||||||
|
* {@link RegionMigration} steps in order until the data reaches the target version.
|
||||||
|
*
|
||||||
|
* <p>Usage:
|
||||||
|
* <ol>
|
||||||
|
* <li>Construct with the path to the migrations log file (or {@code null} to disable logging).
|
||||||
|
* <li>Register one {@link RegionMigration} per version gap via {@link #register}.
|
||||||
|
* <li>Call {@link #migrateIfNeeded} before decoding saved region properties.
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public final class MigrationManager {
|
||||||
|
|
||||||
|
private final Map<Integer, RegionMigration> migrations = new TreeMap<>();
|
||||||
|
private final Path logFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param logFile path to the append-only migrations log; {@code null} to disable file logging
|
||||||
|
*/
|
||||||
|
public MigrationManager(Path logFile) {
|
||||||
|
this.logFile = logFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a migration step.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if the migration is null, if its version span is not
|
||||||
|
* exactly one, or if a migration for the same
|
||||||
|
* {@code fromVersion} is already registered
|
||||||
|
*/
|
||||||
|
public void register(RegionMigration migration) {
|
||||||
|
if (migration == null) {
|
||||||
|
throw new IllegalArgumentException("migration must not be null");
|
||||||
|
}
|
||||||
|
int from = migration.fromVersion();
|
||||||
|
int to = migration.toVersion();
|
||||||
|
if (to != from + 1) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Migration must advance exactly one version; got " + from + " → " + to);
|
||||||
|
}
|
||||||
|
if (migrations.containsKey(from)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Migration already registered for fromVersion " + from);
|
||||||
|
}
|
||||||
|
migrations.put(from, migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of registered migration steps.
|
||||||
|
*/
|
||||||
|
public int getRegisteredMigrationCount() {
|
||||||
|
return migrations.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when the data's {@code schemaVersion} already equals
|
||||||
|
* {@code targetVersion} and no migration is needed.
|
||||||
|
*/
|
||||||
|
public boolean isUpToDate(Properties data, int targetVersion) {
|
||||||
|
return readVersion(data) == targetVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrates the given save data to {@code targetVersion} if it is behind, and returns
|
||||||
|
* the (possibly modified) properties. Returns the original object unchanged when already
|
||||||
|
* at the target version.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException if the data is ahead of {@code targetVersion}, if
|
||||||
|
* {@code schemaVersion} is missing or invalid, or if the
|
||||||
|
* registered migrations do not cover the required range
|
||||||
|
*/
|
||||||
|
public Properties migrateIfNeeded(Properties data, int targetVersion) {
|
||||||
|
int currentVersion = readVersion(data);
|
||||||
|
if (currentVersion == targetVersion) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (currentVersion > targetVersion) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Cannot downgrade save data from schema version "
|
||||||
|
+ currentVersion + " to " + targetVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RegionMigration> path = buildMigrationPath(currentVersion, targetVersion);
|
||||||
|
|
||||||
|
Properties result = copyProperties(data);
|
||||||
|
for (RegionMigration step : path) {
|
||||||
|
result = step.apply(result);
|
||||||
|
result.setProperty("schemaVersion", Integer.toString(step.toVersion()));
|
||||||
|
recordStep(step.fromVersion(), step.toVersion());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// private helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private List<RegionMigration> buildMigrationPath(int from, int to) {
|
||||||
|
List<RegionMigration> path = new ArrayList<>();
|
||||||
|
int version = from;
|
||||||
|
while (version < to) {
|
||||||
|
RegionMigration step = migrations.get(version);
|
||||||
|
if (step == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"No migration registered for schema version " + version
|
||||||
|
+ " (target version: " + to + ")");
|
||||||
|
}
|
||||||
|
path.add(step);
|
||||||
|
version++;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int readVersion(Properties data) {
|
||||||
|
if (data == null) {
|
||||||
|
throw new IllegalArgumentException("data must not be null");
|
||||||
|
}
|
||||||
|
String raw = data.getProperty("schemaVersion");
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
throw new IllegalStateException("Save data is missing required key: schemaVersion");
|
||||||
|
}
|
||||||
|
int version;
|
||||||
|
try {
|
||||||
|
version = Integer.parseInt(raw.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
throw new IllegalStateException("Invalid schemaVersion value: " + raw, e);
|
||||||
|
}
|
||||||
|
if (version <= 0) {
|
||||||
|
throw new IllegalStateException("schemaVersion must be > 0, got: " + version);
|
||||||
|
}
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Properties copyProperties(Properties source) {
|
||||||
|
Properties copy = new Properties();
|
||||||
|
for (String key : source.stringPropertyNames()) {
|
||||||
|
copy.setProperty(key, source.getProperty(key));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordStep(int from, int to) {
|
||||||
|
LivingWorldLogger.info(
|
||||||
|
DiagnosticCategory.PERSISTENCE,
|
||||||
|
"Applied region schema migration: " + from + " → " + to);
|
||||||
|
if (logFile == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String entry = Instant.now() + " Migrated region schema " + from + " → " + to
|
||||||
|
+ System.lineSeparator();
|
||||||
|
try {
|
||||||
|
if (logFile.getParent() != null) {
|
||||||
|
Files.createDirectories(logFile.getParent());
|
||||||
|
}
|
||||||
|
Files.writeString(
|
||||||
|
logFile,
|
||||||
|
entry,
|
||||||
|
StandardCharsets.UTF_8,
|
||||||
|
StandardOpenOption.CREATE,
|
||||||
|
StandardOpenOption.APPEND);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LivingWorldLogger.warn(
|
||||||
|
DiagnosticCategory.PERSISTENCE,
|
||||||
|
"Failed to write to migrations log: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.livingworld.data.migration;
|
||||||
|
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single schema migration step for region save data.
|
||||||
|
*
|
||||||
|
* <p>Each migration advances exactly one schema version. Implementations must be
|
||||||
|
* deterministic: given the same input {@link Properties}, they must always produce
|
||||||
|
* the same output. The {@link MigrationManager} updates {@code schemaVersion} in the
|
||||||
|
* result automatically; implementations must not set it themselves.
|
||||||
|
*/
|
||||||
|
public interface RegionMigration {
|
||||||
|
|
||||||
|
/** The schema version this migration reads from. */
|
||||||
|
int fromVersion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The schema version this migration produces.
|
||||||
|
*
|
||||||
|
* <p>Must equal {@link #fromVersion()} + 1.
|
||||||
|
*/
|
||||||
|
int toVersion();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies this migration to the given save data.
|
||||||
|
*
|
||||||
|
* @param data a copy of the raw region save properties at {@link #fromVersion()}
|
||||||
|
* @return a new or mutated {@link Properties} containing the migrated data
|
||||||
|
*/
|
||||||
|
Properties apply(Properties data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.livingworld.data.serialization;
|
||||||
|
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link PersistenceReader} backed by a {@link Properties} object.
|
||||||
|
*
|
||||||
|
* <p>All key lookups are namespaced by a caller-supplied prefix, mirroring
|
||||||
|
* the convention used by {@link PropertiesPersistenceWriter}. Missing keys
|
||||||
|
* return the supplied default value rather than throwing.</p>
|
||||||
|
*/
|
||||||
|
public final class PropertiesPersistenceReader implements PersistenceReader {
|
||||||
|
|
||||||
|
private final Properties props;
|
||||||
|
private final String prefix;
|
||||||
|
|
||||||
|
public PropertiesPersistenceReader(Properties props, String prefix) {
|
||||||
|
this.props = props;
|
||||||
|
this.prefix = prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String readString(String key, String defaultValue) {
|
||||||
|
String value = props.getProperty(prefix + key);
|
||||||
|
return value != null ? value : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int readInt(String key, int defaultValue) {
|
||||||
|
String value = props.getProperty(prefix + key);
|
||||||
|
return value != null ? Integer.parseInt(value) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long readLong(String key, long defaultValue) {
|
||||||
|
String value = props.getProperty(prefix + key);
|
||||||
|
return value != null ? Long.parseLong(value) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double readDouble(String key, double defaultValue) {
|
||||||
|
String value = props.getProperty(prefix + key);
|
||||||
|
return value != null ? Double.parseDouble(value) : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean readBoolean(String key, boolean defaultValue) {
|
||||||
|
String value = props.getProperty(prefix + key);
|
||||||
|
return value != null ? Boolean.parseBoolean(value) : defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.livingworld.data.serialization;
|
||||||
|
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link PersistenceWriter} backed by a {@link Properties} object.
|
||||||
|
*
|
||||||
|
* <p>All keys are namespaced by a caller-supplied prefix so that multiple
|
||||||
|
* modules can write to the same {@code Properties} without collision.
|
||||||
|
* For example, a prefix of {@code "mod.pollution."} and a key of
|
||||||
|
* {@code "airPollution"} stores the value under {@code "mod.pollution.airPollution"}.</p>
|
||||||
|
*/
|
||||||
|
public final class PropertiesPersistenceWriter implements PersistenceWriter {
|
||||||
|
|
||||||
|
private final Properties props;
|
||||||
|
private final String prefix;
|
||||||
|
|
||||||
|
public PropertiesPersistenceWriter(Properties props, String prefix) {
|
||||||
|
this.props = props;
|
||||||
|
this.prefix = prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeString(String key, String value) {
|
||||||
|
props.setProperty(prefix + key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeInt(String key, int value) {
|
||||||
|
props.setProperty(prefix + key, Integer.toString(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeLong(String key, long value) {
|
||||||
|
props.setProperty(prefix + key, Long.toString(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeDouble(String key, double value) {
|
||||||
|
props.setProperty(prefix + key, Double.toString(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeBoolean(String key, boolean value) {
|
||||||
|
props.setProperty(prefix + key, Boolean.toString(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.livingworld.modules.ecosystem;
|
||||||
|
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.events.LivingWorldEvent;
|
||||||
|
import com.livingworld.modules.ModuleContext;
|
||||||
|
import com.livingworld.modules.ModuleMetadata;
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.SimulationModule;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integrates all ecosystem signals into a composite health score and manages
|
||||||
|
* long-term stress and resilience dynamics.
|
||||||
|
*
|
||||||
|
* <p>This module runs <em>last</em> in the ecosystem update order, after
|
||||||
|
* {@link com.livingworld.modules.pollution.PollutionModule},
|
||||||
|
* {@link com.livingworld.modules.soil.SoilModule}, and
|
||||||
|
* {@link com.livingworld.modules.vegetation.VegetationModule}, so it reads
|
||||||
|
* fully updated current-tick values for all metrics.
|
||||||
|
*
|
||||||
|
* <h3>Ecosystem health formula</h3>
|
||||||
|
* <pre>
|
||||||
|
* health = soilQuality * 0.30
|
||||||
|
* + waterQuality * 0.20
|
||||||
|
* + (100 - pollutionScore) * 0.30
|
||||||
|
* + vegetationPressure * 0.20
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <h3>Stress model</h3>
|
||||||
|
* Danger zones are defined for each metric. Each metric in its danger zone
|
||||||
|
* contributes 2.0 stress per tick. Stress decays by 0.5 per tick when no
|
||||||
|
* dangers are active.
|
||||||
|
*
|
||||||
|
* <h3>Resilience and recovery</h3>
|
||||||
|
* Resilience is a slow-moving trend indicator. It increases when the ecosystem
|
||||||
|
* is healthy and decreases under prolonged stress. Recovery rate is derived
|
||||||
|
* from resilience and current stress level.
|
||||||
|
*/
|
||||||
|
public final class EcosystemModule implements SimulationModule {
|
||||||
|
|
||||||
|
public static final String MODULE_ID = "ecosystem";
|
||||||
|
|
||||||
|
// Weights for the composite health score (must sum to 1.0).
|
||||||
|
private static final double WEIGHT_SOIL = 0.30;
|
||||||
|
private static final double WEIGHT_WATER = 0.20;
|
||||||
|
private static final double WEIGHT_POLLUTION = 0.30;
|
||||||
|
private static final double WEIGHT_VEGETATION = 0.20;
|
||||||
|
|
||||||
|
// Danger zone thresholds (values outside these ranges add stress).
|
||||||
|
private static final double DANGER_SOIL_LOW = 20.0;
|
||||||
|
private static final double DANGER_POLLUTION_HIGH = 70.0;
|
||||||
|
private static final double DANGER_VEGETATION_LOW = 10.0;
|
||||||
|
private static final double DANGER_WATER_LOW = 20.0;
|
||||||
|
|
||||||
|
private static final double STRESS_PER_DANGER = 2.0;
|
||||||
|
private static final double STRESS_DECAY_PER_TICK = 0.5;
|
||||||
|
private static final double RESILIENCE_GROWTH_RATE = 0.02;
|
||||||
|
private static final double RESILIENCE_DRAIN_RATE = 0.01;
|
||||||
|
private static final double CHANGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"Ecosystem",
|
||||||
|
"1.0.0",
|
||||||
|
"Computes composite ecosystem health and manages long-term stress and resilience.",
|
||||||
|
"1",
|
||||||
|
List.of("pollution", "soil", "vegetation"),
|
||||||
|
List.of(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||||
|
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||||
|
region.getModuleData().put(MODULE_ID, EcosystemRegionData.defaults());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
EcosystemRegionData data = region.getModuleData()
|
||||||
|
.get(MODULE_ID, EcosystemRegionData.class)
|
||||||
|
.orElseGet(EcosystemRegionData::defaults);
|
||||||
|
RegionMetrics metrics = region.getMetrics();
|
||||||
|
|
||||||
|
double prevHealth = metrics.getEcosystemHealth();
|
||||||
|
double prevRecoveryPressure = metrics.getRecoveryPressure();
|
||||||
|
|
||||||
|
// --- Composite health ---
|
||||||
|
double health = metrics.getSoilQuality() * WEIGHT_SOIL
|
||||||
|
+ metrics.getWaterQuality() * WEIGHT_WATER
|
||||||
|
+ (100.0 - metrics.getPollutionScore()) * WEIGHT_POLLUTION
|
||||||
|
+ metrics.getVegetationPressure() * WEIGHT_VEGETATION;
|
||||||
|
health = Math.max(0.0, Math.min(100.0, health));
|
||||||
|
|
||||||
|
// --- Stress accumulation ---
|
||||||
|
int dangerCount = 0;
|
||||||
|
if (metrics.getSoilQuality() < DANGER_SOIL_LOW) dangerCount++;
|
||||||
|
if (metrics.getPollutionScore() > DANGER_POLLUTION_HIGH) dangerCount++;
|
||||||
|
if (metrics.getVegetationPressure() < DANGER_VEGETATION_LOW) dangerCount++;
|
||||||
|
if (metrics.getWaterQuality() < DANGER_WATER_LOW) dangerCount++;
|
||||||
|
|
||||||
|
double newStress;
|
||||||
|
if (dangerCount > 0) {
|
||||||
|
newStress = data.getStress() + dangerCount * STRESS_PER_DANGER;
|
||||||
|
} else {
|
||||||
|
newStress = Math.max(0.0, data.getStress() - STRESS_DECAY_PER_TICK);
|
||||||
|
}
|
||||||
|
data.setStress(newStress);
|
||||||
|
|
||||||
|
// --- Resilience trend ---
|
||||||
|
if (health > 60.0) {
|
||||||
|
data.setResilience(data.getResilience() + RESILIENCE_GROWTH_RATE);
|
||||||
|
} else {
|
||||||
|
data.setResilience(data.getResilience() - RESILIENCE_DRAIN_RATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Recovery rate: high resilience and low stress produce fast recovery ---
|
||||||
|
double recoveryRate = data.getResilience() * (1.0 - data.getStress() / 100.0) * 0.10 + 1.0;
|
||||||
|
data.setRecoveryRate(Math.min(100.0, recoveryRate));
|
||||||
|
|
||||||
|
data.setEcosystemHealth(health);
|
||||||
|
|
||||||
|
// --- Summary metrics ---
|
||||||
|
metrics.setEcosystemHealth(health);
|
||||||
|
// Recovery pressure is high when the ecosystem is suffering and needs attention.
|
||||||
|
double recoveryPressure = Math.max(0.0, (100.0 - health) + data.getStress() * 0.30);
|
||||||
|
metrics.setRecoveryPressure(Math.min(100.0, recoveryPressure));
|
||||||
|
|
||||||
|
region.getModuleData().put(MODULE_ID, data);
|
||||||
|
|
||||||
|
boolean changed =
|
||||||
|
Math.abs(metrics.getEcosystemHealth() - prevHealth) > CHANGE_THRESHOLD
|
||||||
|
|| Math.abs(metrics.getRecoveryPressure() - prevRecoveryPressure) > CHANGE_THRESHOLD;
|
||||||
|
|
||||||
|
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.livingworld.modules.ecosystem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-region ecosystem summary state tracked by {@link EcosystemModule}.
|
||||||
|
*
|
||||||
|
* <p>These values integrate signals from all other ecosystem modules to represent
|
||||||
|
* the overall ecological condition of a region. All values are clamped to [0, 100].
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>ecosystemHealth</b> – weighted composite of soil, water, pollution and vegetation
|
||||||
|
* <li><b>stress</b> – accumulated ecological stress; rises when multiple metrics
|
||||||
|
* are in danger zones, recovers slowly in good conditions
|
||||||
|
* <li><b>resilience</b> – long-term stability; high resilience means the region
|
||||||
|
* resists and recovers from damage more quickly
|
||||||
|
* <li><b>recoveryRate</b> – current rate of ecological self-repair per tick
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class EcosystemRegionData {
|
||||||
|
|
||||||
|
private static final double MIN = 0.0;
|
||||||
|
private static final double MAX = 100.0;
|
||||||
|
|
||||||
|
private double ecosystemHealth;
|
||||||
|
private double stress;
|
||||||
|
private double resilience;
|
||||||
|
private double recoveryRate;
|
||||||
|
|
||||||
|
public EcosystemRegionData(
|
||||||
|
double ecosystemHealth,
|
||||||
|
double stress,
|
||||||
|
double resilience,
|
||||||
|
double recoveryRate) {
|
||||||
|
this.ecosystemHealth = clamp(ecosystemHealth);
|
||||||
|
this.stress = clamp(stress);
|
||||||
|
this.resilience = clamp(resilience);
|
||||||
|
this.recoveryRate = clamp(recoveryRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a default moderate-health ecosystem state. */
|
||||||
|
public static EcosystemRegionData defaults() {
|
||||||
|
return new EcosystemRegionData(60.0, 20.0, 50.0, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public double getEcosystemHealth() { return ecosystemHealth; }
|
||||||
|
public double getStress() { return stress; }
|
||||||
|
public double getResilience() { return resilience; }
|
||||||
|
public double getRecoveryRate() { return recoveryRate; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Mutation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an ecological stress event of the given magnitude.
|
||||||
|
*
|
||||||
|
* <p>Stress accumulates and slowly erodes resilience.
|
||||||
|
*
|
||||||
|
* @param amount positive stress magnitude
|
||||||
|
*/
|
||||||
|
public void applyStress(double amount) {
|
||||||
|
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||||
|
stress = clamp(stress + amount);
|
||||||
|
resilience = clamp(resilience - amount * 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an ecological recovery event of the given magnitude.
|
||||||
|
*
|
||||||
|
* <p>Reduces stress and gradually rebuilds resilience.
|
||||||
|
*
|
||||||
|
* @param amount positive recovery magnitude
|
||||||
|
*/
|
||||||
|
public void applyRecovery(double amount) {
|
||||||
|
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||||
|
stress = clamp(stress - amount);
|
||||||
|
resilience = clamp(resilience + amount * 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEcosystemHealth(double v) { ecosystemHealth = clamp(v); }
|
||||||
|
public void setStress(double v) { stress = clamp(v); }
|
||||||
|
public void setResilience(double v) { resilience = clamp(v); }
|
||||||
|
public void setRecoveryRate(double v) { recoveryRate = clamp(v); }
|
||||||
|
|
||||||
|
/** Clamps all fields to [0, 100]. */
|
||||||
|
public void normalize() {
|
||||||
|
ecosystemHealth = clamp(ecosystemHealth);
|
||||||
|
stress = clamp(stress);
|
||||||
|
resilience = clamp(resilience);
|
||||||
|
recoveryRate = clamp(recoveryRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an independent copy. */
|
||||||
|
public EcosystemRegionData copy() {
|
||||||
|
return new EcosystemRegionData(ecosystemHealth, stress, resilience, recoveryRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static double clamp(double v) {
|
||||||
|
return Math.min(MAX, Math.max(MIN, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "EcosystemRegionData{"
|
||||||
|
+ "health=" + ecosystemHealth
|
||||||
|
+ ", stress=" + stress
|
||||||
|
+ ", resilience=" + resilience
|
||||||
|
+ ", recoveryRate=" + recoveryRate + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.livingworld.modules.pollution;
|
||||||
|
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.events.LivingWorldEvent;
|
||||||
|
import com.livingworld.modules.ModuleContext;
|
||||||
|
import com.livingworld.modules.ModuleMetadata;
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.SimulationModule;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates natural pollution decay and computes the summary pollution metrics
|
||||||
|
* written to {@link RegionMetrics}.
|
||||||
|
*
|
||||||
|
* <p>This module should run first in the ecosystem update order so that
|
||||||
|
* {@link com.livingworld.modules.soil.SoilModule} and
|
||||||
|
* {@link com.livingworld.modules.vegetation.VegetationModule} see current-tick
|
||||||
|
* pollution values when they execute.
|
||||||
|
*
|
||||||
|
* <h3>Per-tick rules</h3>
|
||||||
|
* <ol>
|
||||||
|
* <li>Air, ground, and water pollution each decay at different rates.
|
||||||
|
* <li>Ground pollution slowly leaches into water.
|
||||||
|
* <li>{@link RegionMetrics#getPollutionScore()} is recomputed as a weighted average.
|
||||||
|
* <li>{@link RegionMetrics#getWaterQuality()} is reduced proportionally to water pollution.
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <h3>Configurable constants</h3>
|
||||||
|
* These are intentionally simple for V1 and expected to be tuned.
|
||||||
|
* <ul>
|
||||||
|
* <li>BASE_DECAY_RATE – fraction of pollution removed per tick before resistance
|
||||||
|
* <li>GROUND_TO_WATER_LEACH – fraction of groundPollution that leaches to water each tick
|
||||||
|
* <li>WATER_QUALITY_IMPACT – fraction by which water pollution degrades waterQuality
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class PollutionModule implements SimulationModule {
|
||||||
|
|
||||||
|
public static final String MODULE_ID = "pollution";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"Pollution",
|
||||||
|
"1.0.0",
|
||||||
|
"Simulates pollution decay and spread across regions.",
|
||||||
|
"1",
|
||||||
|
List.of(),
|
||||||
|
List.of(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||||
|
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||||
|
region.getModuleData().put(MODULE_ID, PollutionRegionData.defaults());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
PollutionRegionData data = region.getModuleData()
|
||||||
|
.get(MODULE_ID, PollutionRegionData.class)
|
||||||
|
.orElseGet(PollutionRegionData::defaults);
|
||||||
|
|
||||||
|
double prevPollutionScore = region.getMetrics().getPollutionScore();
|
||||||
|
double prevWaterQuality = region.getMetrics().getWaterQuality();
|
||||||
|
|
||||||
|
// Natural decay: air decays fastest, water slowest (modulated by resistance).
|
||||||
|
data.decay(BASE_DECAY_RATE);
|
||||||
|
|
||||||
|
// Ground pollution slowly leaches into water even after decay.
|
||||||
|
double leach = data.getGroundPollution() * GROUND_TO_WATER_LEACH;
|
||||||
|
data.addPollution(0.0, 0.0, leach);
|
||||||
|
|
||||||
|
// Summary metric: weighted average emphasising waterPollution as most damaging.
|
||||||
|
double pollutionScore = (data.getAirPollution() * 0.40
|
||||||
|
+ data.getGroundPollution() * 0.35
|
||||||
|
+ data.getWaterPollution() * 0.25);
|
||||||
|
region.getMetrics().setPollutionScore(pollutionScore);
|
||||||
|
|
||||||
|
// Water quality degrades proportionally to water pollution this tick.
|
||||||
|
double waterQualityDrop = data.getWaterPollution() * WATER_QUALITY_IMPACT;
|
||||||
|
region.getMetrics().setWaterQuality(
|
||||||
|
Math.max(0.0, region.getMetrics().getWaterQuality() - waterQualityDrop));
|
||||||
|
|
||||||
|
// Persist updated data.
|
||||||
|
region.getModuleData().put(MODULE_ID, data);
|
||||||
|
|
||||||
|
boolean changed =
|
||||||
|
Math.abs(region.getMetrics().getPollutionScore() - prevPollutionScore) > CHANGE_THRESHOLD
|
||||||
|
|| Math.abs(region.getMetrics().getWaterQuality() - prevWaterQuality) > CHANGE_THRESHOLD;
|
||||||
|
|
||||||
|
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.livingworld.modules.pollution;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-region pollution state tracked by {@link PollutionModule}.
|
||||||
|
*
|
||||||
|
* <p>Stores three independent pollution layers (air, ground, water) and a decay
|
||||||
|
* resistance score. All values are clamped to [0, 100].
|
||||||
|
*
|
||||||
|
* <p>Decay resistance slows natural recovery. A brand-new region has zero
|
||||||
|
* pollution and low decay resistance, meaning pollution introduced there will
|
||||||
|
* dissipate quickly.
|
||||||
|
*/
|
||||||
|
public final class PollutionRegionData {
|
||||||
|
|
||||||
|
private static final double MIN = 0.0;
|
||||||
|
private static final double MAX = 100.0;
|
||||||
|
|
||||||
|
private double airPollution;
|
||||||
|
private double groundPollution;
|
||||||
|
private double waterPollution;
|
||||||
|
private double decayResistance;
|
||||||
|
|
||||||
|
public PollutionRegionData(
|
||||||
|
double airPollution,
|
||||||
|
double groundPollution,
|
||||||
|
double waterPollution,
|
||||||
|
double decayResistance) {
|
||||||
|
this.airPollution = clamp(airPollution);
|
||||||
|
this.groundPollution = clamp(groundPollution);
|
||||||
|
this.waterPollution = clamp(waterPollution);
|
||||||
|
this.decayResistance = clamp(decayResistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a clean region with low decay resistance. */
|
||||||
|
public static PollutionRegionData defaults() {
|
||||||
|
return new PollutionRegionData(0.0, 0.0, 0.0, 20.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public double getAirPollution() { return airPollution; }
|
||||||
|
public double getGroundPollution() { return groundPollution; }
|
||||||
|
public double getWaterPollution() { return waterPollution; }
|
||||||
|
public double getDecayResistance() { return decayResistance; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Mutation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Adds pollution to each layer; values are clamped after addition. */
|
||||||
|
public void addPollution(double air, double ground, double water) {
|
||||||
|
airPollution = clamp(airPollution + air);
|
||||||
|
groundPollution = clamp(groundPollution + ground);
|
||||||
|
waterPollution = clamp(waterPollution + water);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a single natural-decay tick.
|
||||||
|
*
|
||||||
|
* <p>{@code baseRate} is the fraction removed per tick before resistance is
|
||||||
|
* applied (e.g. 0.02 = 2 %). Decay resistance reduces the effective rate:
|
||||||
|
* effective = baseRate * (1 - decayResistance / 200).
|
||||||
|
*
|
||||||
|
* @param baseRate fraction to decay per tick (0–1)
|
||||||
|
*/
|
||||||
|
public void decay(double baseRate) {
|
||||||
|
double resistanceFactor = 1.0 - (decayResistance / 200.0);
|
||||||
|
double effectiveRate = Math.max(0.0, baseRate * resistanceFactor);
|
||||||
|
airPollution = clamp(airPollution * (1.0 - effectiveRate * 2.0));
|
||||||
|
groundPollution = clamp(groundPollution * (1.0 - effectiveRate * 0.5));
|
||||||
|
waterPollution = clamp(waterPollution * (1.0 - effectiveRate * 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamps all fields to [0, 100]. */
|
||||||
|
public void normalize() {
|
||||||
|
airPollution = clamp(airPollution);
|
||||||
|
groundPollution = clamp(groundPollution);
|
||||||
|
waterPollution = clamp(waterPollution);
|
||||||
|
decayResistance = clamp(decayResistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an independent copy. */
|
||||||
|
public PollutionRegionData copy() {
|
||||||
|
return new PollutionRegionData(airPollution, groundPollution, waterPollution, decayResistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static double clamp(double v) {
|
||||||
|
return Math.min(MAX, Math.max(MIN, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "PollutionRegionData{"
|
||||||
|
+ "air=" + airPollution
|
||||||
|
+ ", ground=" + groundPollution
|
||||||
|
+ ", water=" + waterPollution
|
||||||
|
+ ", decayResistance=" + decayResistance + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.livingworld.modules.recovery;
|
||||||
|
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.events.LivingWorldEvent;
|
||||||
|
import com.livingworld.modules.ModuleContext;
|
||||||
|
import com.livingworld.modules.ModuleMetadata;
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.SimulationModule;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages ecological succession: the gradual progression of a region from bare
|
||||||
|
* ground through grassland, scrubland, and young woodland to mature forest —
|
||||||
|
* and the regression back toward bare ground when conditions deteriorate.
|
||||||
|
*
|
||||||
|
* <p>This module runs near the end of the pipeline (after pollution, soil,
|
||||||
|
* water, vegetation, and resources have updated) so that succession decisions
|
||||||
|
* are made against fully current metrics.
|
||||||
|
*
|
||||||
|
* <h3>Advancement</h3>
|
||||||
|
* Each tick that metrics meet a stage's thresholds, {@code recoveryProgress}
|
||||||
|
* increases by the ecosystem's {@code recoveryPressure}-derived rate. When
|
||||||
|
* it reaches 100, the region advances one succession stage.
|
||||||
|
*
|
||||||
|
* <h3>Regression</h3>
|
||||||
|
* Each tick that metrics fall badly below the current stage's minimums,
|
||||||
|
* {@code damageAccumulation} increases. At 70 accumulated damage the region
|
||||||
|
* regresses one stage.
|
||||||
|
*
|
||||||
|
* <h3>Recovery pressure metric</h3>
|
||||||
|
* The module modifies {@link RegionMetrics#getRecoveryPressure()} to reflect
|
||||||
|
* how far the region is from mature forest (EcosystemModule also writes this;
|
||||||
|
* this module's write takes precedence because it runs later).
|
||||||
|
*/
|
||||||
|
public final class RecoveryModule implements SimulationModule {
|
||||||
|
|
||||||
|
public static final String MODULE_ID = "recovery";
|
||||||
|
|
||||||
|
/** Base recovery progress per tick (added when conditions are met). */
|
||||||
|
private static final double BASE_PROGRESS_PER_TICK = 0.5;
|
||||||
|
/** Extra recovery progress per point of ecosystemHealth above 50. */
|
||||||
|
private static final double HEALTH_PROGRESS_BONUS = 0.02;
|
||||||
|
/** Damage accumulated per tick when conditions are badly violated. */
|
||||||
|
private static final double DAMAGE_PER_BAD_TICK = 3.0;
|
||||||
|
/** Damage decays this fraction per tick when conditions are OK. */
|
||||||
|
private static final double DAMAGE_DECAY_RATE = 0.05;
|
||||||
|
private static final double CHANGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"Recovery & Succession",
|
||||||
|
"1.0.0",
|
||||||
|
"Manages ecological succession stages from barren ground to mature forest.",
|
||||||
|
"1",
|
||||||
|
List.of("pollution", "soil", "vegetation"),
|
||||||
|
List.of("ecosystem"),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||||
|
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||||
|
region.getModuleData().put(MODULE_ID, RecoveryRegionData.defaults());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
RecoveryRegionData data = region.getModuleData()
|
||||||
|
.get(MODULE_ID, RecoveryRegionData.class)
|
||||||
|
.orElseGet(RecoveryRegionData::defaults);
|
||||||
|
RegionMetrics metrics = region.getMetrics();
|
||||||
|
|
||||||
|
SuccessionStage stageBefore = data.getSuccessionStage();
|
||||||
|
double progressBefore = data.getRecoveryProgress();
|
||||||
|
|
||||||
|
double soil = metrics.getSoilQuality();
|
||||||
|
double poll = metrics.getPollutionScore();
|
||||||
|
double veg = metrics.getVegetationPressure();
|
||||||
|
|
||||||
|
if (data.getSuccessionStage().conditionsMetForAdvancement(soil, poll, veg)) {
|
||||||
|
// Conditions are good: advance recovery progress.
|
||||||
|
double progressGain = BASE_PROGRESS_PER_TICK;
|
||||||
|
if (metrics.getEcosystemHealth() > 50.0) {
|
||||||
|
progressGain += (metrics.getEcosystemHealth() - 50.0) * HEALTH_PROGRESS_BONUS;
|
||||||
|
}
|
||||||
|
data.advanceProgress(progressGain, soil, poll, veg);
|
||||||
|
|
||||||
|
// Damage decays passively when conditions are good (bypass accumulateDamage
|
||||||
|
// to avoid triggering regression with a zero-damage call that might fire at 70+).
|
||||||
|
data = new RecoveryRegionData(
|
||||||
|
data.getSuccessionStage(),
|
||||||
|
data.getRecoveryProgress(),
|
||||||
|
Math.max(0.0, data.getDamageAccumulation() * (1.0 - DAMAGE_DECAY_RATE)));
|
||||||
|
|
||||||
|
} else if (data.getSuccessionStage().conditionsMissedForRegression(soil, poll, veg)) {
|
||||||
|
// Conditions are bad: accumulate damage toward regression.
|
||||||
|
data.accumulateDamage(DAMAGE_PER_BAD_TICK, soil, poll, veg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovery pressure reflects distance from mature forest.
|
||||||
|
int stagesFromPeak = SuccessionStage.MATURE_FOREST.ordinal()
|
||||||
|
- data.getSuccessionStage().ordinal();
|
||||||
|
double recoveryPressure = Math.min(100.0, stagesFromPeak * 20.0
|
||||||
|
+ data.getDamageAccumulation() * 0.30);
|
||||||
|
metrics.setRecoveryPressure(recoveryPressure);
|
||||||
|
|
||||||
|
region.getModuleData().put(MODULE_ID, data);
|
||||||
|
|
||||||
|
boolean stageChanged = data.getSuccessionStage() != stageBefore;
|
||||||
|
boolean progressChanged = Math.abs(data.getRecoveryProgress() - progressBefore)
|
||||||
|
> CHANGE_THRESHOLD;
|
||||||
|
return (stageChanged || progressChanged)
|
||||||
|
? ModuleUpdateResult.changed()
|
||||||
|
: ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.livingworld.modules.recovery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-region ecological recovery state tracked by {@link RecoveryModule}.
|
||||||
|
*
|
||||||
|
* <p>Tracks where a region sits in the ecological succession sequence and how much
|
||||||
|
* progress it has made toward the next stage.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>successionStage</b> – current ecological stage (see {@link SuccessionStage})
|
||||||
|
* <li><b>recoveryProgress</b> – progress (0–100) toward advancing to the next stage
|
||||||
|
* <li><b>damageAccumulation</b> – accumulated ecological damage (0–100); when this
|
||||||
|
* exceeds a threshold the region regresses to the previous stage
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class RecoveryRegionData {
|
||||||
|
|
||||||
|
private static final double MIN = 0.0;
|
||||||
|
private static final double MAX = 100.0;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
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. */
|
||||||
|
public static RecoveryRegionData defaults() {
|
||||||
|
return new RecoveryRegionData(SuccessionStage.GRASSLAND, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
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
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances recovery progress. If progress reaches 100 and conditions allow,
|
||||||
|
* the stage advances and progress resets.
|
||||||
|
*
|
||||||
|
* @param amount positive progress to add
|
||||||
|
* @param soilQuality current soilQuality metric
|
||||||
|
* @param pollutionScore current pollutionScore metric
|
||||||
|
* @param vegetationPressure current vegetationPressure metric
|
||||||
|
*/
|
||||||
|
public void advanceProgress(double amount,
|
||||||
|
double soilQuality,
|
||||||
|
double pollutionScore,
|
||||||
|
double vegetationPressure) {
|
||||||
|
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;
|
||||||
|
damageAccumulation = clamp(damageAccumulation - 10.0); // partial healing on advance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accumulates ecological damage. If damage exceeds 70, the region regresses
|
||||||
|
* one succession stage and damage resets partially.
|
||||||
|
*
|
||||||
|
* @param amount positive damage to accumulate
|
||||||
|
* @param soilQuality current soilQuality metric
|
||||||
|
* @param pollutionScore current pollutionScore metric
|
||||||
|
* @param vegetationPressure current vegetationPressure metric
|
||||||
|
*/
|
||||||
|
public void accumulateDamage(double amount,
|
||||||
|
double soilQuality,
|
||||||
|
double pollutionScore,
|
||||||
|
double vegetationPressure) {
|
||||||
|
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||||
|
damageAccumulation = clamp(damageAccumulation + amount);
|
||||||
|
if (damageAccumulation >= 70.0
|
||||||
|
&& successionStage.conditionsMissedForRegression(soilQuality, pollutionScore, vegetationPressure)) {
|
||||||
|
successionStage = successionStage.prev();
|
||||||
|
damageAccumulation = 30.0; // partial reset so regression isn't instantaneous
|
||||||
|
recoveryProgress = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamps all numeric fields to [0, 100]. */
|
||||||
|
public void normalize() {
|
||||||
|
recoveryProgress = clamp(recoveryProgress);
|
||||||
|
damageAccumulation = clamp(damageAccumulation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an independent copy. */
|
||||||
|
public RecoveryRegionData copy() {
|
||||||
|
return new RecoveryRegionData(successionStage, recoveryProgress, damageAccumulation, maxSuccessionStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static double clamp(double v) {
|
||||||
|
return Math.min(MAX, Math.max(MIN, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "RecoveryRegionData{"
|
||||||
|
+ "stage=" + successionStage
|
||||||
|
+ ", progress=" + recoveryProgress
|
||||||
|
+ ", damage=" + damageAccumulation
|
||||||
|
+ ", maxStage=" + maxSuccessionStage + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.livingworld.modules.recovery;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ecological succession stages that a region can progress through or regress from.
|
||||||
|
*
|
||||||
|
* <p>Stages are ordered from most degraded ({@link #BARREN}) to most developed
|
||||||
|
* ({@link #MATURE_FOREST}). Advancement requires conditions to meet each stage's
|
||||||
|
* thresholds; sustained damage can cause regression.
|
||||||
|
*/
|
||||||
|
public enum SuccessionStage {
|
||||||
|
|
||||||
|
/** No significant vegetation; exposed or contaminated soil. */
|
||||||
|
BARREN(
|
||||||
|
/* minSoilQuality */ 0.0,
|
||||||
|
/* maxPollutionScore */ 100.0,
|
||||||
|
/* minVegetationPressure */ 0.0),
|
||||||
|
|
||||||
|
/** Patchy grass cover starting to establish. */
|
||||||
|
SPARSE_GRASS(
|
||||||
|
10.0,
|
||||||
|
80.0,
|
||||||
|
10.0),
|
||||||
|
|
||||||
|
/** Continuous grass cover with scattered herbs. */
|
||||||
|
GRASSLAND(
|
||||||
|
25.0,
|
||||||
|
70.0,
|
||||||
|
20.0),
|
||||||
|
|
||||||
|
/** Shrubs and mixed vegetation; beginning of structural complexity. */
|
||||||
|
SCRUBLAND(
|
||||||
|
40.0,
|
||||||
|
60.0,
|
||||||
|
35.0),
|
||||||
|
|
||||||
|
/** Young trees intermixed with shrubs; canopy forming. */
|
||||||
|
YOUNG_WOODLAND(
|
||||||
|
50.0,
|
||||||
|
50.0,
|
||||||
|
45.0),
|
||||||
|
|
||||||
|
/** Established tree canopy; full ecological complexity. */
|
||||||
|
MATURE_FOREST(
|
||||||
|
60.0,
|
||||||
|
40.0,
|
||||||
|
55.0);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Stage thresholds
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Minimum soilQuality metric required to enter (and stay in) this stage. */
|
||||||
|
public final double minSoilQuality;
|
||||||
|
|
||||||
|
/** Maximum pollutionScore metric allowed to enter (and stay in) this stage. */
|
||||||
|
public final double maxPollutionScore;
|
||||||
|
|
||||||
|
/** Minimum vegetationPressure metric required to enter (and stay in) this stage. */
|
||||||
|
public final double minVegetationPressure;
|
||||||
|
|
||||||
|
SuccessionStage(double minSoilQuality, double maxPollutionScore, double minVegetationPressure) {
|
||||||
|
this.minSoilQuality = minSoilQuality;
|
||||||
|
this.maxPollutionScore = maxPollutionScore;
|
||||||
|
this.minVegetationPressure = minVegetationPressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Navigation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns {@code true} if this stage is not {@link #MATURE_FOREST}. */
|
||||||
|
public boolean hasNext() {
|
||||||
|
return ordinal() < MATURE_FOREST.ordinal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns {@code true} if this stage is not {@link #BARREN}. */
|
||||||
|
public boolean hasPrev() {
|
||||||
|
return ordinal() > BARREN.ordinal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the next stage, or this stage if already {@link #MATURE_FOREST}. */
|
||||||
|
public SuccessionStage next() {
|
||||||
|
return hasNext() ? values()[ordinal() + 1] : this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the previous stage, or this stage if already {@link #BARREN}. */
|
||||||
|
public SuccessionStage prev() {
|
||||||
|
return hasPrev() ? values()[ordinal() - 1] : this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Condition checks (based on RegionMetrics)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when current ecosystem metrics are good enough for this
|
||||||
|
* region to <em>advance</em> to the next succession stage.
|
||||||
|
*/
|
||||||
|
public boolean conditionsMetForAdvancement(
|
||||||
|
double soilQuality, double pollutionScore, double vegetationPressure) {
|
||||||
|
if (!hasNext()) return false;
|
||||||
|
SuccessionStage target = next();
|
||||||
|
return soilQuality >= target.minSoilQuality
|
||||||
|
&& pollutionScore <= target.maxPollutionScore
|
||||||
|
&& vegetationPressure >= target.minVegetationPressure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns {@code true} when current metrics have deteriorated enough to
|
||||||
|
* <em>regress</em> to the previous succession stage.
|
||||||
|
*
|
||||||
|
* <p>Regression threshold is set tighter than advancement (conditions must be
|
||||||
|
* significantly worse than the current stage's minimums).
|
||||||
|
*/
|
||||||
|
public boolean conditionsMissedForRegression(
|
||||||
|
double soilQuality, double pollutionScore, double vegetationPressure) {
|
||||||
|
if (!hasPrev()) return false;
|
||||||
|
return soilQuality < minSoilQuality * 0.5
|
||||||
|
|| pollutionScore > maxPollutionScore * 1.2
|
||||||
|
|| vegetationPressure < minVegetationPressure * 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.livingworld.modules.resources;
|
||||||
|
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.events.LivingWorldEvent;
|
||||||
|
import com.livingworld.modules.ModuleContext;
|
||||||
|
import com.livingworld.modules.ModuleMetadata;
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.SimulationModule;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks how intensively each resource type has been harvested and models
|
||||||
|
* natural regeneration, writing {@link com.livingworld.regions.RegionMetrics#getResourceDepletion()}.
|
||||||
|
*
|
||||||
|
* <p>Depletion events (mining, logging, farming) are normally recorded by the
|
||||||
|
* NeoForge platform adapter when players interact with the world. This module
|
||||||
|
* handles the autonomous decay (regeneration) side.
|
||||||
|
*
|
||||||
|
* <h3>Regeneration rates (per tick)</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li><b>mining</b> – 0.01 % (geological timescale; very slow)
|
||||||
|
* <li><b>logging</b> – 0.5 % base + bonus when vegetationPressure is high
|
||||||
|
* <li><b>farming</b> – 0.3 % base + bonus when soilQuality is high
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Ecosystem feedback</h3>
|
||||||
|
* High logging depletion suppresses tree growth (read by VegetationModule via
|
||||||
|
* {@link com.livingworld.regions.RegionMetrics#getResourceDepletion()}).
|
||||||
|
*/
|
||||||
|
public final class ResourceDepletionModule implements SimulationModule {
|
||||||
|
|
||||||
|
public static final String MODULE_ID = "resources";
|
||||||
|
|
||||||
|
private static final double MINING_REGEN_RATE = 0.0001;
|
||||||
|
private static final double LOGGING_BASE_REGEN = 0.005;
|
||||||
|
private static final double LOGGING_VEG_BONUS_RATE = 0.005;
|
||||||
|
private static final double LOGGING_VEG_THRESHOLD = 50.0;
|
||||||
|
private static final double FARMING_BASE_REGEN = 0.003;
|
||||||
|
private static final double FARMING_SOIL_BONUS_RATE = 0.003;
|
||||||
|
private static final double FARMING_SOIL_THRESHOLD = 50.0;
|
||||||
|
private static final double CHANGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"Resource Depletion",
|
||||||
|
"1.0.0",
|
||||||
|
"Tracks resource exhaustion and natural regeneration.",
|
||||||
|
"1",
|
||||||
|
List.of(),
|
||||||
|
List.of("soil", "vegetation"),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||||
|
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||||
|
region.getModuleData().put(MODULE_ID, ResourceRegionData.defaults());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
ResourceRegionData data = region.getModuleData()
|
||||||
|
.get(MODULE_ID, ResourceRegionData.class)
|
||||||
|
.orElseGet(ResourceRegionData::defaults);
|
||||||
|
|
||||||
|
double prevTotal = data.totalDepletion();
|
||||||
|
|
||||||
|
// Mining regenerates extremely slowly (geological timescale).
|
||||||
|
data.setMiningDepletion(data.getMiningDepletion() * (1.0 - MINING_REGEN_RATE));
|
||||||
|
|
||||||
|
// Logging regenerates faster when vegetation is recovering.
|
||||||
|
double loggingRegen = LOGGING_BASE_REGEN;
|
||||||
|
if (region.getMetrics().getVegetationPressure() > LOGGING_VEG_THRESHOLD) {
|
||||||
|
loggingRegen += (region.getMetrics().getVegetationPressure() - LOGGING_VEG_THRESHOLD)
|
||||||
|
* LOGGING_VEG_BONUS_RATE;
|
||||||
|
}
|
||||||
|
data.setLoggingDepletion(Math.max(0.0, data.getLoggingDepletion() - loggingRegen));
|
||||||
|
|
||||||
|
// Farming depletion recovers faster with good soil quality.
|
||||||
|
double farmingRegen = FARMING_BASE_REGEN;
|
||||||
|
if (region.getMetrics().getSoilQuality() > FARMING_SOIL_THRESHOLD) {
|
||||||
|
farmingRegen += (region.getMetrics().getSoilQuality() - FARMING_SOIL_THRESHOLD)
|
||||||
|
* FARMING_SOIL_BONUS_RATE;
|
||||||
|
}
|
||||||
|
data.setFarmingDepletion(Math.max(0.0, data.getFarmingDepletion() - farmingRegen));
|
||||||
|
|
||||||
|
// Write summary metric.
|
||||||
|
region.getMetrics().setResourceDepletion(data.totalDepletion());
|
||||||
|
|
||||||
|
region.getModuleData().put(MODULE_ID, data);
|
||||||
|
|
||||||
|
boolean changed = Math.abs(data.totalDepletion() - prevTotal) > CHANGE_THRESHOLD;
|
||||||
|
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.livingworld.modules.resources;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-region resource depletion state tracked by {@link ResourceDepletionModule}.
|
||||||
|
*
|
||||||
|
* <p>Three independent depletion layers model how intensively each type of
|
||||||
|
* resource harvesting has affected the region. All values are clamped to [0, 100],
|
||||||
|
* where 0 means untouched and 100 means completely exhausted.
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>miningDepletion</b> – how heavily the region's mineral resources have
|
||||||
|
* been extracted (very slow natural recovery)
|
||||||
|
* <li><b>loggingDepletion</b> – how heavily trees have been harvested (recovery
|
||||||
|
* is linked to vegetation succession)
|
||||||
|
* <li><b>farmingDepletion</b> – how heavily the land has been cultivated without
|
||||||
|
* rest (recovery driven by soil quality)
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Depletion events are recorded via {@link #recordMining}, {@link #recordLogging},
|
||||||
|
* and {@link #recordFarming}, which are intended to be called from platform-layer
|
||||||
|
* event handlers when players interact with the world.
|
||||||
|
*/
|
||||||
|
public final class ResourceRegionData {
|
||||||
|
|
||||||
|
private static final double MIN = 0.0;
|
||||||
|
private static final double MAX = 100.0;
|
||||||
|
|
||||||
|
private double miningDepletion;
|
||||||
|
private double loggingDepletion;
|
||||||
|
private double farmingDepletion;
|
||||||
|
|
||||||
|
public ResourceRegionData(double miningDepletion, double loggingDepletion, double farmingDepletion) {
|
||||||
|
this.miningDepletion = clamp(miningDepletion);
|
||||||
|
this.loggingDepletion = clamp(loggingDepletion);
|
||||||
|
this.farmingDepletion = clamp(farmingDepletion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a pristine, unextracted region. */
|
||||||
|
public static ResourceRegionData defaults() {
|
||||||
|
return new ResourceRegionData(0.0, 0.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public double getMiningDepletion() { return miningDepletion; }
|
||||||
|
public double getLoggingDepletion() { return loggingDepletion; }
|
||||||
|
public double getFarmingDepletion() { return farmingDepletion; }
|
||||||
|
|
||||||
|
/** Weighted total depletion score, matching the metric written to {@link com.livingworld.regions.RegionMetrics}. */
|
||||||
|
public double totalDepletion() {
|
||||||
|
return clamp(miningDepletion * 0.50 + loggingDepletion * 0.30 + farmingDepletion * 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Depletion events (called by platform event handlers)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a mining event of the given intensity.
|
||||||
|
*
|
||||||
|
* @param intensity depletion to add (0–100)
|
||||||
|
*/
|
||||||
|
public void recordMining(double intensity) {
|
||||||
|
if (intensity < 0) throw new IllegalArgumentException("intensity must be >= 0");
|
||||||
|
miningDepletion = clamp(miningDepletion + intensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a logging event of the given intensity.
|
||||||
|
*
|
||||||
|
* @param intensity depletion to add (0–100)
|
||||||
|
*/
|
||||||
|
public void recordLogging(double intensity) {
|
||||||
|
if (intensity < 0) throw new IllegalArgumentException("intensity must be >= 0");
|
||||||
|
loggingDepletion = clamp(loggingDepletion + intensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a farming event of the given intensity.
|
||||||
|
*
|
||||||
|
* @param intensity depletion to add (0–100)
|
||||||
|
*/
|
||||||
|
public void recordFarming(double intensity) {
|
||||||
|
if (intensity < 0) throw new IllegalArgumentException("intensity must be >= 0");
|
||||||
|
farmingDepletion = clamp(farmingDepletion + intensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Internal setters (used by the module's regeneration logic)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public void setMiningDepletion(double v) { miningDepletion = clamp(v); }
|
||||||
|
public void setLoggingDepletion(double v) { loggingDepletion = clamp(v); }
|
||||||
|
public void setFarmingDepletion(double v) { farmingDepletion = clamp(v); }
|
||||||
|
|
||||||
|
/** Clamps all fields to [0, 100]. */
|
||||||
|
public void normalize() {
|
||||||
|
miningDepletion = clamp(miningDepletion);
|
||||||
|
loggingDepletion = clamp(loggingDepletion);
|
||||||
|
farmingDepletion = clamp(farmingDepletion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an independent copy. */
|
||||||
|
public ResourceRegionData copy() {
|
||||||
|
return new ResourceRegionData(miningDepletion, loggingDepletion, farmingDepletion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static double clamp(double v) {
|
||||||
|
return Math.min(MAX, Math.max(MIN, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ResourceRegionData{"
|
||||||
|
+ "mining=" + miningDepletion
|
||||||
|
+ ", logging=" + loggingDepletion
|
||||||
|
+ ", farming=" + farmingDepletion + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.livingworld.modules.soil;
|
||||||
|
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.events.LivingWorldEvent;
|
||||||
|
import com.livingworld.modules.ModuleContext;
|
||||||
|
import com.livingworld.modules.ModuleMetadata;
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.SimulationModule;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates soil health dynamics and writes {@link RegionMetrics#getSoilQuality()}.
|
||||||
|
*
|
||||||
|
* <p>This module runs after {@link com.livingworld.modules.pollution.PollutionModule}
|
||||||
|
* so it reads the current-tick pollution score when deciding contamination accumulation.
|
||||||
|
*
|
||||||
|
* <h3>Per-tick rules</h3>
|
||||||
|
* <ol>
|
||||||
|
* <li>High pollution causes contamination to accumulate in the soil.
|
||||||
|
* <li>Contamination steadily degrades fertility.
|
||||||
|
* <li>Good vegetation cover promotes fertility recovery and resists erosion.
|
||||||
|
* <li>Low fertility with low vegetation accelerates erosion.
|
||||||
|
* <li>Soil quality is computed as a weighted score of fertility, contamination, and erosion.
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
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 = 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.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. */
|
||||||
|
private static final double VEGETATION_RECOVERY_RATE = 0.002;
|
||||||
|
/** Fertility level below which erosion increases. */
|
||||||
|
private static final double EROSION_FERTILITY_THRESHOLD = 30.0;
|
||||||
|
/** Erosion increase per tick when fertility is low. */
|
||||||
|
private static final double EROSION_INCREASE_RATE = 0.05;
|
||||||
|
/** Erosion reduction per tick when vegetation is good. */
|
||||||
|
private static final double EROSION_VEGETATION_RESISTANCE = 0.03;
|
||||||
|
private static final double CHANGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"Soil",
|
||||||
|
"1.0.0",
|
||||||
|
"Simulates soil fertility, contamination, and erosion dynamics.",
|
||||||
|
"1",
|
||||||
|
List.of("pollution"),
|
||||||
|
List.of(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||||
|
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||||
|
region.getModuleData().put(MODULE_ID, SoilRegionData.defaults());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
SoilRegionData data = region.getModuleData()
|
||||||
|
.get(MODULE_ID, SoilRegionData.class)
|
||||||
|
.orElseGet(SoilRegionData::defaults);
|
||||||
|
RegionMetrics metrics = region.getMetrics();
|
||||||
|
|
||||||
|
double prevSoilQuality = metrics.getSoilQuality();
|
||||||
|
|
||||||
|
// Pollution causes contamination to build up in the soil.
|
||||||
|
if (metrics.getPollutionScore() > POLLUTION_CONTAMINATION_THRESHOLD) {
|
||||||
|
double excess = metrics.getPollutionScore() - POLLUTION_CONTAMINATION_THRESHOLD;
|
||||||
|
data.setContamination(data.getContamination() + excess * POLLUTION_TO_CONTAMINATION_RATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contamination steadily drains fertility.
|
||||||
|
data.setFertility(data.getFertility()
|
||||||
|
- data.getContamination() * CONTAMINATION_FERTILITY_DRAIN);
|
||||||
|
|
||||||
|
// Good vegetation cover promotes fertility recovery.
|
||||||
|
if (metrics.getVegetationPressure() > VEGETATION_RECOVERY_THRESHOLD) {
|
||||||
|
double excess = metrics.getVegetationPressure() - VEGETATION_RECOVERY_THRESHOLD;
|
||||||
|
data.setFertility(data.getFertility() + excess * VEGETATION_RECOVERY_RATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low fertility accelerates erosion; good vegetation cover slows it.
|
||||||
|
if (data.getFertility() < EROSION_FERTILITY_THRESHOLD) {
|
||||||
|
data.setErosion(data.getErosion() + EROSION_INCREASE_RATE);
|
||||||
|
}
|
||||||
|
if (metrics.getVegetationPressure() > VEGETATION_RECOVERY_THRESHOLD) {
|
||||||
|
data.setErosion(data.getErosion() - EROSION_VEGETATION_RESISTANCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soil quality: fertility is the main driver, penalised by contamination and erosion.
|
||||||
|
double soilQuality = Math.max(0.0, Math.min(100.0,
|
||||||
|
data.getFertility()
|
||||||
|
- data.getContamination() * 0.40
|
||||||
|
- data.getErosion() * 0.30));
|
||||||
|
metrics.setSoilQuality(soilQuality);
|
||||||
|
|
||||||
|
region.getModuleData().put(MODULE_ID, data);
|
||||||
|
|
||||||
|
boolean changed = Math.abs(metrics.getSoilQuality() - prevSoilQuality) > CHANGE_THRESHOLD;
|
||||||
|
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.livingworld.modules.soil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-region soil state tracked by {@link SoilModule}.
|
||||||
|
*
|
||||||
|
* <p>Five interacting values describe the physical and chemical condition of the
|
||||||
|
* land in a region. All values are clamped to [0, 100].
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>fertility</b> – capacity to support plant growth (higher is better)
|
||||||
|
* <li><b>moisture</b> – water content in soil (too low or too high is harmful)
|
||||||
|
* <li><b>contamination</b> – chemical or biological pollution absorbed by soil
|
||||||
|
* <li><b>compaction</b> – density of soil; high compaction resists root growth
|
||||||
|
* <li><b>erosion</b> – structural loss of topsoil (higher is worse)
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class SoilRegionData {
|
||||||
|
|
||||||
|
private static final double MIN = 0.0;
|
||||||
|
private static final double MAX = 100.0;
|
||||||
|
|
||||||
|
private double fertility;
|
||||||
|
private double moisture;
|
||||||
|
private double contamination;
|
||||||
|
private double compaction;
|
||||||
|
private double erosion;
|
||||||
|
|
||||||
|
public SoilRegionData(
|
||||||
|
double fertility,
|
||||||
|
double moisture,
|
||||||
|
double contamination,
|
||||||
|
double compaction,
|
||||||
|
double erosion) {
|
||||||
|
this.fertility = clamp(fertility);
|
||||||
|
this.moisture = clamp(moisture);
|
||||||
|
this.contamination = clamp(contamination);
|
||||||
|
this.compaction = clamp(compaction);
|
||||||
|
this.erosion = clamp(erosion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a healthy default soil profile. */
|
||||||
|
public static SoilRegionData defaults() {
|
||||||
|
return new SoilRegionData(60.0, 50.0, 0.0, 10.0, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public double getFertility() { return fertility; }
|
||||||
|
public double getMoisture() { return moisture; }
|
||||||
|
public double getContamination() { return contamination; }
|
||||||
|
public double getCompaction() { return compaction; }
|
||||||
|
public double getErosion() { return erosion; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Mutation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Degrades soil by the given amount.
|
||||||
|
*
|
||||||
|
* <p>Reduces fertility and increases contamination proportionally.
|
||||||
|
*
|
||||||
|
* @param amount positive degradation magnitude
|
||||||
|
*/
|
||||||
|
public void degrade(double amount) {
|
||||||
|
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||||
|
fertility = clamp(fertility - amount);
|
||||||
|
contamination = clamp(contamination + amount * 0.5);
|
||||||
|
erosion = clamp(erosion + amount * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies ecological recovery by the given amount.
|
||||||
|
*
|
||||||
|
* <p>Increases fertility and reduces contamination and erosion.
|
||||||
|
*
|
||||||
|
* @param amount positive recovery magnitude
|
||||||
|
*/
|
||||||
|
public void recover(double amount) {
|
||||||
|
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||||
|
fertility = clamp(fertility + amount);
|
||||||
|
contamination = clamp(contamination - amount * 0.4);
|
||||||
|
erosion = clamp(erosion - amount * 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFertility(double v) { fertility = clamp(v); }
|
||||||
|
public void setMoisture(double v) { moisture = clamp(v); }
|
||||||
|
public void setContamination(double v) { contamination = clamp(v); }
|
||||||
|
public void setCompaction(double v) { compaction = clamp(v); }
|
||||||
|
public void setErosion(double v) { erosion = clamp(v); }
|
||||||
|
|
||||||
|
/** Clamps all fields to [0, 100]. */
|
||||||
|
public void normalize() {
|
||||||
|
fertility = clamp(fertility);
|
||||||
|
moisture = clamp(moisture);
|
||||||
|
contamination = clamp(contamination);
|
||||||
|
compaction = clamp(compaction);
|
||||||
|
erosion = clamp(erosion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an independent copy. */
|
||||||
|
public SoilRegionData copy() {
|
||||||
|
return new SoilRegionData(fertility, moisture, contamination, compaction, erosion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static double clamp(double v) {
|
||||||
|
return Math.min(MAX, Math.max(MIN, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "SoilRegionData{"
|
||||||
|
+ "fertility=" + fertility
|
||||||
|
+ ", moisture=" + moisture
|
||||||
|
+ ", contamination=" + contamination
|
||||||
|
+ ", compaction=" + compaction
|
||||||
|
+ ", erosion=" + erosion + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package com.livingworld.modules.vegetation;
|
||||||
|
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.events.LivingWorldEvent;
|
||||||
|
import com.livingworld.modules.ModuleContext;
|
||||||
|
import com.livingworld.modules.ModuleMetadata;
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.SimulationModule;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates vegetation succession and die-off, writing
|
||||||
|
* {@link RegionMetrics#getVegetationPressure()}.
|
||||||
|
*
|
||||||
|
* <p>This module runs after {@link com.livingworld.modules.soil.SoilModule} and
|
||||||
|
* {@link com.livingworld.modules.pollution.PollutionModule} so it reads
|
||||||
|
* current-tick soil quality and pollution.
|
||||||
|
*
|
||||||
|
* <h3>Succession model</h3>
|
||||||
|
* When soil quality is adequate and pollution is low, vegetation follows a
|
||||||
|
* succession path: grass → flowers → shrubs → trees. Each tier can only grow
|
||||||
|
* once the tier below it reaches a critical threshold.
|
||||||
|
*
|
||||||
|
* <h3>Die-off model</h3>
|
||||||
|
* High pollution or very low soil quality kills vegetation in the reverse order
|
||||||
|
* (trees → shrubs → grass) and increases dead organic material.
|
||||||
|
*
|
||||||
|
* <h3>Dead vegetation</h3>
|
||||||
|
* Dead matter decomposes slowly each tick, eventually recycling into soil
|
||||||
|
* nutrients (modelled implicitly via the soil module's contamination dynamics).
|
||||||
|
*/
|
||||||
|
public final class VegetationModule implements SimulationModule {
|
||||||
|
|
||||||
|
public static final String MODULE_ID = "vegetation";
|
||||||
|
|
||||||
|
// --- growth thresholds ---
|
||||||
|
private static final double GROWTH_SOIL_THRESHOLD = 35.0;
|
||||||
|
private static final double GROWTH_POLLUTION_LIMIT = 40.0;
|
||||||
|
private static final double SHRUB_UNLOCK_GRASS = 50.0;
|
||||||
|
private static final double TREE_UNLOCK_SHRUB = 40.0;
|
||||||
|
|
||||||
|
// --- growth rates per tick ---
|
||||||
|
private static final double GRASS_GROWTH_RATE = 0.015;
|
||||||
|
private static final double FLOWER_GROWTH_RATE = 0.008;
|
||||||
|
private static final double SHRUB_GROWTH_RATE = 0.005;
|
||||||
|
private static final double TREE_GROWTH_RATE = 0.002;
|
||||||
|
|
||||||
|
// --- die-off thresholds ---
|
||||||
|
private static final double DIEOFF_SOIL_THRESHOLD = 20.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;
|
||||||
|
|
||||||
|
// --- decomposition ---
|
||||||
|
private static final double DEAD_DECOMPOSITION_RATE = 0.01;
|
||||||
|
|
||||||
|
private static final double CHANGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"Vegetation",
|
||||||
|
"1.0.0",
|
||||||
|
"Simulates vegetation succession, growth, and die-off.",
|
||||||
|
"1",
|
||||||
|
List.of("pollution", "soil"),
|
||||||
|
List.of(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||||
|
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||||
|
region.getModuleData().put(MODULE_ID, VegetationRegionData.defaults());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
VegetationRegionData data = region.getModuleData()
|
||||||
|
.get(MODULE_ID, VegetationRegionData.class)
|
||||||
|
.orElseGet(VegetationRegionData::defaults);
|
||||||
|
RegionMetrics metrics = region.getMetrics();
|
||||||
|
|
||||||
|
double prevVegetationPressure = metrics.getVegetationPressure();
|
||||||
|
|
||||||
|
boolean goodConditions = metrics.getSoilQuality() > GROWTH_SOIL_THRESHOLD
|
||||||
|
&& metrics.getPollutionScore() < GROWTH_POLLUTION_LIMIT;
|
||||||
|
|
||||||
|
boolean badConditions = metrics.getSoilQuality() < DIEOFF_SOIL_THRESHOLD
|
||||||
|
|| metrics.getPollutionScore() > DIEOFF_POLLUTION_THRESHOLD;
|
||||||
|
|
||||||
|
if (goodConditions) {
|
||||||
|
double soilBonus = metrics.getSoilQuality() - GROWTH_SOIL_THRESHOLD;
|
||||||
|
|
||||||
|
data.setGrassPressure(data.getGrassPressure() + soilBonus * GRASS_GROWTH_RATE);
|
||||||
|
data.setFlowerPressure(data.getFlowerPressure() + soilBonus * FLOWER_GROWTH_RATE);
|
||||||
|
|
||||||
|
// Shrubs grow only once grass is established.
|
||||||
|
if (data.getGrassPressure() > SHRUB_UNLOCK_GRASS) {
|
||||||
|
data.setShrubPressure(data.getShrubPressure() + soilBonus * SHRUB_GROWTH_RATE);
|
||||||
|
}
|
||||||
|
// Trees grow only once shrubs are established.
|
||||||
|
if (data.getShrubPressure() > TREE_UNLOCK_SHRUB) {
|
||||||
|
data.setTreePressure(data.getTreePressure() + soilBonus * TREE_GROWTH_RATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badConditions) {
|
||||||
|
data.setGrassPressure(data.getGrassPressure() - GRASS_DIEOFF_RATE);
|
||||||
|
data.setDeadVegetation(data.getDeadVegetation() + DEAD_ACCUMULATION_RATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead vegetation decomposes slowly each tick.
|
||||||
|
data.setDeadVegetation(data.getDeadVegetation() * (1.0 - DEAD_DECOMPOSITION_RATE));
|
||||||
|
|
||||||
|
// Vegetation pressure summary: living tiers weighted by ecological significance,
|
||||||
|
// penalised by dead material burden.
|
||||||
|
double vegetationPressure = Math.max(0.0, Math.min(100.0,
|
||||||
|
data.getGrassPressure() * 0.35
|
||||||
|
+ data.getFlowerPressure() * 0.10
|
||||||
|
+ data.getShrubPressure() * 0.25
|
||||||
|
+ data.getTreePressure() * 0.25
|
||||||
|
- data.getDeadVegetation() * 0.20));
|
||||||
|
metrics.setVegetationPressure(vegetationPressure);
|
||||||
|
|
||||||
|
region.getModuleData().put(MODULE_ID, data);
|
||||||
|
|
||||||
|
boolean changed = Math.abs(metrics.getVegetationPressure() - prevVegetationPressure)
|
||||||
|
> CHANGE_THRESHOLD;
|
||||||
|
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.livingworld.modules.vegetation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-region vegetation state tracked by {@link VegetationModule}.
|
||||||
|
*
|
||||||
|
* <p>Five values capture the biomass distribution and health of plant cover in a
|
||||||
|
* region. All values are clamped to [0, 100].
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>grassPressure</b> – density and health of grass-layer plants
|
||||||
|
* <li><b>flowerPressure</b> – density and health of flowering plants
|
||||||
|
* <li><b>shrubPressure</b> – density and health of shrubs / undergrowth
|
||||||
|
* <li><b>treePressure</b> – density and health of tree canopy
|
||||||
|
* <li><b>deadVegetation</b> – accumulated dead organic material (higher = more decay burden)
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Vegetation succession naturally flows from grass → flowers → shrubs → trees
|
||||||
|
* when conditions allow. Damage reverses this sequence and raises deadVegetation.
|
||||||
|
*/
|
||||||
|
public final class VegetationRegionData {
|
||||||
|
|
||||||
|
private static final double MIN = 0.0;
|
||||||
|
private static final double MAX = 100.0;
|
||||||
|
|
||||||
|
private double grassPressure;
|
||||||
|
private double flowerPressure;
|
||||||
|
private double shrubPressure;
|
||||||
|
private double treePressure;
|
||||||
|
private double deadVegetation;
|
||||||
|
|
||||||
|
public VegetationRegionData(
|
||||||
|
double grassPressure,
|
||||||
|
double flowerPressure,
|
||||||
|
double shrubPressure,
|
||||||
|
double treePressure,
|
||||||
|
double deadVegetation) {
|
||||||
|
this.grassPressure = clamp(grassPressure);
|
||||||
|
this.flowerPressure = clamp(flowerPressure);
|
||||||
|
this.shrubPressure = clamp(shrubPressure);
|
||||||
|
this.treePressure = clamp(treePressure);
|
||||||
|
this.deadVegetation = clamp(deadVegetation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a default mixed-vegetation profile. */
|
||||||
|
public static VegetationRegionData defaults() {
|
||||||
|
return new VegetationRegionData(50.0, 30.0, 30.0, 40.0, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public double getGrassPressure() { return grassPressure; }
|
||||||
|
public double getFlowerPressure() { return flowerPressure; }
|
||||||
|
public double getShrubPressure() { return shrubPressure; }
|
||||||
|
public double getTreePressure() { return treePressure; }
|
||||||
|
public double getDeadVegetation() { return deadVegetation; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Mutation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates the impact of logging or clear-cutting by the given amount.
|
||||||
|
*
|
||||||
|
* <p>Reduces tree and shrub pressure while increasing dead vegetation
|
||||||
|
* proportionally.
|
||||||
|
*
|
||||||
|
* @param amount positive logging damage magnitude
|
||||||
|
*/
|
||||||
|
public void reduceFromLogging(double amount) {
|
||||||
|
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||||
|
treePressure = clamp(treePressure - amount);
|
||||||
|
shrubPressure = clamp(shrubPressure - amount * 0.5);
|
||||||
|
deadVegetation = clamp(deadVegetation + amount * 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies ecological recovery across all vegetation layers by the given amount.
|
||||||
|
*
|
||||||
|
* <p>Increases all living pressures modestly and reduces dead vegetation.
|
||||||
|
*
|
||||||
|
* @param amount positive recovery magnitude
|
||||||
|
*/
|
||||||
|
public void recover(double amount) {
|
||||||
|
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
|
||||||
|
grassPressure = clamp(grassPressure + amount);
|
||||||
|
flowerPressure = clamp(flowerPressure + amount * 0.7);
|
||||||
|
shrubPressure = clamp(shrubPressure + amount * 0.5);
|
||||||
|
treePressure = clamp(treePressure + amount * 0.3);
|
||||||
|
deadVegetation = clamp(deadVegetation - amount * 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGrassPressure(double v) { grassPressure = clamp(v); }
|
||||||
|
public void setFlowerPressure(double v) { flowerPressure = clamp(v); }
|
||||||
|
public void setShrubPressure(double v) { shrubPressure = clamp(v); }
|
||||||
|
public void setTreePressure(double v) { treePressure = clamp(v); }
|
||||||
|
public void setDeadVegetation(double v) { deadVegetation = clamp(v); }
|
||||||
|
|
||||||
|
/** Clamps all fields to [0, 100]. */
|
||||||
|
public void normalize() {
|
||||||
|
grassPressure = clamp(grassPressure);
|
||||||
|
flowerPressure = clamp(flowerPressure);
|
||||||
|
shrubPressure = clamp(shrubPressure);
|
||||||
|
treePressure = clamp(treePressure);
|
||||||
|
deadVegetation = clamp(deadVegetation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an independent copy. */
|
||||||
|
public VegetationRegionData copy() {
|
||||||
|
return new VegetationRegionData(
|
||||||
|
grassPressure, flowerPressure, shrubPressure, treePressure, deadVegetation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static double clamp(double v) {
|
||||||
|
return Math.min(MAX, Math.max(MIN, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "VegetationRegionData{"
|
||||||
|
+ "grass=" + grassPressure
|
||||||
|
+ ", flower=" + flowerPressure
|
||||||
|
+ ", shrub=" + shrubPressure
|
||||||
|
+ ", tree=" + treePressure
|
||||||
|
+ ", dead=" + deadVegetation + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.livingworld.modules.water;
|
||||||
|
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.events.LivingWorldEvent;
|
||||||
|
import com.livingworld.modules.ModuleContext;
|
||||||
|
import com.livingworld.modules.ModuleMetadata;
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.SimulationModule;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refines {@link RegionMetrics#getWaterQuality()} after the pollution module has
|
||||||
|
* applied its raw damage, adding vegetation-driven purification and soil-driven
|
||||||
|
* contamination leaching.
|
||||||
|
*
|
||||||
|
* <p>This module runs <em>after</em>
|
||||||
|
* {@link com.livingworld.modules.pollution.PollutionModule} and
|
||||||
|
* {@link com.livingworld.modules.soil.SoilModule} in the update pipeline so
|
||||||
|
* it reads current-tick values for both pollutionScore and soilQuality.
|
||||||
|
*
|
||||||
|
* <h3>Per-tick rules</h3>
|
||||||
|
* <ol>
|
||||||
|
* <li>Purification capacity is derived from current vegetation pressure
|
||||||
|
* (plants filter water).
|
||||||
|
* <li>Low soil quality allows contamination to leach into groundwater,
|
||||||
|
* reducing water quality.
|
||||||
|
* <li>Purification capacity partially restores water quality each tick.
|
||||||
|
* <li>Water availability drifts toward a baseline unless stressed by
|
||||||
|
* drought or flood conditions (V1 placeholder).
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public final class WaterModule implements SimulationModule {
|
||||||
|
|
||||||
|
public static final String MODULE_ID = "water";
|
||||||
|
|
||||||
|
/** Vegetation pressure drives this fraction of purification capacity. */
|
||||||
|
private static final double VEG_TO_PURIFICATION_FACTOR = 0.50;
|
||||||
|
/** Each point of soil quality below 40 leaches this much water quality per tick. */
|
||||||
|
private static final double SOIL_LEACH_RATE = 0.005;
|
||||||
|
/** Soil quality threshold below which contamination leaches into water. */
|
||||||
|
private static final double SOIL_LEACH_THRESHOLD = 40.0;
|
||||||
|
/** Fraction of purification capacity applied to water quality per tick. */
|
||||||
|
private static final double PURIFICATION_RATE = 0.01;
|
||||||
|
private static final double CHANGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"Water Quality",
|
||||||
|
"1.0.0",
|
||||||
|
"Refines water quality with vegetation purification and soil contamination leaching.",
|
||||||
|
"1",
|
||||||
|
List.of("pollution", "soil"),
|
||||||
|
List.of("vegetation"),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||||
|
if (!region.getModuleData().contains(MODULE_ID)) {
|
||||||
|
region.getModuleData().put(MODULE_ID, WaterRegionData.defaults());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
WaterRegionData data = region.getModuleData()
|
||||||
|
.get(MODULE_ID, WaterRegionData.class)
|
||||||
|
.orElseGet(WaterRegionData::defaults);
|
||||||
|
RegionMetrics metrics = region.getMetrics();
|
||||||
|
|
||||||
|
double prevWaterQuality = metrics.getWaterQuality();
|
||||||
|
|
||||||
|
// Purification capacity is driven by vegetation cover.
|
||||||
|
double purification = metrics.getVegetationPressure() * VEG_TO_PURIFICATION_FACTOR;
|
||||||
|
data.setPurificationCapacity(purification);
|
||||||
|
|
||||||
|
// Low soil quality allows contamination to leach into groundwater.
|
||||||
|
if (metrics.getSoilQuality() < SOIL_LEACH_THRESHOLD) {
|
||||||
|
double leach = (SOIL_LEACH_THRESHOLD - metrics.getSoilQuality()) * SOIL_LEACH_RATE;
|
||||||
|
metrics.setWaterQuality(Math.max(0.0, metrics.getWaterQuality() - leach));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vegetation purification partially restores water quality.
|
||||||
|
double recovery = purification * PURIFICATION_RATE;
|
||||||
|
metrics.setWaterQuality(Math.min(100.0, metrics.getWaterQuality() + recovery));
|
||||||
|
|
||||||
|
region.getModuleData().put(MODULE_ID, data);
|
||||||
|
|
||||||
|
boolean changed = Math.abs(metrics.getWaterQuality() - prevWaterQuality) > CHANGE_THRESHOLD;
|
||||||
|
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.livingworld.modules.water;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-region water state tracked by {@link WaterModule}.
|
||||||
|
*
|
||||||
|
* <p>All values are clamped to [0, 100].
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><b>waterAvailability</b> – how much fresh water exists in this region
|
||||||
|
* <li><b>purificationCapacity</b> – ecosystem's ability to filter water;
|
||||||
|
* driven by vegetation cover
|
||||||
|
* <li><b>droughtRisk</b> – likelihood of water shortage conditions
|
||||||
|
* <li><b>floodRisk</b> – likelihood of waterlogging or runoff damage
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class WaterRegionData {
|
||||||
|
|
||||||
|
private static final double MIN = 0.0;
|
||||||
|
private static final double MAX = 100.0;
|
||||||
|
|
||||||
|
private double waterAvailability;
|
||||||
|
private double purificationCapacity;
|
||||||
|
private double droughtRisk;
|
||||||
|
private double floodRisk;
|
||||||
|
|
||||||
|
public WaterRegionData(
|
||||||
|
double waterAvailability,
|
||||||
|
double purificationCapacity,
|
||||||
|
double droughtRisk,
|
||||||
|
double floodRisk) {
|
||||||
|
this.waterAvailability = clamp(waterAvailability);
|
||||||
|
this.purificationCapacity = clamp(purificationCapacity);
|
||||||
|
this.droughtRisk = clamp(droughtRisk);
|
||||||
|
this.floodRisk = clamp(floodRisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a healthy default water profile. */
|
||||||
|
public static WaterRegionData defaults() {
|
||||||
|
return new WaterRegionData(60.0, 50.0, 10.0, 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Getters
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public double getWaterAvailability() { return waterAvailability; }
|
||||||
|
public double getPurificationCapacity() { return purificationCapacity; }
|
||||||
|
public double getDroughtRisk() { return droughtRisk; }
|
||||||
|
public double getFloodRisk() { return floodRisk; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Setters (clamp on write)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
public void setWaterAvailability(double v) { waterAvailability = clamp(v); }
|
||||||
|
public void setPurificationCapacity(double v) { purificationCapacity = clamp(v); }
|
||||||
|
public void setDroughtRisk(double v) { droughtRisk = clamp(v); }
|
||||||
|
public void setFloodRisk(double v) { floodRisk = clamp(v); }
|
||||||
|
|
||||||
|
/** Clamps all fields to [0, 100]. */
|
||||||
|
public void normalize() {
|
||||||
|
waterAvailability = clamp(waterAvailability);
|
||||||
|
purificationCapacity = clamp(purificationCapacity);
|
||||||
|
droughtRisk = clamp(droughtRisk);
|
||||||
|
floodRisk = clamp(floodRisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an independent copy. */
|
||||||
|
public WaterRegionData copy() {
|
||||||
|
return new WaterRegionData(waterAvailability, purificationCapacity, droughtRisk, floodRisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static double clamp(double v) {
|
||||||
|
return Math.min(MAX, Math.max(MIN, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "WaterRegionData{"
|
||||||
|
+ "availability=" + waterAvailability
|
||||||
|
+ ", purification=" + purificationCapacity
|
||||||
|
+ ", drought=" + droughtRisk
|
||||||
|
+ ", flood=" + floodRisk + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.livingworld.modules.worldeffects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives {@link WorldEffectRequest}s generated by {@link WorldEffectsModule}
|
||||||
|
* and applies the corresponding changes to the world.
|
||||||
|
*
|
||||||
|
* <p>The NeoForge platform adapter registers itself as a consumer during bootstrap.
|
||||||
|
* Tests register a capturing consumer to verify which effects were requested.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface WorldEffectConsumer {
|
||||||
|
|
||||||
|
/** Called when the ecosystem simulation wants a visible world change applied. */
|
||||||
|
void consume(WorldEffectRequest request);
|
||||||
|
|
||||||
|
/** A no-op consumer used when no platform adapter is registered. */
|
||||||
|
WorldEffectConsumer NO_OP = request -> {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.livingworld.modules.worldeffects;
|
||||||
|
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An immutable request for a visible world change in a specific region.
|
||||||
|
*
|
||||||
|
* <p>Generated by {@link WorldEffectsModule} and delivered to registered
|
||||||
|
* {@link WorldEffectConsumer}s. The platform layer (NeoForge adapter) is
|
||||||
|
* responsible for translating the request into actual block operations on
|
||||||
|
* loaded chunks.
|
||||||
|
*
|
||||||
|
* @param type the category of effect to apply
|
||||||
|
* @param region the region in which the effect should be applied
|
||||||
|
* @param intensity how strongly to apply the effect (0.0 = minimal, 1.0 = full)
|
||||||
|
*/
|
||||||
|
public record WorldEffectRequest(
|
||||||
|
WorldEffectType type,
|
||||||
|
RegionCoordinate region,
|
||||||
|
double intensity) {
|
||||||
|
|
||||||
|
public WorldEffectRequest {
|
||||||
|
if (type == null) throw new IllegalArgumentException("type must not be null");
|
||||||
|
if (region == null) throw new IllegalArgumentException("region must not be null");
|
||||||
|
if (intensity < 0.0 || intensity > 1.0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"intensity must be in [0.0, 1.0], got: " + intensity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.livingworld.modules.worldeffects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categories of visible world changes that the ecosystem simulation can request.
|
||||||
|
*
|
||||||
|
* <p>These are abstract descriptions of what should happen. The platform adapter
|
||||||
|
* (NeoForge layer) translates each type into concrete block changes or entity
|
||||||
|
* interactions on loaded chunks.
|
||||||
|
*/
|
||||||
|
public enum WorldEffectType {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* High pollution combined with low soil quality causes grass blocks to
|
||||||
|
* degrade into dirt or coarse dirt.
|
||||||
|
*/
|
||||||
|
GRASS_DEGRADES_TO_DIRT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Healthy soil and good vegetation pressure allows grass, moss, and flowers
|
||||||
|
* to spread onto adjacent bare blocks.
|
||||||
|
*/
|
||||||
|
VEGETATION_SPREADS,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sustained logging depletion slows the chance of saplings spawning from
|
||||||
|
* leaf decay and reduces natural sapling growth rate.
|
||||||
|
*/
|
||||||
|
SAPLING_GROWTH_SLOWED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A well-recovered region at an advanced succession stage boosts the
|
||||||
|
* chance of saplings appearing and growing.
|
||||||
|
*/
|
||||||
|
SAPLING_GROWTH_BOOSTED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A visual tint or overlay indicator applied to blocks in heavily polluted
|
||||||
|
* regions (e.g. discoloured water, dark soil). Implementation details are
|
||||||
|
* left to the platform layer.
|
||||||
|
*/
|
||||||
|
POLLUTION_VISUAL_INDICATOR,
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package com.livingworld.modules.worldeffects;
|
||||||
|
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.events.LivingWorldEvent;
|
||||||
|
import com.livingworld.modules.ModuleContext;
|
||||||
|
import com.livingworld.modules.ModuleMetadata;
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.SimulationModule;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryModule;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
|
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||||
|
import com.livingworld.modules.resources.ResourceRegionData;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates ecosystem simulation state into visible world change requests.
|
||||||
|
*
|
||||||
|
* <p>This module runs <em>last</em> in the pipeline. It reads the fully-updated
|
||||||
|
* state of all other modules and emits {@link WorldEffectRequest}s to registered
|
||||||
|
* {@link WorldEffectConsumer}s. The consumers (typically the NeoForge platform
|
||||||
|
* adapter) apply the requests as actual block changes on loaded chunks.
|
||||||
|
*
|
||||||
|
* <p>This module contains no Minecraft imports. The platform boundary lives
|
||||||
|
* entirely within the consumer implementations.
|
||||||
|
*
|
||||||
|
* <h3>Visible effects generated</h3>
|
||||||
|
* <ol>
|
||||||
|
* <li>{@link WorldEffectType#GRASS_DEGRADES_TO_DIRT} — when pollutionScore > 60
|
||||||
|
* and soilQuality < 30.
|
||||||
|
* <li>{@link WorldEffectType#VEGETATION_SPREADS} — when vegetationPressure > 60
|
||||||
|
* and soilQuality > 50.
|
||||||
|
* <li>{@link WorldEffectType#SAPLING_GROWTH_SLOWED} — when logging depletion
|
||||||
|
* exceeds 50 % (read from {@link ResourceRegionData}).
|
||||||
|
* <li>{@link WorldEffectType#SAPLING_GROWTH_BOOSTED} — when the succession stage
|
||||||
|
* is {@link SuccessionStage#YOUNG_WOODLAND} or higher.
|
||||||
|
* <li>{@link WorldEffectType#POLLUTION_VISUAL_INDICATOR} — when pollutionScore > 70.
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <h3>Registering consumers</h3>
|
||||||
|
* Call {@link #registerConsumer(WorldEffectConsumer)} before the simulation starts.
|
||||||
|
* Multiple consumers may be registered. If no consumer is registered the module
|
||||||
|
* operates silently as a no-op.
|
||||||
|
*/
|
||||||
|
public final class WorldEffectsModule implements SimulationModule {
|
||||||
|
|
||||||
|
public static final String MODULE_ID = "worldeffects";
|
||||||
|
|
||||||
|
// Thresholds that trigger effect requests.
|
||||||
|
// Grass degradation: pollution score > 15 AND soil quality below the gate.
|
||||||
|
// Soil defaults to 60 so the gate is set at 75 to avoid blocking early effects.
|
||||||
|
private static final double GRASS_DEGRADE_POLLUTION_MIN = 15.0;
|
||||||
|
private static final double GRASS_DEGRADE_SOIL_MAX = 75.0;
|
||||||
|
private static final double VEG_SPREAD_VEG_MIN = 60.0;
|
||||||
|
private static final double VEG_SPREAD_SOIL_MIN = 50.0;
|
||||||
|
private static final double SAPLING_SLOW_LOGGING_MIN = 50.0;
|
||||||
|
// Smoke particles appear as soon as any meaningful pollution exists.
|
||||||
|
private static final double POLLUTION_INDICATOR_MIN = 10.0;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"World Effects",
|
||||||
|
"1.0.0",
|
||||||
|
"Translates ecosystem state into visible block-change requests for the platform layer.",
|
||||||
|
"1",
|
||||||
|
List.of("pollution", "soil", "vegetation", "resources", "recovery"),
|
||||||
|
List.of(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
private final List<WorldEffectConsumer> consumers = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a consumer that will receive effect requests each simulation tick.
|
||||||
|
*
|
||||||
|
* @param consumer the consumer to register (must not be null)
|
||||||
|
*/
|
||||||
|
public void registerConsumer(WorldEffectConsumer consumer) {
|
||||||
|
if (consumer == null) throw new IllegalArgumentException("consumer must not be null");
|
||||||
|
consumers.add(consumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an unmodifiable view of all registered consumers. */
|
||||||
|
public List<WorldEffectConsumer> getConsumers() {
|
||||||
|
return Collections.unmodifiableList(consumers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
// No per-region data; this module only reads state, it does not persist any.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
if (consumers.isEmpty()) return ModuleUpdateResult.noChange();
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
RegionMetrics m = region.getMetrics();
|
||||||
|
boolean emitted = false;
|
||||||
|
|
||||||
|
// --- Effect 1: grass degrades when pollution is high and soil is poor ---
|
||||||
|
if (m.getPollutionScore() > GRASS_DEGRADE_POLLUTION_MIN
|
||||||
|
&& m.getSoilQuality() < GRASS_DEGRADE_SOIL_MAX) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
m.getPollutionScore() - GRASS_DEGRADE_POLLUTION_MIN, 40.0)
|
||||||
|
* computeIntensity(GRASS_DEGRADE_SOIL_MAX - m.getSoilQuality(), 30.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.GRASS_DEGRADES_TO_DIRT, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect 2: vegetation spreads when soil and vegetation pressure are healthy ---
|
||||||
|
if (m.getVegetationPressure() > VEG_SPREAD_VEG_MIN
|
||||||
|
&& m.getSoilQuality() > VEG_SPREAD_SOIL_MIN) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
m.getVegetationPressure() - VEG_SPREAD_VEG_MIN, 40.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.VEGETATION_SPREADS, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect 3: logging depletion slows sapling growth ---
|
||||||
|
ResourceRegionData resources = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
|
||||||
|
.orElse(null);
|
||||||
|
if (resources != null && resources.getLoggingDepletion() > SAPLING_SLOW_LOGGING_MIN) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
resources.getLoggingDepletion() - SAPLING_SLOW_LOGGING_MIN, 50.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.SAPLING_GROWTH_SLOWED, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect 4: advanced succession boosts sapling growth ---
|
||||||
|
RecoveryRegionData recovery = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
|
||||||
|
.orElse(null);
|
||||||
|
if (recovery != null
|
||||||
|
&& recovery.getSuccessionStage().ordinal()
|
||||||
|
>= SuccessionStage.YOUNG_WOODLAND.ordinal()) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
recovery.getSuccessionStage().ordinal()
|
||||||
|
- SuccessionStage.YOUNG_WOODLAND.ordinal() + 1.0, 2.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.SAPLING_GROWTH_BOOSTED, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect 5: pollution visual indicator ---
|
||||||
|
if (m.getPollutionScore() > POLLUTION_INDICATOR_MIN) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
m.getPollutionScore() - POLLUTION_INDICATOR_MIN, 30.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.POLLUTION_VISUAL_INDICATOR, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return emitted ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() { consumers.clear(); }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Computes effect intensity as a fraction of the excess over a range, clamped to [0, 1]. */
|
||||||
|
private static double computeIntensity(double excess, double range) {
|
||||||
|
return Math.min(1.0, Math.max(0.0, excess / range));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emit(WorldEffectRequest request) {
|
||||||
|
for (WorldEffectConsumer consumer : consumers) {
|
||||||
|
consumer.consume(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.livingworld.platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-neutral description of a block broken by a player.
|
||||||
|
*
|
||||||
|
* <p>The NeoForge adapter constructs this from a {@code BlockEvent.BreakEvent}
|
||||||
|
* and passes it to the bootstrap handler so no Minecraft types cross the
|
||||||
|
* platform boundary into core simulation code.</p>
|
||||||
|
*
|
||||||
|
* @param dimensionId Minecraft dimension key string (e.g. {@code "minecraft:overworld"})
|
||||||
|
* @param blockX world X coordinate of the broken block
|
||||||
|
* @param blockZ world Z coordinate of the broken block
|
||||||
|
* @param blockRegistryName registry name of the broken block (e.g. {@code "minecraft:oak_log"})
|
||||||
|
*/
|
||||||
|
public record BlockBreakInfo(
|
||||||
|
String dimensionId,
|
||||||
|
int blockX,
|
||||||
|
int blockZ,
|
||||||
|
String blockRegistryName) {
|
||||||
|
|
||||||
|
public BlockBreakInfo {
|
||||||
|
if (dimensionId == null || dimensionId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("dimensionId must not be null or blank");
|
||||||
|
}
|
||||||
|
if (blockRegistryName == null || blockRegistryName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("blockRegistryName must not be null or blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.livingworld.platform.neoforge;
|
package com.livingworld.platform.neoforge;
|
||||||
|
|
||||||
|
import com.livingworld.platform.BlockBreakInfo;
|
||||||
import com.livingworld.platform.PlatformAdapter;
|
import com.livingworld.platform.PlatformAdapter;
|
||||||
import com.mojang.brigadier.CommandDispatcher;
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -8,11 +9,14 @@ import java.util.function.Consumer;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import net.minecraft.SharedConstants;
|
import net.minecraft.SharedConstants;
|
||||||
import net.minecraft.commands.CommandSourceStack;
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
import net.neoforged.api.distmarker.Dist;
|
import net.neoforged.api.distmarker.Dist;
|
||||||
import net.neoforged.fml.ModList;
|
import net.neoforged.fml.ModList;
|
||||||
import net.neoforged.fml.loading.FMLEnvironment;
|
import net.neoforged.fml.loading.FMLEnvironment;
|
||||||
import net.neoforged.neoforge.common.NeoForge;
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||||
|
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,17 +29,21 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
|
|||||||
private final Supplier<Path> worldSaveDirectory;
|
private final Supplier<Path> worldSaveDirectory;
|
||||||
private final Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar;
|
private final Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar;
|
||||||
private final Runnable serverTickHook;
|
private final Runnable serverTickHook;
|
||||||
|
private final Consumer<BlockBreakInfo> blockBreakHandler;
|
||||||
private boolean commandsRegistered;
|
private boolean commandsRegistered;
|
||||||
private boolean serverTickRegistered;
|
private boolean serverTickRegistered;
|
||||||
|
private boolean playerEventsRegistered;
|
||||||
|
|
||||||
public NeoForgePlatformAdapter(
|
public NeoForgePlatformAdapter(
|
||||||
Supplier<Path> worldSaveDirectory,
|
Supplier<Path> worldSaveDirectory,
|
||||||
Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar,
|
Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar,
|
||||||
Runnable serverTickHook) {
|
Runnable serverTickHook,
|
||||||
|
Consumer<BlockBreakInfo> blockBreakHandler) {
|
||||||
this.worldSaveDirectory =
|
this.worldSaveDirectory =
|
||||||
Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory");
|
Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory");
|
||||||
this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar");
|
this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar");
|
||||||
this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook");
|
this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook");
|
||||||
|
this.blockBreakHandler = Objects.requireNonNull(blockBreakHandler, "blockBreakHandler");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -92,6 +100,39 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void registerPlayerEventHooks() {
|
public void registerPlayerEventHooks() {
|
||||||
// Player activity hooks are intentionally deferred until a module needs them.
|
if (playerEventsRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NeoForge.EVENT_BUS.addListener(BlockEvent.BreakEvent.class, this::onBlockBroken);
|
||||||
|
playerEventsRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a NeoForge block-break event into a platform-neutral
|
||||||
|
* {@link BlockBreakInfo} and forwards it to the core handler.
|
||||||
|
*
|
||||||
|
* <p>Only player-caused breaks are forwarded (non-null player). Creative-mode
|
||||||
|
* breaks are included intentionally so creative players can still trigger
|
||||||
|
* depletion for testing.</p>
|
||||||
|
*/
|
||||||
|
private void onBlockBroken(BlockEvent.BreakEvent event) {
|
||||||
|
if (event.getPlayer() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(event.getLevel() instanceof Level level)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String dimensionId = level.dimension().location().toString();
|
||||||
|
ResourceLocation blockId = event.getState().getBlockHolder()
|
||||||
|
.unwrapKey()
|
||||||
|
.map(key -> key.location())
|
||||||
|
.orElse(null);
|
||||||
|
if (blockId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int blockX = event.getPos().getX();
|
||||||
|
int blockZ = event.getPos().getZ();
|
||||||
|
blockBreakHandler.accept(
|
||||||
|
new BlockBreakInfo(dimensionId, blockX, blockZ, blockId.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.livingworld.platform.neoforge;
|
||||||
|
|
||||||
|
import com.livingworld.core.LivingWorldConstants;
|
||||||
|
import com.livingworld.debug.DiagnosticCategory;
|
||||||
|
import com.livingworld.debug.LivingWorldLogger;
|
||||||
|
import com.livingworld.modules.worldeffects.WorldEffectConsumer;
|
||||||
|
import com.livingworld.modules.worldeffects.WorldEffectRequest;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.core.particles.ParticleTypes;
|
||||||
|
import net.minecraft.core.registries.Registries;
|
||||||
|
import net.minecraft.resources.ResourceKey;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.Block;
|
||||||
|
import net.minecraft.world.level.block.Blocks;
|
||||||
|
import net.minecraft.world.level.levelgen.Heightmap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates {@link WorldEffectRequest}s from the ecosystem simulation into
|
||||||
|
* concrete Minecraft block operations and particle effects.
|
||||||
|
*
|
||||||
|
* <p>Called on the server tick thread. Block writes are guarded by a
|
||||||
|
* {@link ServerLevel#isLoaded} check to avoid force-loading chunks.</p>
|
||||||
|
*/
|
||||||
|
public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
|
||||||
|
|
||||||
|
private static final int REGION_BLOCKS =
|
||||||
|
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16;
|
||||||
|
private static final int BLOCK_ATTEMPTS = 20;
|
||||||
|
private static final int MIN_GRASS_LIGHT = 9;
|
||||||
|
|
||||||
|
private final Supplier<MinecraftServer> serverSupplier;
|
||||||
|
private final Random random = new Random();
|
||||||
|
|
||||||
|
public NeoForgeWorldEffectExecutor(Supplier<MinecraftServer> serverSupplier) {
|
||||||
|
this.serverSupplier = serverSupplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void consume(WorldEffectRequest request) {
|
||||||
|
MinecraftServer server = serverSupplier.get();
|
||||||
|
if (server == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ResourceKey<Level> dimensionKey = ResourceKey.create(
|
||||||
|
Registries.DIMENSION,
|
||||||
|
ResourceLocation.parse(request.region().dimensionId()));
|
||||||
|
ServerLevel level = server.getLevel(dimensionKey);
|
||||||
|
if (level == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int baseX = request.region().x() * REGION_BLOCKS;
|
||||||
|
int baseZ = request.region().z() * REGION_BLOCKS;
|
||||||
|
|
||||||
|
switch (request.type()) {
|
||||||
|
case GRASS_DEGRADES_TO_DIRT ->
|
||||||
|
degradeGrass(level, baseX, baseZ, request.intensity());
|
||||||
|
case VEGETATION_SPREADS ->
|
||||||
|
spreadVegetation(level, baseX, baseZ, request.intensity());
|
||||||
|
case POLLUTION_VISUAL_INDICATOR ->
|
||||||
|
spawnPollutionParticles(level, baseX, baseZ, request.intensity());
|
||||||
|
case SAPLING_GROWTH_BOOSTED ->
|
||||||
|
placeSaplings(level, baseX, baseZ, request.intensity());
|
||||||
|
case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void degradeGrass(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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void spreadVegetation(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;
|
||||||
|
BlockPos above = pos.above();
|
||||||
|
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;
|
||||||
|
Block sapling = pickSapling(intensity);
|
||||||
|
level.setBlock(above, sapling.defaultBlockState(), Block.UPDATE_ALL);
|
||||||
|
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
|
||||||
|
"WorldEffect SAPLING_GROWTH_BOOSTED (" + sapling + ") at " + pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects a sapling species weighted by succession intensity.
|
||||||
|
* Low intensity (young woodland, ~0.5): pioneer species — oak and birch dominate.
|
||||||
|
* High intensity (mature forest, ~1.0): diverse canopy — spruce, dark oak, cherry join in.
|
||||||
|
*/
|
||||||
|
private Block pickSapling(double intensity) {
|
||||||
|
int r = random.nextInt(100);
|
||||||
|
if (intensity < 0.7) {
|
||||||
|
// Young woodland: pioneer species only
|
||||||
|
return r < 55 ? Blocks.OAK_SAPLING : Blocks.BIRCH_SAPLING;
|
||||||
|
} else if (intensity < 0.9) {
|
||||||
|
// Developing forest: conifers begin to establish
|
||||||
|
if (r < 35) return Blocks.OAK_SAPLING;
|
||||||
|
if (r < 65) return Blocks.BIRCH_SAPLING;
|
||||||
|
if (r < 85) return Blocks.SPRUCE_SAPLING;
|
||||||
|
return Blocks.DARK_OAK_SAPLING;
|
||||||
|
} else {
|
||||||
|
// Mature forest: full species diversity
|
||||||
|
if (r < 25) return Blocks.OAK_SAPLING;
|
||||||
|
if (r < 45) return Blocks.BIRCH_SAPLING;
|
||||||
|
if (r < 62) return Blocks.SPRUCE_SAPLING;
|
||||||
|
if (r < 77) return Blocks.DARK_OAK_SAPLING;
|
||||||
|
if (r < 90) return Blocks.CHERRY_SAPLING;
|
||||||
|
return Blocks.JUNGLE_SAPLING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void spawnPollutionParticles(
|
||||||
|
ServerLevel level, int baseX, int baseZ, double intensity) {
|
||||||
|
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;
|
||||||
|
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE, (int) x, (int) z);
|
||||||
|
level.sendParticles(ParticleTypes.SMOKE, x, y + 1.5, z, 3, 0.5, 0.5, 0.5, 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
BlockPos pos = new BlockPos(x, y, z);
|
||||||
|
return level.isLoaded(pos) ? pos : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ class LivingWorldBootstrapTest {
|
|||||||
assertTrue(bootstrap.getServices().isRegistered(CoreServices.TIME));
|
assertTrue(bootstrap.getServices().isRegistered(CoreServices.TIME));
|
||||||
assertTrue(bootstrap.getServices().isRegistered(CoreServices.DEBUG));
|
assertTrue(bootstrap.getServices().isRegistered(CoreServices.DEBUG));
|
||||||
|
|
||||||
for (int tick = 0; tick < 100; tick++) {
|
for (int tick = 0; tick < 50; tick++) {
|
||||||
bootstrap.onServerTick();
|
bootstrap.onServerTick();
|
||||||
}
|
}
|
||||||
TimeService timeService = bootstrap.getServices().get(CoreServices.TIME);
|
TimeService timeService = bootstrap.getServices().get(CoreServices.TIME);
|
||||||
|
|||||||
@@ -1,36 +1,79 @@
|
|||||||
package com.livingworld.commands;
|
package com.livingworld.commands;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import com.livingworld.regions.Region;
|
import com.livingworld.regions.Region;
|
||||||
import com.livingworld.regions.RegionCoordinate;
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
import com.livingworld.regions.RegionFactory;
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
|
||||||
class RegionInfoFormatterTest {
|
class RegionInfoFormatterTest {
|
||||||
|
|
||||||
@Test
|
private static Region region() {
|
||||||
void includesIdentityLifecycleMetricsFlagsAndSortedModuleIds() {
|
return new RegionFactory().createNewRegion(
|
||||||
Region region = new RegionFactory().createNewRegion(
|
|
||||||
new RegionCoordinate("minecraft:overworld", -1, 2), 0);
|
new RegionCoordinate("minecraft:overworld", -1, 2), 0);
|
||||||
region.getMetrics().setPollutionScore(75);
|
}
|
||||||
region.getFlags().setHasHighPollution(true);
|
|
||||||
region.getModuleData().put("water", "data");
|
|
||||||
region.getModuleData().put("soil", "data");
|
|
||||||
|
|
||||||
List<String> lines = RegionInfoFormatter.format(region);
|
@Test
|
||||||
|
void headerContainsRegionId() {
|
||||||
|
List<String> lines = RegionInfoFormatter.format(region());
|
||||||
|
assertTrue(lines.get(0).contains("minecraft:overworld:-1:2"),
|
||||||
|
"header should contain stable ID; got: " + lines.get(0));
|
||||||
|
assertTrue(lines.get(0).contains("ACTIVE"), lines.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
assertEquals(5, lines.size());
|
@Test
|
||||||
assertTrue(lines.get(0).contains("minecraft:overworld:-1:2"));
|
void metricsLineContainsPollutionScore() {
|
||||||
assertTrue(lines.get(1).contains("ACTIVE"));
|
Region r = region();
|
||||||
assertTrue(lines.get(2).contains("pollution=75.0"));
|
r.getMetrics().setPollutionScore(75);
|
||||||
assertTrue(lines.get(3).contains("highPollution=true"));
|
List<String> lines = RegionInfoFormatter.format(r);
|
||||||
assertEquals("Module data: soil, water", lines.get(4));
|
assertTrue(lines.stream().anyMatch(l -> l.contains("poll=75.0")),
|
||||||
|
"metrics line should contain poll=75.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flagsLineContainsHighPollution() {
|
||||||
|
Region r = region();
|
||||||
|
r.getFlags().setHasHighPollution(true);
|
||||||
|
List<String> lines = RegionInfoFormatter.format(r);
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.contains("highPollution=true")),
|
||||||
|
"flags line should contain highPollution=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void moduleDataSectionPresentForAllModules() {
|
||||||
|
List<String> lines = RegionInfoFormatter.format(region());
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.contains("--- Module Data ---")));
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" pollution:")));
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" soil:")));
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" water:")));
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" vegetation:")));
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" resources:")));
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" recovery:")));
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" ecosystem:")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void moduleDataValuesShownWhenPresent() {
|
||||||
|
Region r = region();
|
||||||
|
r.getModuleData().put("pollution", new PollutionRegionData(33.0, 0.0, 0.0, 20.0));
|
||||||
|
r.getModuleData().put("recovery",
|
||||||
|
new RecoveryRegionData(SuccessionStage.YOUNG_WOODLAND, 50.0, 0.0));
|
||||||
|
|
||||||
|
List<String> lines = RegionInfoFormatter.format(r);
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.contains("air=33.0")));
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.contains("stage=YOUNG_WOODLAND")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noDataShownWhenModuleAbsent() {
|
||||||
|
List<String> lines = RegionInfoFormatter.format(region());
|
||||||
|
assertTrue(lines.stream().anyMatch(l -> l.contains("(no data)")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class SimulationConfigTest {
|
|||||||
@Test
|
@Test
|
||||||
void defaultSimulationIntervalTicks() {
|
void defaultSimulationIntervalTicks() {
|
||||||
final SimulationConfig config = new SimulationConfig();
|
final SimulationConfig config = new SimulationConfig();
|
||||||
assertEquals(100, config.getSimulationIntervalTicks());
|
assertEquals(50, config.getSimulationIntervalTicks());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -276,6 +276,6 @@ class SimulationConfigTest {
|
|||||||
final String result = config.toString();
|
final String result = config.toString();
|
||||||
assertTrue(result.contains("SimulationConfig"));
|
assertTrue(result.contains("SimulationConfig"));
|
||||||
assertTrue(result.contains("regionSizeChunks=8"));
|
assertTrue(result.contains("regionSizeChunks=8"));
|
||||||
assertTrue(result.contains("simulationIntervalTicks=100"));
|
assertTrue(result.contains("simulationIntervalTicks=50"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
|
import com.livingworld.modules.soil.SoilRegionData;
|
||||||
import com.livingworld.regions.Region;
|
import com.livingworld.regions.Region;
|
||||||
import com.livingworld.regions.RegionCoordinate;
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
import com.livingworld.regions.RegionFactory;
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
import com.livingworld.regions.RegionModuleData;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -99,6 +104,77 @@ class FileRegionPersistenceServiceTest {
|
|||||||
assertEquals(0, restarted.getDirtyRegionCount());
|
assertEquals(0, restarted.getDirtyRegionCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripsModuleData() {
|
||||||
|
FileRegionPersistenceService service = service();
|
||||||
|
service.registerModuleCodec(
|
||||||
|
"pollution",
|
||||||
|
(data, w) -> {
|
||||||
|
PollutionRegionData d = data.get("pollution", PollutionRegionData.class)
|
||||||
|
.orElseGet(PollutionRegionData::defaults);
|
||||||
|
w.writeDouble("airPollution", d.getAirPollution());
|
||||||
|
w.writeDouble("groundPollution", d.getGroundPollution());
|
||||||
|
w.writeDouble("waterPollution", d.getWaterPollution());
|
||||||
|
w.writeDouble("decayResistance", d.getDecayResistance());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put("pollution", new PollutionRegionData(
|
||||||
|
r.readDouble("airPollution", 0.0),
|
||||||
|
r.readDouble("groundPollution", 0.0),
|
||||||
|
r.readDouble("waterPollution", 0.0),
|
||||||
|
r.readDouble("decayResistance", 20.0))));
|
||||||
|
service.registerModuleCodec(
|
||||||
|
"soil",
|
||||||
|
(data, w) -> {
|
||||||
|
SoilRegionData d = data.get("soil", SoilRegionData.class)
|
||||||
|
.orElseGet(SoilRegionData::defaults);
|
||||||
|
w.writeDouble("fertility", d.getFertility());
|
||||||
|
w.writeDouble("contamination", d.getContamination());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put("soil", new SoilRegionData(
|
||||||
|
r.readDouble("fertility", 60.0),
|
||||||
|
r.readDouble("moisture", 50.0),
|
||||||
|
r.readDouble("contamination", 0.0),
|
||||||
|
r.readDouble("compaction", 10.0),
|
||||||
|
r.readDouble("erosion", 0.0))));
|
||||||
|
service.registerModuleCodec(
|
||||||
|
"recovery",
|
||||||
|
(data, w) -> {
|
||||||
|
RecoveryRegionData d = data.get("recovery", RecoveryRegionData.class)
|
||||||
|
.orElseGet(RecoveryRegionData::defaults);
|
||||||
|
w.writeString("successionStage", d.getSuccessionStage().name());
|
||||||
|
w.writeDouble("damageAccumulation", d.getDamageAccumulation());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put("recovery", new RecoveryRegionData(
|
||||||
|
SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
|
||||||
|
r.readDouble("recoveryProgress", 0.0),
|
||||||
|
r.readDouble("damageAccumulation", 0.0))));
|
||||||
|
|
||||||
|
Region original = new RegionFactory().createNewRegion(
|
||||||
|
new RegionCoordinate("minecraft:overworld", 0, 0), 0);
|
||||||
|
RegionModuleData moduleData = original.getModuleData();
|
||||||
|
moduleData.put("pollution", new PollutionRegionData(42.0, 15.0, 8.0, 25.0));
|
||||||
|
moduleData.put("soil", new SoilRegionData(75.0, 60.0, 5.0, 20.0, 3.0));
|
||||||
|
moduleData.put("recovery", new RecoveryRegionData(SuccessionStage.YOUNG_WOODLAND, 66.0, 12.5));
|
||||||
|
|
||||||
|
service.saveRegion(original);
|
||||||
|
Region restored = service.loadRegion(original.getCoordinate()).orElseThrow();
|
||||||
|
RegionModuleData restoredData = restored.getModuleData();
|
||||||
|
|
||||||
|
PollutionRegionData pollution = restoredData.get("pollution", PollutionRegionData.class).orElseThrow();
|
||||||
|
assertEquals(42.0, pollution.getAirPollution());
|
||||||
|
assertEquals(15.0, pollution.getGroundPollution());
|
||||||
|
assertEquals(8.0, pollution.getWaterPollution());
|
||||||
|
assertEquals(25.0, pollution.getDecayResistance());
|
||||||
|
|
||||||
|
SoilRegionData soil = restoredData.get("soil", SoilRegionData.class).orElseThrow();
|
||||||
|
assertEquals(75.0, soil.getFertility());
|
||||||
|
assertEquals(5.0, soil.getContamination());
|
||||||
|
|
||||||
|
RecoveryRegionData recovery = restoredData.get("recovery", RecoveryRegionData.class).orElseThrow();
|
||||||
|
assertEquals(SuccessionStage.YOUNG_WOODLAND, recovery.getSuccessionStage());
|
||||||
|
assertEquals(12.5, recovery.getDamageAccumulation());
|
||||||
|
}
|
||||||
|
|
||||||
private FileRegionPersistenceService service() {
|
private FileRegionPersistenceService service() {
|
||||||
return new FileRegionPersistenceService(
|
return new FileRegionPersistenceService(
|
||||||
temporaryDirectory,
|
temporaryDirectory,
|
||||||
|
|||||||
@@ -233,6 +233,11 @@ class SimulationManagerTest {
|
|||||||
return coordinates.stream().map(regions::get).filter(java.util.Objects::nonNull).toList();
|
return coordinates.stream().map(regions::get).filter(java.util.Objects::nonNull).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.Collection<Region> getActiveRegions() {
|
||||||
|
return regions.values();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void markDirty(Region region) {
|
public void markDirty(Region region) {
|
||||||
markDirtyCount++;
|
markDirtyCount++;
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package com.livingworld.data.migration;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class MigrationManagerTest {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static Properties propsAtVersion(int version) {
|
||||||
|
Properties p = new Properties();
|
||||||
|
p.setProperty("schemaVersion", Integer.toString(version));
|
||||||
|
p.setProperty("someField", "original");
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Migration that adds a tag proving it ran. */
|
||||||
|
private static RegionMigration tagMigration(int from) {
|
||||||
|
return new RegionMigration() {
|
||||||
|
@Override public int fromVersion() { return from; }
|
||||||
|
@Override public int toVersion() { return from + 1; }
|
||||||
|
@Override public Properties apply(Properties data) {
|
||||||
|
Properties out = new Properties();
|
||||||
|
out.putAll(data);
|
||||||
|
out.setProperty("migrated_" + from + "_to_" + (from + 1), "true");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// construction
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newManagerHasNoMigrations() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
assertEquals(0, manager.getRegisteredMigrationCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// registration
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerNullMigrationThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> manager.register(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerMigrationThatSkipsVersionThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
RegionMigration bad = new RegionMigration() {
|
||||||
|
@Override public int fromVersion() { return 1; }
|
||||||
|
@Override public int toVersion() { return 3; } // skips v2
|
||||||
|
@Override public Properties apply(Properties data) { return data; }
|
||||||
|
};
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> manager.register(bad));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerDuplicateFromVersionThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
manager.register(tagMigration(1));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> manager.register(tagMigration(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registerIncreasesCount() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
manager.register(tagMigration(1));
|
||||||
|
manager.register(tagMigration(2));
|
||||||
|
assertEquals(2, manager.getRegisteredMigrationCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// isUpToDate
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isUpToDateReturnsTrueWhenVersionMatches() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
assertTrue(manager.isUpToDate(propsAtVersion(1), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isUpToDateReturnsFalseWhenBehind() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
assertFalse(manager.isUpToDate(propsAtVersion(1), 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// migrateIfNeeded — already at target
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void migrateIfNeededReturnsSameObjectWhenAlreadyAtTarget() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
Properties data = propsAtVersion(1);
|
||||||
|
Properties result = manager.migrateIfNeeded(data, 1);
|
||||||
|
assertSame(data, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// migrateIfNeeded — single step
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singleMigrationIsApplied() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
manager.register(tagMigration(1));
|
||||||
|
|
||||||
|
Properties data = propsAtVersion(1);
|
||||||
|
Properties result = manager.migrateIfNeeded(data, 2);
|
||||||
|
|
||||||
|
assertEquals("2", result.getProperty("schemaVersion"));
|
||||||
|
assertEquals("true", result.getProperty("migrated_1_to_2"));
|
||||||
|
assertEquals("original", result.getProperty("someField")); // original data preserved
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// migrateIfNeeded — multi-step
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multiStepMigrationAppliesAllStepsInOrder() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
manager.register(tagMigration(1));
|
||||||
|
manager.register(tagMigration(2));
|
||||||
|
|
||||||
|
Properties result = manager.migrateIfNeeded(propsAtVersion(1), 3);
|
||||||
|
|
||||||
|
assertEquals("3", result.getProperty("schemaVersion"));
|
||||||
|
assertEquals("true", result.getProperty("migrated_1_to_2"));
|
||||||
|
assertEquals("true", result.getProperty("migrated_2_to_3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// migrateIfNeeded — source data is not mutated
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void originalDataIsNotMutatedByMigration() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
manager.register(tagMigration(1));
|
||||||
|
|
||||||
|
Properties data = propsAtVersion(1);
|
||||||
|
manager.migrateIfNeeded(data, 2);
|
||||||
|
|
||||||
|
assertEquals("1", data.getProperty("schemaVersion"));
|
||||||
|
assertNull(data.getProperty("migrated_1_to_2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// migrateIfNeeded — error cases
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downgradeAttemptThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager.migrateIfNeeded(propsAtVersion(2), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingMigrationInChainThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
manager.register(tagMigration(1));
|
||||||
|
// no migration for version 2 → 3
|
||||||
|
|
||||||
|
// data at v1, target v3: step 1→2 exists but 2→3 is missing
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager.migrateIfNeeded(propsAtVersion(1), 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingSchemaVersionKeyThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
Properties data = new Properties();
|
||||||
|
data.setProperty("someField", "value");
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager.migrateIfNeeded(data, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidSchemaVersionValueThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
Properties data = new Properties();
|
||||||
|
data.setProperty("schemaVersion", "not-a-number");
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager.migrateIfNeeded(data, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroSchemaVersionThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
Properties data = new Properties();
|
||||||
|
data.setProperty("schemaVersion", "0");
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager.migrateIfNeeded(data, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullDataThrows() {
|
||||||
|
MigrationManager manager = new MigrationManager(null);
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> manager.migrateIfNeeded(null, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// migrations.log
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void migrationIsRecordedInLogFile(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path logFile = tempDir.resolve("migrations.log");
|
||||||
|
MigrationManager manager = new MigrationManager(logFile);
|
||||||
|
manager.register(tagMigration(1));
|
||||||
|
|
||||||
|
manager.migrateIfNeeded(propsAtVersion(1), 2);
|
||||||
|
|
||||||
|
assertTrue(Files.exists(logFile), "migrations.log should be created");
|
||||||
|
String content = Files.readString(logFile);
|
||||||
|
assertTrue(content.contains("1 → 2"), "log should record the version step");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multiStepMigrationWritesMultipleLogEntries(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path logFile = tempDir.resolve("sub/migrations.log");
|
||||||
|
MigrationManager manager = new MigrationManager(logFile);
|
||||||
|
manager.register(tagMigration(1));
|
||||||
|
manager.register(tagMigration(2));
|
||||||
|
|
||||||
|
manager.migrateIfNeeded(propsAtVersion(1), 3);
|
||||||
|
|
||||||
|
String content = Files.readString(logFile);
|
||||||
|
assertTrue(content.contains("1 → 2"));
|
||||||
|
assertTrue(content.contains("2 → 3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noLogFileWrittenWhenAlreadyAtTargetVersion(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path logFile = tempDir.resolve("migrations.log");
|
||||||
|
MigrationManager manager = new MigrationManager(logFile);
|
||||||
|
|
||||||
|
manager.migrateIfNeeded(propsAtVersion(1), 1);
|
||||||
|
|
||||||
|
assertFalse(Files.exists(logFile), "log should not be created when no migration runs");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package com.livingworld.modules;
|
||||||
|
|
||||||
|
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||||
|
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||||
|
import com.livingworld.modules.pollution.PollutionModule;
|
||||||
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
|
import com.livingworld.modules.soil.SoilModule;
|
||||||
|
import com.livingworld.modules.soil.SoilRegionData;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationModule;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
import com.livingworld.regions.RegionLifecycleState;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests that run all four ecosystem modules in pipeline order against
|
||||||
|
* real Region objects and verify ecological cause-and-effect across multiple ticks.
|
||||||
|
*/
|
||||||
|
class EcosystemModuleIntegrationTest {
|
||||||
|
|
||||||
|
private static final List<SimulationModule> MODULES = List.of(
|
||||||
|
new PollutionModule(),
|
||||||
|
new SoilModule(),
|
||||||
|
new VegetationModule(),
|
||||||
|
new EcosystemModule());
|
||||||
|
|
||||||
|
private RegionFactory factory;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
factory = new RegionFactory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private Region freshRegion() {
|
||||||
|
return factory.createNewRegion(
|
||||||
|
new RegionCoordinate("overworld", 0, 0), 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs all modules once against the region, in pipeline order. */
|
||||||
|
private void tick(Region region) {
|
||||||
|
RegionUpdateContext ctx = new RegionUpdateContext(region);
|
||||||
|
for (SimulationModule module : MODULES) {
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
module.updateRegion(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs N ticks against the region. */
|
||||||
|
private void tick(Region region, int ticks) {
|
||||||
|
for (int i = 0; i < ticks; i++) {
|
||||||
|
tick(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// clean region stays stable
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanRegionKeepsHighEcosystemHealthOverManyTicks() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Start with pristine defaults – no pollution.
|
||||||
|
tick(region, 50);
|
||||||
|
// Ecosystem health should remain high with no external stressors.
|
||||||
|
assertTrue(region.getMetrics().getEcosystemHealth() >= 50.0,
|
||||||
|
"Clean region should maintain reasonable ecosystem health");
|
||||||
|
assertTrue(region.getMetrics().getPollutionScore() < 5.0,
|
||||||
|
"No pollution was added; score should stay near zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// heavy pollution degrades soil and vegetation
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void heavyPollutionDegradesSoilQualityOverTime() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Prime the region with severe pollution directly in module data.
|
||||||
|
PollutionRegionData pollData = new PollutionRegionData(80.0, 80.0, 50.0, 20.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||||
|
|
||||||
|
double initialSoilQuality = region.getMetrics().getSoilQuality();
|
||||||
|
|
||||||
|
tick(region, 30);
|
||||||
|
|
||||||
|
double laterSoilQuality = region.getMetrics().getSoilQuality();
|
||||||
|
assertTrue(laterSoilQuality < initialSoilQuality,
|
||||||
|
"Sustained heavy pollution should degrade soil quality. Before="
|
||||||
|
+ initialSoilQuality + " After=" + laterSoilQuality);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void heavyPollutionReducesVegetationPressure() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollData = new PollutionRegionData(90.0, 90.0, 70.0, 20.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||||
|
|
||||||
|
double initialVeg = region.getMetrics().getVegetationPressure();
|
||||||
|
tick(region, 30);
|
||||||
|
double laterVeg = region.getMetrics().getVegetationPressure();
|
||||||
|
|
||||||
|
assertTrue(laterVeg < initialVeg,
|
||||||
|
"Heavy pollution should reduce vegetation pressure. Before="
|
||||||
|
+ initialVeg + " After=" + laterVeg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void heavyPollutionIncreasesEcosystemStress() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollData = new PollutionRegionData(100.0, 100.0, 100.0, 50.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||||
|
|
||||||
|
tick(region, 20);
|
||||||
|
|
||||||
|
EcosystemRegionData ecoData = region.getModuleData()
|
||||||
|
.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class)
|
||||||
|
.orElseThrow();
|
||||||
|
assertTrue(ecoData.getStress() > 20.0,
|
||||||
|
"Severe pollution should elevate ecosystem stress");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// vegetation succession
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bareGroundWithGoodSoilGrowsGrassOverTime() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Strip all vegetation.
|
||||||
|
VegetationRegionData barren = new VegetationRegionData(5.0, 0.0, 0.0, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(VegetationModule.MODULE_ID, barren);
|
||||||
|
|
||||||
|
tick(region, 50);
|
||||||
|
|
||||||
|
VegetationRegionData later = region.getModuleData()
|
||||||
|
.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
|
||||||
|
.orElseThrow();
|
||||||
|
assertTrue(later.getGrassPressure() > 5.0,
|
||||||
|
"Good soil should allow grass to grow back from a barren start");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loggingReducesTreeAndShrubPressure() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
VegetationRegionData preLog = VegetationRegionData.defaults();
|
||||||
|
double treesBefore = preLog.getTreePressure();
|
||||||
|
preLog.reduceFromLogging(30.0);
|
||||||
|
region.getModuleData().put(VegetationModule.MODULE_ID, preLog);
|
||||||
|
|
||||||
|
assertTrue(preLog.getTreePressure() < treesBefore,
|
||||||
|
"Logging should immediately reduce tree pressure");
|
||||||
|
assertTrue(preLog.getDeadVegetation() > 5.0,
|
||||||
|
"Logging should produce dead vegetation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// water quality
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void waterPollutionDegradedWaterQuality() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Force high water pollution.
|
||||||
|
PollutionRegionData pollData = new PollutionRegionData(0.0, 0.0, 80.0, 20.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||||
|
// Set metrics to a known starting waterQuality.
|
||||||
|
region.getMetrics().setWaterQuality(80.0);
|
||||||
|
|
||||||
|
tick(region, 10);
|
||||||
|
|
||||||
|
assertTrue(region.getMetrics().getWaterQuality() < 80.0,
|
||||||
|
"Water pollution should degrade water quality metric");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// recovery after pollution clears
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ecosystemHealthImprovesDramaticallyAfterPollutionIsRemoved() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Heavily pollute for 20 ticks.
|
||||||
|
PollutionRegionData pollData = new PollutionRegionData(90.0, 90.0, 60.0, 20.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||||
|
tick(region, 20);
|
||||||
|
double healthMidPollution = region.getMetrics().getEcosystemHealth();
|
||||||
|
|
||||||
|
// Remove pollution and run for another 50 ticks.
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
|
||||||
|
tick(region, 50);
|
||||||
|
double healthAfterRecovery = region.getMetrics().getEcosystemHealth();
|
||||||
|
|
||||||
|
assertTrue(healthAfterRecovery > healthMidPollution,
|
||||||
|
"Ecosystem should recover after pollution is removed. MidPollution="
|
||||||
|
+ healthMidPollution + " AfterRecovery=" + healthAfterRecovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// module order matters
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pollutionModuleUpdatesMetricsReadBySoilModuleInSameTick() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// groundPollution=90 → pollutionScore=90*0.35=31.5 > POLLUTION_CONTAMINATION_THRESHOLD(30).
|
||||||
|
PollutionRegionData pollData = new PollutionRegionData(0.0, 90.0, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
|
||||||
|
|
||||||
|
// Run exactly one tick.
|
||||||
|
tick(region);
|
||||||
|
|
||||||
|
// Pollution module should have set a non-zero pollution score.
|
||||||
|
assertTrue(region.getMetrics().getPollutionScore() > 0.0,
|
||||||
|
"PollutionModule should have written a non-zero pollutionScore to metrics");
|
||||||
|
// Soil module (running after) should have begun accumulating contamination.
|
||||||
|
SoilRegionData soilData = region.getModuleData()
|
||||||
|
.get(SoilModule.MODULE_ID, SoilRegionData.class)
|
||||||
|
.orElseThrow();
|
||||||
|
assertTrue(soilData.getContamination() > 0.0,
|
||||||
|
"SoilModule should have accumulated contamination from this tick's pollutionScore");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// lifecycle: createDefaultRegionData is idempotent
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDefaultRegionDataIsIdempotent() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionModule module = new PollutionModule();
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
module.createDefaultRegionData(region); // second call must not overwrite
|
||||||
|
|
||||||
|
// Data exists and is valid defaults.
|
||||||
|
PollutionRegionData data = region.getModuleData()
|
||||||
|
.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
|
||||||
|
.orElseThrow();
|
||||||
|
assertEquals(0.0, data.getAirPollution(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// ModuleUpdateResult signals
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pollutionModuleReturnsNoChangeWhenPollutionIsZero() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Ensure pollution module data is initialised to all-zero.
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
|
||||||
|
// Zero water quality impact needs zero starting waterQuality impact too.
|
||||||
|
region.getMetrics().setWaterQuality(60.0);
|
||||||
|
|
||||||
|
ModuleUpdateResult result = new PollutionModule()
|
||||||
|
.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
// Zero pollution decays to zero; pollutionScore stays 0; no meaningful change.
|
||||||
|
assertFalse(result.changedRegion(),
|
||||||
|
"PollutionModule with zero pollution should return noChange");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pollutionModuleReturnsChangedWhenPollutionIsPresent() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID,
|
||||||
|
new PollutionRegionData(50.0, 50.0, 50.0, 0.0));
|
||||||
|
|
||||||
|
ModuleUpdateResult result = new PollutionModule()
|
||||||
|
.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertTrue(result.changedRegion(),
|
||||||
|
"PollutionModule with non-zero pollution should return changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// long-run stability
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allMetricsRemainInValidRangeAfter1000Ticks() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Add moderate pollution so the simulation isn't completely quiescent.
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID,
|
||||||
|
new PollutionRegionData(30.0, 20.0, 10.0, 25.0));
|
||||||
|
|
||||||
|
tick(region, 1000);
|
||||||
|
|
||||||
|
RegionMetrics m = region.getMetrics();
|
||||||
|
assertInRange("ecosystemHealth", m.getEcosystemHealth());
|
||||||
|
assertInRange("pollutionScore", m.getPollutionScore());
|
||||||
|
assertInRange("soilQuality", m.getSoilQuality());
|
||||||
|
assertInRange("waterQuality", m.getWaterQuality());
|
||||||
|
assertInRange("vegetationPressure",m.getVegetationPressure());
|
||||||
|
assertInRange("recoveryPressure", m.getRecoveryPressure());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertInRange(String name, double value) {
|
||||||
|
assertTrue(value >= 0.0 && value <= 100.0,
|
||||||
|
name + " must be in [0, 100] but was " + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package com.livingworld.modules;
|
||||||
|
|
||||||
|
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||||
|
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||||
|
import com.livingworld.modules.pollution.PollutionModule;
|
||||||
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryModule;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
|
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||||
|
import com.livingworld.modules.resources.ResourceRegionData;
|
||||||
|
import com.livingworld.modules.soil.SoilModule;
|
||||||
|
import com.livingworld.modules.soil.SoilRegionData;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationModule;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||||
|
import com.livingworld.modules.water.WaterModule;
|
||||||
|
import com.livingworld.modules.worldeffects.WorldEffectRequest;
|
||||||
|
import com.livingworld.modules.worldeffects.WorldEffectType;
|
||||||
|
import com.livingworld.modules.worldeffects.WorldEffectsModule;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full Volume 2 integration test: runs all eight ecosystem modules in pipeline
|
||||||
|
* order (Pollution → Soil → Water → Vegetation → ResourceDepletion → Recovery
|
||||||
|
* → Ecosystem → WorldEffects) and verifies ecological cause-and-effect across
|
||||||
|
* multiple simulation ticks.
|
||||||
|
*/
|
||||||
|
@DisplayName("Volume 2 Full Pipeline Integration")
|
||||||
|
class Volume2IntegrationTest {
|
||||||
|
|
||||||
|
private RegionFactory factory;
|
||||||
|
private WorldEffectsModule worldEffects;
|
||||||
|
private List<WorldEffectRequest> effectsCapture;
|
||||||
|
|
||||||
|
private List<SimulationModule> pipeline;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
factory = new RegionFactory();
|
||||||
|
worldEffects = new WorldEffectsModule();
|
||||||
|
effectsCapture = new ArrayList<>();
|
||||||
|
worldEffects.registerConsumer(effectsCapture::add);
|
||||||
|
|
||||||
|
pipeline = List.of(
|
||||||
|
new PollutionModule(),
|
||||||
|
new SoilModule(),
|
||||||
|
new WaterModule(),
|
||||||
|
new VegetationModule(),
|
||||||
|
new ResourceDepletionModule(),
|
||||||
|
new RecoveryModule(),
|
||||||
|
new EcosystemModule(),
|
||||||
|
worldEffects);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private Region freshRegion() {
|
||||||
|
return factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tick(Region region) {
|
||||||
|
effectsCapture.clear();
|
||||||
|
RegionUpdateContext ctx = new RegionUpdateContext(region);
|
||||||
|
for (SimulationModule module : pipeline) {
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
module.updateRegion(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tick(Region region, int ticks) {
|
||||||
|
for (int i = 0; i < ticks; i++) {
|
||||||
|
tick(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Structural tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("all modules initialize without error")
|
||||||
|
void allModulesInitialize() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
for (SimulationModule module : pipeline) {
|
||||||
|
assertDoesNotThrow(() -> module.createDefaultRegionData(region));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("pipeline runs 100 ticks on a clean region without exception")
|
||||||
|
void cleanRegionStableOver100Ticks() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
assertDoesNotThrow(() -> tick(region, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("all metrics stay in [0, 100] over 1000 ticks — pristine region")
|
||||||
|
void metricsInRangePristine() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
tick(region, 1000);
|
||||||
|
assertMetricsInRange(region.getMetrics());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("all metrics stay in [0, 100] over 500 ticks — heavily polluted region")
|
||||||
|
void metricsInRangeHeavilyPolluted() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
|
||||||
|
tick(region, 500);
|
||||||
|
assertMetricsInRange(region.getMetrics());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Causal chain tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("heavy pollution degrades soil quality over 30 ticks")
|
||||||
|
void heavyPollutionDegradesSoil() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
double initialSoil = region.getMetrics().getSoilQuality();
|
||||||
|
|
||||||
|
tick(region, 30);
|
||||||
|
|
||||||
|
assertTrue(region.getMetrics().getSoilQuality() < initialSoil,
|
||||||
|
"Soil quality should degrade under heavy pollution");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("heavy pollution reduces vegetation pressure over 50 ticks")
|
||||||
|
void heavyPollutionReducesVegetation() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
double initialVeg = region.getMetrics().getVegetationPressure();
|
||||||
|
|
||||||
|
tick(region, 50);
|
||||||
|
|
||||||
|
assertTrue(region.getMetrics().getVegetationPressure() < initialVeg,
|
||||||
|
"Vegetation pressure should fall under heavy pollution");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("heavy pollution lowers water quality over 20 ticks")
|
||||||
|
void heavyPollutionLowersWater() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
double initialWater = region.getMetrics().getWaterQuality();
|
||||||
|
|
||||||
|
tick(region, 20);
|
||||||
|
|
||||||
|
assertTrue(region.getMetrics().getWaterQuality() < initialWater,
|
||||||
|
"Water quality should fall under heavy pollution");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("heavy pollution increases ecosystem stress over 30 ticks")
|
||||||
|
void heavyPollutionIncreasesStress() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
|
||||||
|
tick(region, 30);
|
||||||
|
|
||||||
|
EcosystemRegionData eco = region.getModuleData()
|
||||||
|
.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class).orElseThrow();
|
||||||
|
assertTrue(eco.getStress() > 20.0,
|
||||||
|
"Ecosystem stress should be elevated under heavy pollution");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("bare ground (low soil + zero pollution) grows grass over 50 ticks")
|
||||||
|
void bareGroundGrowsGrass() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Start with bare-ish ground: good soil, no pollution, low veg
|
||||||
|
SoilRegionData soil = new SoilRegionData(70.0, 50.0, 0.0, 10.0, 0.0);
|
||||||
|
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||||
|
VegetationRegionData veg = new VegetationRegionData(5.0, 5.0, 0.0, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
|
||||||
|
|
||||||
|
tick(region, 50);
|
||||||
|
|
||||||
|
VegetationRegionData after = region.getModuleData()
|
||||||
|
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElseThrow();
|
||||||
|
assertTrue(after.getGrassPressure() > 5.0,
|
||||||
|
"Grass should grow on bare ground with good soil, was: " + after.getGrassPressure());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("logging depletion reduces tree and shrub pressure over 20 ticks")
|
||||||
|
void loggingReducesTreeShrubPressure() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
ResourceRegionData resources = ResourceRegionData.defaults();
|
||||||
|
resources.recordLogging(80.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||||
|
VegetationRegionData vegData = new VegetationRegionData(50.0, 30.0, 50.0, 60.0, 5.0);
|
||||||
|
vegData.reduceFromLogging(80.0);
|
||||||
|
region.getModuleData().put(VegetationModule.MODULE_ID, vegData);
|
||||||
|
|
||||||
|
tick(region, 20);
|
||||||
|
|
||||||
|
VegetationRegionData after = region.getModuleData()
|
||||||
|
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElseThrow();
|
||||||
|
assertTrue(after.getTreePressure() < 60.0,
|
||||||
|
"Tree pressure should be reduced after logging");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("high vegetation purifies water quality over 30 ticks (pollution-free region)")
|
||||||
|
void vegetationPurifiesWater() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Start with high veg, good soil, slightly low water quality
|
||||||
|
VegetationRegionData vegData = new VegetationRegionData(80.0, 50.0, 60.0, 40.0, 5.0);
|
||||||
|
region.getModuleData().put(VegetationModule.MODULE_ID, vegData);
|
||||||
|
region.getMetrics().setWaterQuality(40.0);
|
||||||
|
|
||||||
|
tick(region, 30);
|
||||||
|
|
||||||
|
assertTrue(region.getMetrics().getWaterQuality() > 40.0,
|
||||||
|
"Water quality should improve when vegetation is healthy");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("ecosystem health improves after pollution is removed (over 50 ticks)")
|
||||||
|
void healthImprovesAfterPollutionRemoved() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// First: run 30 ticks with heavy pollution
|
||||||
|
PollutionRegionData heavyPollution = new PollutionRegionData(80.0, 80.0, 80.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, heavyPollution);
|
||||||
|
tick(region, 30);
|
||||||
|
double healthAfterPollution = region.getMetrics().getEcosystemHealth();
|
||||||
|
|
||||||
|
// Now remove pollution and run 50 more ticks
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
|
||||||
|
tick(region, 50);
|
||||||
|
|
||||||
|
assertTrue(region.getMetrics().getEcosystemHealth() > healthAfterPollution,
|
||||||
|
"Ecosystem health should improve once pollution is removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("succession advances from BARREN toward GRASSLAND under good conditions")
|
||||||
|
void successionAdvancesUnderGoodConditions() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Set good conditions exceeding all early stage thresholds
|
||||||
|
region.getMetrics().setSoilQuality(70.0);
|
||||||
|
region.getMetrics().setPollutionScore(0.0);
|
||||||
|
region.getMetrics().setVegetationPressure(40.0);
|
||||||
|
RecoveryRegionData recovery = new RecoveryRegionData(SuccessionStage.BARREN, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||||
|
|
||||||
|
tick(region, 300);
|
||||||
|
|
||||||
|
RecoveryRegionData result = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||||
|
assertTrue(result.getSuccessionStage().ordinal() > SuccessionStage.BARREN.ordinal(),
|
||||||
|
"Succession should advance beyond BARREN with good conditions");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("succession regresses under severe pollution over 30 ticks")
|
||||||
|
void successionRegressesUnderSeverePollution() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Start at SCRUBLAND
|
||||||
|
RecoveryRegionData recovery = new RecoveryRegionData(SuccessionStage.SCRUBLAND, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||||
|
// Force very bad conditions
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(95.0, 95.0, 95.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
SoilRegionData soil = new SoilRegionData(5.0, 0.0, 50.0, 30.0, 20.0);
|
||||||
|
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||||
|
|
||||||
|
tick(region, 30);
|
||||||
|
|
||||||
|
RecoveryRegionData result = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||||
|
// Either damage accumulated or regression happened
|
||||||
|
assertTrue(result.getDamageAccumulation() > 0.0
|
||||||
|
|| result.getSuccessionStage().ordinal() < SuccessionStage.SCRUBLAND.ordinal(),
|
||||||
|
"Succession should regress or take damage under severe pollution");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// World effects tests
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("GRASS_DEGRADES_TO_DIRT emitted when pollution is high and soil is poor")
|
||||||
|
void worldEffectGrassDegrades() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
SoilRegionData soil = new SoilRegionData(10.0, 0.0, 50.0, 30.0, 20.0);
|
||||||
|
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||||
|
|
||||||
|
// Prime the pipeline so metrics are set by pollution/soil before worldeffects runs
|
||||||
|
tick(region, 5);
|
||||||
|
effectsCapture.clear();
|
||||||
|
tick(region);
|
||||||
|
|
||||||
|
boolean found = effectsCapture.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
|
||||||
|
assertTrue(found,
|
||||||
|
"GRASS_DEGRADES_TO_DIRT should be emitted when soil is poor and pollution is high");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("VEGETATION_SPREADS emitted when vegetation and soil are healthy")
|
||||||
|
void worldEffectVegetationSpreads() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// Force high vegetation and good soil
|
||||||
|
VegetationRegionData veg = new VegetationRegionData(90.0, 50.0, 70.0, 60.0, 0.0);
|
||||||
|
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
|
||||||
|
SoilRegionData soil = new SoilRegionData(80.0, 60.0, 0.0, 5.0, 0.0);
|
||||||
|
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||||
|
|
||||||
|
tick(region, 3);
|
||||||
|
effectsCapture.clear();
|
||||||
|
tick(region);
|
||||||
|
|
||||||
|
boolean found = effectsCapture.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
|
||||||
|
assertTrue(found,
|
||||||
|
"VEGETATION_SPREADS should be emitted with high vegetation and good soil");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SAPLING_GROWTH_SLOWED emitted when logging depletion is high")
|
||||||
|
void worldEffectSaplingSlowed() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
ResourceRegionData resources = ResourceRegionData.defaults();
|
||||||
|
resources.recordLogging(80.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||||
|
|
||||||
|
effectsCapture.clear();
|
||||||
|
tick(region);
|
||||||
|
|
||||||
|
boolean found = effectsCapture.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
|
||||||
|
assertTrue(found, "SAPLING_GROWTH_SLOWED should be emitted with heavy logging depletion");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SAPLING_GROWTH_BOOSTED emitted when region reaches YOUNG_WOODLAND")
|
||||||
|
void worldEffectSaplingBoosted() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
RecoveryRegionData recovery = new RecoveryRegionData(
|
||||||
|
SuccessionStage.YOUNG_WOODLAND, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||||
|
|
||||||
|
effectsCapture.clear();
|
||||||
|
tick(region);
|
||||||
|
|
||||||
|
boolean found = effectsCapture.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
|
||||||
|
assertTrue(found, "SAPLING_GROWTH_BOOSTED should be emitted at YOUNG_WOODLAND stage");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("emitted WorldEffectRequests all have intensity in [0, 1]")
|
||||||
|
void worldEffectIntensitiesInRange() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
SoilRegionData soil = new SoilRegionData(10.0, 0.0, 50.0, 30.0, 20.0);
|
||||||
|
region.getModuleData().put(SoilModule.MODULE_ID, soil);
|
||||||
|
tick(region, 5);
|
||||||
|
effectsCapture.clear();
|
||||||
|
tick(region);
|
||||||
|
|
||||||
|
for (WorldEffectRequest req : effectsCapture) {
|
||||||
|
assertTrue(req.intensity() >= 0.0 && req.intensity() <= 1.0,
|
||||||
|
"Intensity out of range: " + req.intensity() + " for " + req.type());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Pipeline ordering test
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("pollution metric written in same tick is read by soil and water modules")
|
||||||
|
void pipelineOrderVerified() {
|
||||||
|
Region region = freshRegion();
|
||||||
|
// High ground pollution → pollutionScore computed by PollutionModule
|
||||||
|
// → SoilModule reads pollutionScore → contamination raised
|
||||||
|
// → WaterModule reads soilQuality → leach applied
|
||||||
|
PollutionRegionData pollution = new PollutionRegionData(0.0, 90.0, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
|
||||||
|
|
||||||
|
// Single tick — everything computed in sequence
|
||||||
|
tick(region);
|
||||||
|
|
||||||
|
// pollutionScore = ground * 0.35 = 31.5 > threshold → soil contamination increases
|
||||||
|
SoilRegionData soilAfter = region.getModuleData()
|
||||||
|
.get(SoilModule.MODULE_ID, SoilRegionData.class).orElseThrow();
|
||||||
|
assertTrue(soilAfter.getContamination() > 0.0,
|
||||||
|
"SoilModule should have read the pollution metric set by PollutionModule in the same tick");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void assertMetricsInRange(RegionMetrics m) {
|
||||||
|
assertTrue(m.getEcosystemHealth() >= 0 && m.getEcosystemHealth() <= 100, "ecosystemHealth out of range: " + m.getEcosystemHealth());
|
||||||
|
assertTrue(m.getPollutionScore() >= 0 && m.getPollutionScore() <= 100, "pollutionScore out of range: " + m.getPollutionScore());
|
||||||
|
assertTrue(m.getSoilQuality() >= 0 && m.getSoilQuality() <= 100, "soilQuality out of range: " + m.getSoilQuality());
|
||||||
|
assertTrue(m.getWaterQuality() >= 0 && m.getWaterQuality() <= 100, "waterQuality out of range: " + m.getWaterQuality());
|
||||||
|
assertTrue(m.getVegetationPressure() >= 0 && m.getVegetationPressure() <= 100, "vegetationPressure out of range: " + m.getVegetationPressure());
|
||||||
|
assertTrue(m.getResourceDepletion() >= 0 && m.getResourceDepletion() <= 100, "resourceDepletion out of range: " + m.getResourceDepletion());
|
||||||
|
assertTrue(m.getRecoveryPressure() >= 0 && m.getRecoveryPressure() <= 100, "recoveryPressure out of range: " + m.getRecoveryPressure());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.livingworld.modules.ecosystem;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class EcosystemRegionDataTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultsAreModerateHealth() {
|
||||||
|
EcosystemRegionData d = EcosystemRegionData.defaults();
|
||||||
|
assertEquals(60.0, d.getEcosystemHealth(), 1e-9);
|
||||||
|
assertEquals(20.0, d.getStress(), 1e-9);
|
||||||
|
assertEquals(50.0, d.getResilience(), 1e-9);
|
||||||
|
assertEquals(5.0, d.getRecoveryRate(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyStressIncreasesStressAndDegradesResilience() {
|
||||||
|
EcosystemRegionData d = EcosystemRegionData.defaults();
|
||||||
|
d.applyStress(10.0);
|
||||||
|
assertEquals(30.0, d.getStress(), 1e-9);
|
||||||
|
assertTrue(d.getResilience() < 50.0, "resilience should decrease under stress");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyStressNegativeThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> EcosystemRegionData.defaults().applyStress(-1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyStressClampsAt100() {
|
||||||
|
EcosystemRegionData d = new EcosystemRegionData(60.0, 90.0, 50.0, 5.0);
|
||||||
|
d.applyStress(20.0);
|
||||||
|
assertEquals(100.0, d.getStress(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyRecoveryDecreasesStressAndIncreasesResilience() {
|
||||||
|
EcosystemRegionData d = new EcosystemRegionData(60.0, 40.0, 50.0, 5.0);
|
||||||
|
d.applyRecovery(10.0);
|
||||||
|
assertEquals(30.0, d.getStress(), 1e-9);
|
||||||
|
assertTrue(d.getResilience() > 50.0, "resilience should increase during recovery");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyRecoveryNegativeThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> EcosystemRegionData.defaults().applyRecovery(-1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyRecoveryClampsBelowZero() {
|
||||||
|
EcosystemRegionData d = new EcosystemRegionData(60.0, 5.0, 50.0, 5.0);
|
||||||
|
d.applyRecovery(20.0);
|
||||||
|
assertEquals(0.0, d.getStress(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorClampsAbove100() {
|
||||||
|
EcosystemRegionData d = new EcosystemRegionData(200.0, 200.0, 200.0, 200.0);
|
||||||
|
assertEquals(100.0, d.getEcosystemHealth(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getStress(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getResilience(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getRecoveryRate(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorClampsBelowZero() {
|
||||||
|
EcosystemRegionData d = new EcosystemRegionData(-1.0, -1.0, -1.0, -1.0);
|
||||||
|
assertEquals(0.0, d.getEcosystemHealth(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getStress(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getResilience(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getRecoveryRate(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void copyIsIndependent() {
|
||||||
|
EcosystemRegionData original = EcosystemRegionData.defaults();
|
||||||
|
EcosystemRegionData copy = original.copy();
|
||||||
|
copy.applyStress(50.0);
|
||||||
|
assertEquals(20.0, original.getStress(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settersClampValues() {
|
||||||
|
EcosystemRegionData d = EcosystemRegionData.defaults();
|
||||||
|
d.setStress(-10.0);
|
||||||
|
d.setResilience(999.0);
|
||||||
|
assertEquals(0.0, d.getStress(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getResilience(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizeDoesNotChangeLegalValues() {
|
||||||
|
EcosystemRegionData d = EcosystemRegionData.defaults();
|
||||||
|
d.normalize();
|
||||||
|
assertEquals(60.0, d.getEcosystemHealth(), 1e-9);
|
||||||
|
assertEquals(20.0, d.getStress(), 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.livingworld.modules.pollution;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class PollutionRegionDataTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultsHaveZeroPollution() {
|
||||||
|
PollutionRegionData d = PollutionRegionData.defaults();
|
||||||
|
assertEquals(0.0, d.getAirPollution(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getGroundPollution(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getWaterPollution(), 1e-9);
|
||||||
|
assertEquals(20.0, d.getDecayResistance(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addPollutionAccumulates() {
|
||||||
|
PollutionRegionData d = PollutionRegionData.defaults();
|
||||||
|
d.addPollution(10.0, 20.0, 5.0);
|
||||||
|
assertEquals(10.0, d.getAirPollution(), 1e-9);
|
||||||
|
assertEquals(20.0, d.getGroundPollution(), 1e-9);
|
||||||
|
assertEquals(5.0, d.getWaterPollution(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addPollutionClampsAt100() {
|
||||||
|
PollutionRegionData d = new PollutionRegionData(90.0, 90.0, 90.0, 20.0);
|
||||||
|
d.addPollution(20.0, 20.0, 20.0);
|
||||||
|
assertEquals(100.0, d.getAirPollution(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getGroundPollution(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getWaterPollution(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decayReducesAllPollution() {
|
||||||
|
PollutionRegionData d = new PollutionRegionData(50.0, 50.0, 50.0, 0.0);
|
||||||
|
d.decay(0.02);
|
||||||
|
// With zero resistance: effectiveRate = 0.02 * 1.0 = 0.02
|
||||||
|
// air: 50 * (1 - 0.04) = 48
|
||||||
|
// ground: 50 * (1 - 0.01) = 49.5
|
||||||
|
// water: 50 * (1 - 0.006) = 49.7
|
||||||
|
assertTrue(d.getAirPollution() < 50.0);
|
||||||
|
assertTrue(d.getGroundPollution() < 50.0);
|
||||||
|
assertTrue(d.getWaterPollution() < 50.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decayWithHighResistanceIsSlower() {
|
||||||
|
PollutionRegionData low = new PollutionRegionData(50.0, 50.0, 50.0, 0.0);
|
||||||
|
PollutionRegionData high = new PollutionRegionData(50.0, 50.0, 50.0, 100.0);
|
||||||
|
low.decay(0.02);
|
||||||
|
high.decay(0.02);
|
||||||
|
assertTrue(high.getAirPollution() > low.getAirPollution(),
|
||||||
|
"High resistance should leave more air pollution after decay");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decayOnZeroPollutionStaysZero() {
|
||||||
|
PollutionRegionData d = PollutionRegionData.defaults();
|
||||||
|
d.decay(0.10);
|
||||||
|
assertEquals(0.0, d.getAirPollution(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getGroundPollution(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getWaterPollution(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorClampsNegativeValues() {
|
||||||
|
PollutionRegionData d = new PollutionRegionData(-10.0, -5.0, -1.0, -50.0);
|
||||||
|
assertEquals(0.0, d.getAirPollution(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getGroundPollution(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getWaterPollution(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getDecayResistance(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorClampsAbove100() {
|
||||||
|
PollutionRegionData d = new PollutionRegionData(200.0, 150.0, 110.0, 999.0);
|
||||||
|
assertEquals(100.0, d.getAirPollution(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getGroundPollution(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getWaterPollution(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getDecayResistance(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void copyIsIndependent() {
|
||||||
|
PollutionRegionData original = new PollutionRegionData(30.0, 40.0, 10.0, 25.0);
|
||||||
|
PollutionRegionData copy = original.copy();
|
||||||
|
copy.addPollution(50.0, 50.0, 50.0);
|
||||||
|
assertEquals(30.0, original.getAirPollution(), 1e-9);
|
||||||
|
assertEquals(40.0, original.getGroundPollution(), 1e-9);
|
||||||
|
assertEquals(10.0, original.getWaterPollution(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizeDoesNotChangeValidValues() {
|
||||||
|
PollutionRegionData d = new PollutionRegionData(30.0, 40.0, 10.0, 25.0);
|
||||||
|
d.normalize();
|
||||||
|
assertEquals(30.0, d.getAirPollution(), 1e-9);
|
||||||
|
assertEquals(40.0, d.getGroundPollution(), 1e-9);
|
||||||
|
assertEquals(10.0, d.getWaterPollution(), 1e-9);
|
||||||
|
assertEquals(25.0, d.getDecayResistance(), 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.livingworld.modules.recovery;
|
||||||
|
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@DisplayName("RecoveryModule")
|
||||||
|
class RecoveryModuleTest {
|
||||||
|
|
||||||
|
private RecoveryModule module;
|
||||||
|
private RegionFactory factory;
|
||||||
|
private Region region;
|
||||||
|
private RegionMetrics metrics;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
module = new RecoveryModule();
|
||||||
|
factory = new RegionFactory();
|
||||||
|
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||||
|
metrics = region.getMetrics();
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("moduleId is 'recovery'")
|
||||||
|
void moduleId() {
|
||||||
|
assertEquals("recovery", module.getModuleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("metadata is non-null with correct id")
|
||||||
|
void metadata() {
|
||||||
|
assertNotNull(module.getMetadata());
|
||||||
|
assertEquals("recovery", module.getMetadata().moduleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("initialize throws on null context")
|
||||||
|
void initializeNullThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createDefaultRegionData populates module data at GRASSLAND stage")
|
||||||
|
void createDefaultPopulates() {
|
||||||
|
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||||
|
module.createDefaultRegionData(fresh);
|
||||||
|
RecoveryRegionData data = fresh.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||||
|
assertEquals(SuccessionStage.GRASSLAND, data.getSuccessionStage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createDefaultRegionData is idempotent")
|
||||||
|
void createDefaultIdempotent() {
|
||||||
|
SuccessionStage before = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow()
|
||||||
|
.getSuccessionStage();
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
SuccessionStage after = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow()
|
||||||
|
.getSuccessionStage();
|
||||||
|
assertEquals(before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("updateRegion throws on null context")
|
||||||
|
void updateNullThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("good conditions advance recovery progress")
|
||||||
|
void goodConditionsAdvanceProgress() {
|
||||||
|
// GRASSLAND thresholds: minSoil=25, maxPollution=70, minVeg=20
|
||||||
|
metrics.setSoilQuality(70.0);
|
||||||
|
metrics.setPollutionScore(10.0);
|
||||||
|
metrics.setVegetationPressure(50.0);
|
||||||
|
metrics.setEcosystemHealth(50.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
RecoveryRegionData data = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||||
|
assertTrue(data.getRecoveryProgress() > 0.0,
|
||||||
|
"Recovery progress should advance under good conditions");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("bad conditions accumulate damage over several ticks")
|
||||||
|
void badConditionsAccumulateDamage() {
|
||||||
|
// Put region at SCRUBLAND so it CAN regress.
|
||||||
|
// Force conditions way below SCRUBLAND minimums to trigger regression checks.
|
||||||
|
RecoveryRegionData startData = new RecoveryRegionData(
|
||||||
|
SuccessionStage.SCRUBLAND, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(RecoveryModule.MODULE_ID, startData);
|
||||||
|
|
||||||
|
metrics.setSoilQuality(5.0); // far below minSoil=40 (SCRUBLAND)
|
||||||
|
metrics.setPollutionScore(95.0); // far above maxPollution=60 (SCRUBLAND)
|
||||||
|
metrics.setVegetationPressure(2.0); // far below minVeg=35 (SCRUBLAND)
|
||||||
|
|
||||||
|
for (int i = 0; i < 25; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
RecoveryRegionData result = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||||
|
// Either damage accumulated or regression already happened
|
||||||
|
assertTrue(result.getDamageAccumulation() > 0.0
|
||||||
|
|| result.getSuccessionStage().ordinal() < SuccessionStage.SCRUBLAND.ordinal(),
|
||||||
|
"Bad conditions should accumulate damage or cause regression");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("enough good ticks cause succession stage advancement")
|
||||||
|
void enoughGoodTicksAdvanceStage() {
|
||||||
|
// Start at BARREN — advancement thresholds: minSoil=10, maxPollution=80, minVeg=10
|
||||||
|
RecoveryRegionData data = new RecoveryRegionData(SuccessionStage.BARREN, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(RecoveryModule.MODULE_ID, data);
|
||||||
|
metrics.setSoilQuality(60.0);
|
||||||
|
metrics.setPollutionScore(5.0);
|
||||||
|
metrics.setVegetationPressure(30.0);
|
||||||
|
metrics.setEcosystemHealth(80.0); // triggers health bonus
|
||||||
|
|
||||||
|
for (int i = 0; i < 250; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
RecoveryRegionData result = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
|
||||||
|
assertTrue(result.getSuccessionStage().ordinal() > SuccessionStage.BARREN.ordinal(),
|
||||||
|
"Stage should advance with enough good ticks");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("recoveryPressure metric is written and stays in [0, 100]")
|
||||||
|
void recoveryPressureMetricWritten() {
|
||||||
|
metrics.setSoilQuality(70.0);
|
||||||
|
metrics.setPollutionScore(0.0);
|
||||||
|
metrics.setVegetationPressure(50.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
double pressure = region.getMetrics().getRecoveryPressure();
|
||||||
|
assertTrue(pressure >= 0.0 && pressure <= 100.0,
|
||||||
|
"Recovery pressure must be in [0,100], was: " + pressure);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MATURE_FOREST stage has zero base recovery pressure")
|
||||||
|
void matureForestZeroPressure() {
|
||||||
|
RecoveryRegionData data = new RecoveryRegionData(
|
||||||
|
SuccessionStage.MATURE_FOREST, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(RecoveryModule.MODULE_ID, data);
|
||||||
|
metrics.setSoilQuality(70.0);
|
||||||
|
metrics.setPollutionScore(0.0);
|
||||||
|
metrics.setVegetationPressure(50.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
// stagesFromPeak = 0 → base pressure = 0 + damage * 0.3 = 0
|
||||||
|
assertEquals(0.0, region.getMetrics().getRecoveryPressure(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returns changed when progress increases")
|
||||||
|
void changedWhenProgressIncreases() {
|
||||||
|
metrics.setSoilQuality(70.0);
|
||||||
|
metrics.setPollutionScore(5.0);
|
||||||
|
metrics.setVegetationPressure(50.0);
|
||||||
|
|
||||||
|
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertTrue(result.changedRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returns noChange when neither advancement nor regression conditions are met")
|
||||||
|
void noChangeWhenNeutral() {
|
||||||
|
// GRASSLAND advancement: minSoil=25, maxPollution=70, minVeg=20
|
||||||
|
// GRASSLAND regression (50% severity): soil<12.5, pollution>84, veg<10
|
||||||
|
// Put values between these two sets: soil=20 (below advance, above regress),
|
||||||
|
// pollution=10 (fine), veg=15 (below advance threshold 20, above regress threshold 10)
|
||||||
|
metrics.setSoilQuality(20.0);
|
||||||
|
metrics.setPollutionScore(10.0);
|
||||||
|
metrics.setVegetationPressure(15.0);
|
||||||
|
|
||||||
|
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertFalse(result.changedRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("recovery pressure stays in [0, 100] over 1000 ticks with bad conditions")
|
||||||
|
void recoveryPressureBounded() {
|
||||||
|
metrics.setSoilQuality(5.0);
|
||||||
|
metrics.setPollutionScore(90.0);
|
||||||
|
metrics.setVegetationPressure(5.0);
|
||||||
|
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
double p = region.getMetrics().getRecoveryPressure();
|
||||||
|
assertTrue(p >= 0.0 && p <= 100.0, "Recovery pressure out of range: " + p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package com.livingworld.modules.resources;
|
||||||
|
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@DisplayName("ResourceDepletionModule")
|
||||||
|
class ResourceDepletionModuleTest {
|
||||||
|
|
||||||
|
private ResourceDepletionModule module;
|
||||||
|
private RegionFactory factory;
|
||||||
|
private Region region;
|
||||||
|
private RegionMetrics metrics;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
module = new ResourceDepletionModule();
|
||||||
|
factory = new RegionFactory();
|
||||||
|
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||||
|
metrics = region.getMetrics();
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("moduleId is 'resources'")
|
||||||
|
void moduleId() {
|
||||||
|
assertEquals("resources", module.getModuleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("metadata is non-null with correct id")
|
||||||
|
void metadata() {
|
||||||
|
assertNotNull(module.getMetadata());
|
||||||
|
assertEquals("resources", module.getMetadata().moduleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("initialize throws on null context")
|
||||||
|
void initializeNullThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createDefaultRegionData populates module data")
|
||||||
|
void createDefaultPopulates() {
|
||||||
|
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||||
|
module.createDefaultRegionData(fresh);
|
||||||
|
assertTrue(fresh.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createDefaultRegionData is idempotent")
|
||||||
|
void createDefaultIdempotent() {
|
||||||
|
ResourceRegionData before = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
ResourceRegionData after = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||||
|
assertEquals(before.getMiningDepletion(), after.getMiningDepletion(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("updateRegion throws on null context")
|
||||||
|
void updateNullThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("mining depletion regenerates very slowly (geological timescale)")
|
||||||
|
void miningRegeneratesSlowly() {
|
||||||
|
ResourceRegionData data = ResourceRegionData.defaults();
|
||||||
|
data.recordMining(50.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||||
|
|
||||||
|
// After 1000 ticks, mining depletion should be very close to 50 (barely moved)
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
ResourceRegionData after = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||||
|
assertTrue(after.getMiningDepletion() > 40.0,
|
||||||
|
"Mining depletion should recover very slowly, was: " + after.getMiningDepletion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("logging depletion regenerates — drops from 80 over 100 ticks")
|
||||||
|
void loggingRegenerates() {
|
||||||
|
ResourceRegionData data = ResourceRegionData.defaults();
|
||||||
|
data.recordLogging(80.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||||
|
metrics.setVegetationPressure(30.0); // below bonus threshold
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
ResourceRegionData after = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||||
|
assertTrue(after.getLoggingDepletion() < 80.0,
|
||||||
|
"Logging depletion should decrease, was: " + after.getLoggingDepletion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("high vegetation pressure accelerates logging regeneration")
|
||||||
|
void highVegetationAcceleratesLoggingRegen() {
|
||||||
|
ResourceRegionData data = ResourceRegionData.defaults();
|
||||||
|
data.recordLogging(80.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||||
|
metrics.setVegetationPressure(80.0); // above threshold
|
||||||
|
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
double withHighVeg = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
|
||||||
|
.getLoggingDepletion();
|
||||||
|
|
||||||
|
// Reset to same starting state with low vegetation
|
||||||
|
Region region2 = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||||
|
module.createDefaultRegionData(region2);
|
||||||
|
ResourceRegionData data2 = ResourceRegionData.defaults();
|
||||||
|
data2.recordLogging(80.0);
|
||||||
|
region2.getModuleData().put(ResourceDepletionModule.MODULE_ID, data2);
|
||||||
|
region2.getMetrics().setVegetationPressure(0.0);
|
||||||
|
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region2));
|
||||||
|
}
|
||||||
|
double withLowVeg = region2.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
|
||||||
|
.getLoggingDepletion();
|
||||||
|
|
||||||
|
assertTrue(withHighVeg < withLowVeg,
|
||||||
|
"Higher vegetation should accelerate logging regen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("farming depletion regenerates — drops from 60 over 100 ticks")
|
||||||
|
void farmingRegenerates() {
|
||||||
|
ResourceRegionData data = ResourceRegionData.defaults();
|
||||||
|
data.recordFarming(60.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||||
|
metrics.setSoilQuality(30.0);
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
ResourceRegionData after = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||||
|
assertTrue(after.getFarmingDepletion() < 60.0,
|
||||||
|
"Farming depletion should decrease, was: " + after.getFarmingDepletion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("high soil quality accelerates farming regeneration")
|
||||||
|
void highSoilAcceleratesFarmingRegen() {
|
||||||
|
ResourceRegionData data = ResourceRegionData.defaults();
|
||||||
|
data.recordFarming(60.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||||
|
metrics.setSoilQuality(80.0);
|
||||||
|
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
double withHighSoil = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
|
||||||
|
.getFarmingDepletion();
|
||||||
|
|
||||||
|
Region region2 = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||||
|
module.createDefaultRegionData(region2);
|
||||||
|
ResourceRegionData data2 = ResourceRegionData.defaults();
|
||||||
|
data2.recordFarming(60.0);
|
||||||
|
region2.getModuleData().put(ResourceDepletionModule.MODULE_ID, data2);
|
||||||
|
region2.getMetrics().setSoilQuality(20.0);
|
||||||
|
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region2));
|
||||||
|
}
|
||||||
|
double withLowSoil = region2.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
|
||||||
|
.getFarmingDepletion();
|
||||||
|
|
||||||
|
assertTrue(withHighSoil < withLowSoil,
|
||||||
|
"Higher soil quality should accelerate farming regen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("resourceDepletion metric is written after update")
|
||||||
|
void metricWritten() {
|
||||||
|
ResourceRegionData data = ResourceRegionData.defaults();
|
||||||
|
data.recordMining(50.0);
|
||||||
|
data.recordLogging(40.0);
|
||||||
|
data.recordFarming(20.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertTrue(region.getMetrics().getResourceDepletion() > 0.0,
|
||||||
|
"Resource depletion metric should be set");
|
||||||
|
assertTrue(region.getMetrics().getResourceDepletion() <= 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returns noChange for pristine (zero depletion) region")
|
||||||
|
void noChangeWhenPristine() {
|
||||||
|
// defaults are all-zero depletion; regen of zero is zero delta
|
||||||
|
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
assertFalse(result.changedRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returns changed when depletion drops by more than threshold")
|
||||||
|
void changedWhenDepletionDrops() {
|
||||||
|
// High vegetation triggers bonus logging regen (0.255/tick × 30% weight = 0.077 delta > 0.01)
|
||||||
|
metrics.setVegetationPressure(100.0);
|
||||||
|
ResourceRegionData data = ResourceRegionData.defaults();
|
||||||
|
data.recordLogging(100.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||||
|
|
||||||
|
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertTrue(result.changedRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("all depletion values stay in [0, 100] under extreme conditions")
|
||||||
|
void depletionsBounded() {
|
||||||
|
ResourceRegionData data = ResourceRegionData.defaults();
|
||||||
|
data.recordMining(100.0);
|
||||||
|
data.recordLogging(100.0);
|
||||||
|
data.recordFarming(100.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
|
||||||
|
|
||||||
|
for (int i = 0; i < 500; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
ResourceRegionData result = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
|
||||||
|
assertTrue(result.getMiningDepletion() >= 0.0 && result.getMiningDepletion() <= 100.0);
|
||||||
|
assertTrue(result.getLoggingDepletion() >= 0.0 && result.getLoggingDepletion() <= 100.0);
|
||||||
|
assertTrue(result.getFarmingDepletion() >= 0.0 && result.getFarmingDepletion() <= 100.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.livingworld.modules.soil;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class SoilRegionDataTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultsAreHealthyValues() {
|
||||||
|
SoilRegionData d = SoilRegionData.defaults();
|
||||||
|
assertEquals(60.0, d.getFertility(), 1e-9);
|
||||||
|
assertEquals(50.0, d.getMoisture(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getContamination(), 1e-9);
|
||||||
|
assertEquals(10.0, d.getCompaction(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getErosion(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void degradeReducesFertilityAndIncreasesContaminationAndErosion() {
|
||||||
|
SoilRegionData d = SoilRegionData.defaults();
|
||||||
|
d.degrade(10.0);
|
||||||
|
assertTrue(d.getFertility() < 60.0, "fertility should decrease");
|
||||||
|
assertTrue(d.getContamination() > 0.0, "contamination should increase");
|
||||||
|
assertTrue(d.getErosion() > 0.0, "erosion should increase");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void degradeByZeroChangesNothing() {
|
||||||
|
SoilRegionData d = SoilRegionData.defaults();
|
||||||
|
d.degrade(0.0);
|
||||||
|
assertEquals(60.0, d.getFertility(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getContamination(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getErosion(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void degradeNegativeAmountThrows() {
|
||||||
|
SoilRegionData d = SoilRegionData.defaults();
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> d.degrade(-1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recoverIncreasesFertilityAndReducesContaminationAndErosion() {
|
||||||
|
SoilRegionData d = new SoilRegionData(40.0, 50.0, 20.0, 10.0, 15.0);
|
||||||
|
d.recover(10.0);
|
||||||
|
assertTrue(d.getFertility() > 40.0, "fertility should increase");
|
||||||
|
assertTrue(d.getContamination() < 20.0, "contamination should decrease");
|
||||||
|
assertTrue(d.getErosion() < 15.0, "erosion should decrease");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recoverNegativeAmountThrows() {
|
||||||
|
SoilRegionData d = SoilRegionData.defaults();
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> d.recover(-1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void valuesAreClampedAbove100() {
|
||||||
|
SoilRegionData d = new SoilRegionData(200.0, 200.0, 200.0, 200.0, 200.0);
|
||||||
|
assertEquals(100.0, d.getFertility(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getMoisture(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getContamination(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getCompaction(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getErosion(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void valuesAreClampedBelowZero() {
|
||||||
|
SoilRegionData d = new SoilRegionData(-10.0, -10.0, -10.0, -10.0, -10.0);
|
||||||
|
assertEquals(0.0, d.getFertility(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getMoisture(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getContamination(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getCompaction(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getErosion(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void copyIsIndependent() {
|
||||||
|
SoilRegionData original = SoilRegionData.defaults();
|
||||||
|
SoilRegionData copy = original.copy();
|
||||||
|
copy.degrade(30.0);
|
||||||
|
assertEquals(60.0, original.getFertility(), 1e-9);
|
||||||
|
assertEquals(0.0, original.getContamination(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settersClampValues() {
|
||||||
|
SoilRegionData d = SoilRegionData.defaults();
|
||||||
|
d.setFertility(-5.0);
|
||||||
|
d.setErosion(999.0);
|
||||||
|
assertEquals(0.0, d.getFertility(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getErosion(), 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.livingworld.modules.vegetation;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class VegetationRegionDataTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultsAreHealthyMixedVegetation() {
|
||||||
|
VegetationRegionData d = VegetationRegionData.defaults();
|
||||||
|
assertEquals(50.0, d.getGrassPressure(), 1e-9);
|
||||||
|
assertEquals(30.0, d.getFlowerPressure(), 1e-9);
|
||||||
|
assertEquals(30.0, d.getShrubPressure(), 1e-9);
|
||||||
|
assertEquals(40.0, d.getTreePressure(), 1e-9);
|
||||||
|
assertEquals(5.0, d.getDeadVegetation(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reduceFromLoggingDecreasesTressAndShrubs() {
|
||||||
|
VegetationRegionData d = VegetationRegionData.defaults();
|
||||||
|
d.reduceFromLogging(20.0);
|
||||||
|
assertTrue(d.getTreePressure() < 40.0, "tree pressure should drop");
|
||||||
|
assertTrue(d.getShrubPressure() < 30.0, "shrub pressure should drop");
|
||||||
|
assertTrue(d.getDeadVegetation() > 5.0, "dead vegetation should increase");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reduceFromLoggingByZeroChangesNothing() {
|
||||||
|
VegetationRegionData d = VegetationRegionData.defaults();
|
||||||
|
d.reduceFromLogging(0.0);
|
||||||
|
assertEquals(40.0, d.getTreePressure(), 1e-9);
|
||||||
|
assertEquals(30.0, d.getShrubPressure(), 1e-9);
|
||||||
|
assertEquals(5.0, d.getDeadVegetation(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reduceFromLoggingNegativeThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> VegetationRegionData.defaults().reduceFromLogging(-1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recoverIncreasesAllLivingPressures() {
|
||||||
|
VegetationRegionData d = new VegetationRegionData(10.0, 5.0, 5.0, 5.0, 30.0);
|
||||||
|
d.recover(10.0);
|
||||||
|
assertTrue(d.getGrassPressure() > 10.0, "grass should increase");
|
||||||
|
assertTrue(d.getFlowerPressure() > 5.0, "flowers should increase");
|
||||||
|
assertTrue(d.getShrubPressure() > 5.0, "shrubs should increase");
|
||||||
|
assertTrue(d.getTreePressure() > 5.0, "trees should increase");
|
||||||
|
assertTrue(d.getDeadVegetation() < 30.0, "dead vegetation should decrease");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recoverNegativeThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> VegetationRegionData.defaults().recover(-1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorClampsAbove100() {
|
||||||
|
VegetationRegionData d = new VegetationRegionData(200.0, 200.0, 200.0, 200.0, 200.0);
|
||||||
|
assertEquals(100.0, d.getGrassPressure(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getFlowerPressure(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getShrubPressure(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getTreePressure(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getDeadVegetation(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void constructorClampsBelowZero() {
|
||||||
|
VegetationRegionData d = new VegetationRegionData(-1.0, -1.0, -1.0, -1.0, -1.0);
|
||||||
|
assertEquals(0.0, d.getGrassPressure(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getFlowerPressure(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getShrubPressure(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getTreePressure(), 1e-9);
|
||||||
|
assertEquals(0.0, d.getDeadVegetation(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void copyIsIndependent() {
|
||||||
|
VegetationRegionData original = VegetationRegionData.defaults();
|
||||||
|
VegetationRegionData copy = original.copy();
|
||||||
|
copy.reduceFromLogging(40.0);
|
||||||
|
assertEquals(40.0, original.getTreePressure(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settersClampValues() {
|
||||||
|
VegetationRegionData d = VegetationRegionData.defaults();
|
||||||
|
d.setGrassPressure(-5.0);
|
||||||
|
d.setDeadVegetation(999.0);
|
||||||
|
assertEquals(0.0, d.getGrassPressure(), 1e-9);
|
||||||
|
assertEquals(100.0, d.getDeadVegetation(), 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.livingworld.modules.water;
|
||||||
|
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@DisplayName("WaterModule")
|
||||||
|
class WaterModuleTest {
|
||||||
|
|
||||||
|
private WaterModule module;
|
||||||
|
private RegionFactory factory;
|
||||||
|
private Region region;
|
||||||
|
private RegionMetrics metrics;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
module = new WaterModule();
|
||||||
|
factory = new RegionFactory();
|
||||||
|
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||||
|
metrics = region.getMetrics();
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("moduleId is 'water'")
|
||||||
|
void moduleId() {
|
||||||
|
assertEquals("water", module.getModuleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("metadata is non-null and has correct id")
|
||||||
|
void metadata() {
|
||||||
|
assertNotNull(module.getMetadata());
|
||||||
|
assertEquals("water", module.getMetadata().moduleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("initialize throws on null context")
|
||||||
|
void initializeNullContextThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createDefaultRegionData populates module data")
|
||||||
|
void createDefaultRegionDataPopulates() {
|
||||||
|
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
|
||||||
|
module.createDefaultRegionData(fresh);
|
||||||
|
assertTrue(fresh.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createDefaultRegionData is idempotent")
|
||||||
|
void createDefaultRegionDataIdempotent() {
|
||||||
|
WaterRegionData before = region.getModuleData()
|
||||||
|
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
WaterRegionData after = region.getModuleData()
|
||||||
|
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
|
||||||
|
assertEquals(before.getPurificationCapacity(), after.getPurificationCapacity(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("updateRegion throws on null context")
|
||||||
|
void updateRegionNullThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("high vegetation pressure sets purification capacity")
|
||||||
|
void highVegetationIncreasesPurification() {
|
||||||
|
metrics.setVegetationPressure(80.0);
|
||||||
|
metrics.setWaterQuality(50.0);
|
||||||
|
metrics.setSoilQuality(70.0); // above leach threshold
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
WaterRegionData data = region.getModuleData()
|
||||||
|
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
|
||||||
|
// purification = 80 * 0.50 = 40
|
||||||
|
assertEquals(40.0, data.getPurificationCapacity(), 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("vegetation purification raises water quality")
|
||||||
|
void vegetationPurificationRaisesWaterQuality() {
|
||||||
|
metrics.setVegetationPressure(80.0);
|
||||||
|
metrics.setWaterQuality(50.0);
|
||||||
|
metrics.setSoilQuality(70.0); // above leach threshold — no leaching
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
// Recovery = 40 * 0.01 = 0.4 gain
|
||||||
|
assertTrue(region.getMetrics().getWaterQuality() > 50.0,
|
||||||
|
"Water quality should rise with vegetation purification");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("low soil quality leaches contamination into water")
|
||||||
|
void lowSoilQualityLeachesWater() {
|
||||||
|
metrics.setVegetationPressure(0.0); // no purification
|
||||||
|
metrics.setWaterQuality(80.0);
|
||||||
|
metrics.setSoilQuality(10.0); // well below threshold of 40
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
// leach = (40 - 10) * 0.005 = 0.15 reduction
|
||||||
|
assertTrue(region.getMetrics().getWaterQuality() < 80.0,
|
||||||
|
"Water quality should fall when soil is contaminated");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("soil above threshold causes no leaching")
|
||||||
|
void soilAboveThresholdNoLeach() {
|
||||||
|
metrics.setVegetationPressure(0.0);
|
||||||
|
metrics.setWaterQuality(60.0);
|
||||||
|
metrics.setSoilQuality(50.0); // above threshold
|
||||||
|
|
||||||
|
double prevWQ = metrics.getWaterQuality();
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
// No leach; no purification either (veg=0). Water quality unchanged.
|
||||||
|
assertEquals(prevWQ, region.getMetrics().getWaterQuality(), 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returns noChange when water quality is stable")
|
||||||
|
void noChangeWhenStable() {
|
||||||
|
metrics.setVegetationPressure(0.0);
|
||||||
|
metrics.setSoilQuality(50.0);
|
||||||
|
metrics.setWaterQuality(60.0);
|
||||||
|
|
||||||
|
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertFalse(result.changedRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returns changed when water quality shifts")
|
||||||
|
void changedWhenWaterQualityShifts() {
|
||||||
|
metrics.setVegetationPressure(80.0);
|
||||||
|
metrics.setSoilQuality(70.0);
|
||||||
|
metrics.setWaterQuality(50.0);
|
||||||
|
|
||||||
|
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertTrue(result.changedRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("water quality stays in [0, 100] under extreme conditions")
|
||||||
|
void waterQualityBounded() {
|
||||||
|
metrics.setVegetationPressure(100.0);
|
||||||
|
metrics.setSoilQuality(0.0);
|
||||||
|
metrics.setWaterQuality(0.0);
|
||||||
|
|
||||||
|
for (int i = 0; i < 200; i++) {
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
}
|
||||||
|
|
||||||
|
double wq = region.getMetrics().getWaterQuality();
|
||||||
|
assertTrue(wq >= 0.0 && wq <= 100.0,
|
||||||
|
"Water quality must stay in [0, 100], was: " + wq);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package com.livingworld.modules.worldeffects;
|
||||||
|
|
||||||
|
import com.livingworld.modules.ModuleUpdateResult;
|
||||||
|
import com.livingworld.modules.RegionUpdateContext;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryModule;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
|
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||||
|
import com.livingworld.modules.resources.ResourceRegionData;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
import com.livingworld.regions.RegionMetrics;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
@DisplayName("WorldEffectsModule")
|
||||||
|
class WorldEffectsModuleTest {
|
||||||
|
|
||||||
|
private WorldEffectsModule module;
|
||||||
|
private RegionFactory factory;
|
||||||
|
private Region region;
|
||||||
|
private RegionMetrics metrics;
|
||||||
|
private List<WorldEffectRequest> captured;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
module = new WorldEffectsModule();
|
||||||
|
captured = new ArrayList<>();
|
||||||
|
module.registerConsumer(captured::add);
|
||||||
|
|
||||||
|
factory = new RegionFactory();
|
||||||
|
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
|
||||||
|
metrics = region.getMetrics();
|
||||||
|
module.createDefaultRegionData(region);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("moduleId is 'worldeffects'")
|
||||||
|
void moduleId() {
|
||||||
|
assertEquals("worldeffects", module.getModuleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("metadata is non-null with correct id")
|
||||||
|
void metadata() {
|
||||||
|
assertNotNull(module.getMetadata());
|
||||||
|
assertEquals("worldeffects", module.getMetadata().moduleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("initialize throws on null context")
|
||||||
|
void initializeNullThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("registerConsumer throws on null")
|
||||||
|
void registerNullThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.registerConsumer(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("updateRegion throws on null context")
|
||||||
|
void updateNullThrows() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("no consumer registered → noChange returned")
|
||||||
|
void noConsumerNoChange() {
|
||||||
|
WorldEffectsModule fresh = new WorldEffectsModule();
|
||||||
|
metrics.setPollutionScore(80.0);
|
||||||
|
ModuleUpdateResult result = fresh.updateRegion(new RegionUpdateContext(region));
|
||||||
|
assertFalse(result.changedRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("GRASS_DEGRADES_TO_DIRT emitted when pollution > 60 and soil < 30")
|
||||||
|
void grassDegrades() {
|
||||||
|
metrics.setPollutionScore(80.0);
|
||||||
|
metrics.setSoilQuality(15.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
|
||||||
|
assertTrue(found, "GRASS_DEGRADES_TO_DIRT should have been emitted");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("GRASS_DEGRADES_TO_DIRT not emitted when pollution is low")
|
||||||
|
void grassDegradeNotEmittedLowPollution() {
|
||||||
|
metrics.setPollutionScore(10.0);
|
||||||
|
metrics.setSoilQuality(15.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
|
||||||
|
assertFalse(found, "GRASS_DEGRADES_TO_DIRT should NOT be emitted with low pollution");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("VEGETATION_SPREADS emitted when vegetationPressure > 60 and soilQuality > 50")
|
||||||
|
void vegetationSpreads() {
|
||||||
|
metrics.setVegetationPressure(80.0);
|
||||||
|
metrics.setSoilQuality(70.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
|
||||||
|
assertTrue(found, "VEGETATION_SPREADS should have been emitted");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("VEGETATION_SPREADS not emitted when soil is poor")
|
||||||
|
void vegetationSpreadsNotEmittedPoorSoil() {
|
||||||
|
metrics.setVegetationPressure(80.0);
|
||||||
|
metrics.setSoilQuality(30.0); // below threshold
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
|
||||||
|
assertFalse(found, "VEGETATION_SPREADS should NOT be emitted with poor soil");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SAPLING_GROWTH_SLOWED emitted when logging depletion > 50")
|
||||||
|
void saplingGrowthSlowed() {
|
||||||
|
ResourceRegionData resources = ResourceRegionData.defaults();
|
||||||
|
resources.recordLogging(70.0);
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
|
||||||
|
assertTrue(found, "SAPLING_GROWTH_SLOWED should have been emitted");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SAPLING_GROWTH_SLOWED not emitted when logging depletion is low")
|
||||||
|
void saplingSlowNotEmittedLowLogging() {
|
||||||
|
ResourceRegionData resources = ResourceRegionData.defaults();
|
||||||
|
resources.recordLogging(20.0); // below threshold
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
|
||||||
|
assertFalse(found, "SAPLING_GROWTH_SLOWED should NOT be emitted with low logging");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SAPLING_GROWTH_BOOSTED emitted when succession stage >= YOUNG_WOODLAND")
|
||||||
|
void saplingGrowthBoostedAtYoungWoodland() {
|
||||||
|
RecoveryRegionData recovery = new RecoveryRegionData(
|
||||||
|
SuccessionStage.YOUNG_WOODLAND, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
|
||||||
|
assertTrue(found, "SAPLING_GROWTH_BOOSTED should have been emitted at YOUNG_WOODLAND");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("SAPLING_GROWTH_BOOSTED not emitted below YOUNG_WOODLAND")
|
||||||
|
void saplingBoostNotEmittedBelowYoungWoodland() {
|
||||||
|
RecoveryRegionData recovery = new RecoveryRegionData(
|
||||||
|
SuccessionStage.SCRUBLAND, 0.0, 0.0);
|
||||||
|
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
|
||||||
|
assertFalse(found, "SAPLING_GROWTH_BOOSTED should NOT be emitted below YOUNG_WOODLAND");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("POLLUTION_VISUAL_INDICATOR emitted when pollutionScore > 70")
|
||||||
|
void pollutionVisualIndicator() {
|
||||||
|
metrics.setPollutionScore(85.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.POLLUTION_VISUAL_INDICATOR);
|
||||||
|
assertTrue(found, "POLLUTION_VISUAL_INDICATOR should have been emitted");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("POLLUTION_VISUAL_INDICATOR not emitted below threshold")
|
||||||
|
void pollutionVisualNotEmitted() {
|
||||||
|
metrics.setPollutionScore(5.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
boolean found = captured.stream()
|
||||||
|
.anyMatch(r -> r.type() == WorldEffectType.POLLUTION_VISUAL_INDICATOR);
|
||||||
|
assertFalse(found, "POLLUTION_VISUAL_INDICATOR should NOT be emitted below threshold");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("emitted request has intensity in [0, 1]")
|
||||||
|
void requestIntensityInRange() {
|
||||||
|
metrics.setPollutionScore(80.0);
|
||||||
|
metrics.setSoilQuality(15.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
for (WorldEffectRequest request : captured) {
|
||||||
|
assertTrue(request.intensity() >= 0.0 && request.intensity() <= 1.0,
|
||||||
|
"Intensity out of range: " + request.intensity() + " for " + request.type());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("emitted request carries the correct region coordinate")
|
||||||
|
void requestHasCorrectCoordinate() {
|
||||||
|
metrics.setPollutionScore(85.0);
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertFalse(captured.isEmpty(), "Should have emitted at least one request");
|
||||||
|
assertEquals(region.getCoordinate(), captured.get(0).region());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returns noChange when no effects are triggered")
|
||||||
|
void noChangeWhenNoEffects() {
|
||||||
|
// Default metrics: pollution=0, veg=50, soil=60 — none of the five conditions met
|
||||||
|
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
assertFalse(result.changedRegion());
|
||||||
|
assertTrue(captured.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("returns changed when at least one effect is triggered")
|
||||||
|
void changedWhenEffectTriggered() {
|
||||||
|
metrics.setPollutionScore(85.0);
|
||||||
|
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
assertTrue(result.changedRegion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multiple consumers all receive the same requests")
|
||||||
|
void multipleConsumersAllReceive() {
|
||||||
|
List<WorldEffectRequest> second = new ArrayList<>();
|
||||||
|
module.registerConsumer(second::add);
|
||||||
|
metrics.setPollutionScore(85.0);
|
||||||
|
|
||||||
|
module.updateRegion(new RegionUpdateContext(region));
|
||||||
|
|
||||||
|
assertFalse(captured.isEmpty());
|
||||||
|
assertEquals(captured.size(), second.size(),
|
||||||
|
"Both consumers should receive the same number of requests");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("NO_OP consumer can be registered without error")
|
||||||
|
void noOpConsumer() {
|
||||||
|
WorldEffectsModule fresh = new WorldEffectsModule();
|
||||||
|
assertDoesNotThrow(() -> fresh.registerConsumer(WorldEffectConsumer.NO_OP));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("shutdown clears all registered consumers")
|
||||||
|
void shutdownClearsConsumers() {
|
||||||
|
module.shutdown();
|
||||||
|
assertTrue(module.getConsumers().isEmpty(), "Consumers should be cleared after shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,6 +158,11 @@ class LongRunSimulationTest {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<Region> getActiveRegions() {
|
||||||
|
return orderedRegions;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void markDirty(Region region) {
|
public void markDirty(Region region) {
|
||||||
region.markDirty();
|
region.markDirty();
|
||||||
|
|||||||
Reference in New Issue
Block a user