Compare commits
10 Commits
ae2a8db3ce
...
67a1e07b82
| 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.bus.api.IEventBus;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.event.entity.living.FinalizeSpawnEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStartedEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStartingEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.minecraft.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.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
import com.livingworld.bootstrap.LivingWorldBootstrap;
|
||||
import com.livingworld.core.LivingWorldConstants;
|
||||
import com.livingworld.debug.DiagnosticCategory;
|
||||
import com.livingworld.debug.LivingWorldLogger;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import com.livingworld.platform.neoforge.NeoForgePlatformAdapter;
|
||||
import com.livingworld.platform.neoforge.NeoForgeWorldEffectExecutor;
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import 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.
|
||||
@@ -25,7 +64,28 @@ import com.livingworld.platform.neoforge.NeoForgePlatformAdapter;
|
||||
public class LivingWorldMod {
|
||||
|
||||
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 Random random = new Random();
|
||||
private MinecraftServer minecraftServer;
|
||||
private int furnaceScanTick = 0;
|
||||
private int playerCheckTick = 0;
|
||||
private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>();
|
||||
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
|
||||
|
||||
public LivingWorldMod(IEventBus eventBus) {
|
||||
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
|
||||
@@ -34,7 +94,8 @@ public class LivingWorldMod {
|
||||
NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter(
|
||||
bootstrap::getWorldSaveDirectory,
|
||||
bootstrap::registerCommands,
|
||||
bootstrap::onServerTick);
|
||||
bootstrap::onServerTick,
|
||||
bootstrap::handleBlockBreak);
|
||||
this.bootstrap.initialize(platformAdapter);
|
||||
|
||||
eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup());
|
||||
@@ -42,11 +103,197 @@ public class LivingWorldMod {
|
||||
ServerStartingEvent.class,
|
||||
event -> bootstrap.onServerStarting(
|
||||
event.getServer().getWorldPath(LevelResource.ROOT)));
|
||||
NeoForge.EVENT_BUS.addListener(
|
||||
ServerStartedEvent.class, event -> bootstrap.onServerStarted());
|
||||
NeoForge.EVENT_BUS.addListener(
|
||||
ServerStoppingEvent.class, event -> bootstrap.onServerStopping());
|
||||
NeoForge.EVENT_BUS.addListener(ServerStartedEvent.class, event -> {
|
||||
this.minecraftServer = event.getServer();
|
||||
bootstrap.onServerStarted();
|
||||
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.");
|
||||
}
|
||||
|
||||
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.ModuleRegistry;
|
||||
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.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionLifecycleController;
|
||||
import com.livingworld.regions.RegionManager;
|
||||
@@ -26,6 +46,16 @@ import com.livingworld.regions.cache.RegionCache;
|
||||
import com.livingworld.regions.query.RegionQueryEngine;
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.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;
|
||||
|
||||
/**
|
||||
@@ -33,12 +63,26 @@ import net.minecraft.commands.CommandSourceStack;
|
||||
*/
|
||||
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 Path worldSaveDirectory;
|
||||
private ServiceRegistry services;
|
||||
private RegionManager regionManager;
|
||||
private ModuleRegistry moduleRegistry;
|
||||
private SimulationManager simulationManager;
|
||||
private WorldEffectsModule worldEffectsModule;
|
||||
private BooleanSupplier overworldRaining = () -> false;
|
||||
private boolean initialized;
|
||||
private boolean serverReady;
|
||||
|
||||
@@ -110,6 +154,46 @@ public final class LivingWorldBootstrap {
|
||||
"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.
|
||||
*/
|
||||
@@ -119,12 +203,123 @@ public final class LivingWorldBootstrap {
|
||||
}
|
||||
regionManager.flushAll();
|
||||
moduleRegistry.shutdownAll();
|
||||
hudEnabledPlayers.clear();
|
||||
serverReady = false;
|
||||
LivingWorldLogger.info(
|
||||
DiagnosticCategory.BOOTSTRAP,
|
||||
"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() {
|
||||
if (!serverReady) {
|
||||
return;
|
||||
@@ -132,17 +327,116 @@ public final class LivingWorldBootstrap {
|
||||
long previousSimulationTick = simulationManager.getSimulationTickCounter();
|
||||
simulationManager.onMinecraftServerTick();
|
||||
if (simulationManager.getSimulationTickCounter() != previousSimulationTick) {
|
||||
applyWeatherFeedback();
|
||||
spreadPollutionAcrossRegions();
|
||||
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) {
|
||||
requireInitialized();
|
||||
LivingWorldCommandRoot.registerDeferred(
|
||||
dispatcher,
|
||||
() -> requireService(regionManager, "regionManager"),
|
||||
() -> requireService(moduleRegistry, "moduleRegistry"),
|
||||
() -> requireService(simulationManager, "simulationManager"));
|
||||
() -> requireService(simulationManager, "simulationManager"),
|
||||
this::toggleHud);
|
||||
}
|
||||
|
||||
public Path getWorldSaveDirectory() {
|
||||
@@ -165,6 +459,7 @@ public final class LivingWorldBootstrap {
|
||||
new FileRegionPersistenceService(
|
||||
saveDirectory.resolve("living_world"),
|
||||
LivingWorldConstants.MOD_VERSION);
|
||||
registerModuleCodecs(persistenceService);
|
||||
RegionCache regionCache = new RegionCache();
|
||||
RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache);
|
||||
regionManager = new RegionManager(
|
||||
@@ -175,6 +470,7 @@ public final class LivingWorldBootstrap {
|
||||
new RegionLifecycleController(),
|
||||
config);
|
||||
moduleRegistry = new ModuleRegistry();
|
||||
registerEcosystemModules(moduleRegistry);
|
||||
LivingWorldEventBus eventBus = new LivingWorldEventBus();
|
||||
DefaultTimeService timeService = new DefaultTimeService();
|
||||
SimulationScheduler scheduler = new SimulationScheduler(config);
|
||||
@@ -202,6 +498,198 @@ public final class LivingWorldBootstrap {
|
||||
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() {
|
||||
if (!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;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
import com.livingworld.core.simulation.SimulationManager;
|
||||
import com.livingworld.modules.ModuleRegistry;
|
||||
@@ -33,14 +37,16 @@ public final class LivingWorldCommandRoot {
|
||||
dispatcher,
|
||||
() -> regionManager,
|
||||
() -> moduleRegistry,
|
||||
() -> simulationManager);
|
||||
() -> simulationManager,
|
||||
uuid -> false);
|
||||
}
|
||||
|
||||
public static void registerDeferred(
|
||||
CommandDispatcher<CommandSourceStack> dispatcher,
|
||||
Supplier<RegionManager> regionManager,
|
||||
Supplier<ModuleRegistry> moduleRegistry,
|
||||
Supplier<SimulationManager> simulationManager) {
|
||||
Supplier<SimulationManager> simulationManager,
|
||||
Function<UUID, Boolean> hudToggle) {
|
||||
if (dispatcher == null) {
|
||||
throw new IllegalArgumentException("dispatcher must not be null");
|
||||
}
|
||||
@@ -64,14 +70,30 @@ public final class LivingWorldCommandRoot {
|
||||
requireService(simulationManager, "simulationManager"))))
|
||||
.then(Commands.literal("region")
|
||||
.then(Commands.literal("info")
|
||||
.executes(context -> RegionInfoCommand.execute(
|
||||
.executes(context -> RegionInfoCommand.executeAtSelf(
|
||||
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("list")
|
||||
.executes(context -> listModules(
|
||||
context.getSource(),
|
||||
requireService(moduleRegistry, "moduleRegistry")))))
|
||||
.then(Commands.literal("stats")
|
||||
.executes(context -> StatsCommand.execute(
|
||||
context.getSource(),
|
||||
requireService(simulationManager, "simulationManager"))))
|
||||
.then(Commands.literal("simulate")
|
||||
.then(Commands.argument(
|
||||
"ticks",
|
||||
@@ -80,7 +102,24 @@ public final class LivingWorldCommandRoot {
|
||||
.executes(context -> SimulateCommand.execute(
|
||||
context.getSource(),
|
||||
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) {
|
||||
|
||||
@@ -1,44 +1,55 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionManager;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.network.chat.Component;
|
||||
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 {
|
||||
|
||||
private RegionInfoCommand() {
|
||||
}
|
||||
|
||||
public static int execute(
|
||||
/** Uses the command-source position (player standing location). */
|
||||
public static int executeAtSelf(
|
||||
CommandSourceStack source,
|
||||
RegionManager regionManager) {
|
||||
if (source == null) {
|
||||
throw new IllegalArgumentException("source must not be null");
|
||||
}
|
||||
if (regionManager == null) {
|
||||
throw new IllegalArgumentException("regionManager must not be null");
|
||||
}
|
||||
if (source == null) throw new IllegalArgumentException("source must not be null");
|
||||
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
|
||||
|
||||
Vec3 position = source.getPosition();
|
||||
String dimensionId = source.getLevel().dimension().location().toString();
|
||||
Region region = regionManager.getOrCreateRegionAtBlock(
|
||||
dimensionId,
|
||||
floorToBlock(position.x),
|
||||
floorToBlock(position.z));
|
||||
(int) Math.floor(position.x),
|
||||
(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)) {
|
||||
source.sendSuccess(() -> Component.literal(line), false);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int floorToBlock(double coordinate) {
|
||||
return (int) Math.floor(coordinate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
package com.livingworld.commands;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
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.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.RegionFlags;
|
||||
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.
|
||||
@@ -21,26 +34,84 @@ public final class RegionInfoFormatter {
|
||||
throw new IllegalArgumentException("region must not be null");
|
||||
}
|
||||
|
||||
RegionMetrics metrics = region.getMetrics();
|
||||
RegionFlags flags = region.getFlags();
|
||||
Set<String> moduleIds = new TreeSet<>(region.getModuleData().moduleIds());
|
||||
RegionMetrics m = region.getMetrics();
|
||||
RegionFlags f = region.getFlags();
|
||||
RegionModuleData data = region.getModuleData();
|
||||
|
||||
return List.of(
|
||||
"Region: " + region.getCoordinate().stableId(),
|
||||
"Lifecycle: " + region.getLifecycleState() + ", dirty=" + region.isDirty(),
|
||||
"Metrics: ecosystemHealth=" + metrics.getEcosystemHealth()
|
||||
+ ", pollution=" + metrics.getPollutionScore()
|
||||
+ ", soilQuality=" + metrics.getSoilQuality()
|
||||
+ ", waterQuality=" + metrics.getWaterQuality()
|
||||
+ ", vegetationPressure=" + metrics.getVegetationPressure()
|
||||
+ ", resourceDepletion=" + metrics.getResourceDepletion()
|
||||
+ ", recoveryPressure=" + metrics.getRecoveryPressure(),
|
||||
"Flags: playerActivity=" + flags.isHasPlayerActivity()
|
||||
+ ", highPollution=" + flags.isHasHighPollution()
|
||||
+ ", lowSoilQuality=" + flags.isHasLowSoilQuality()
|
||||
+ ", activeEcosystemEvent=" + flags.isHasActiveEcosystemEvent()
|
||||
+ ", forceLoaded=" + flags.isForceLoadedBySimulation()
|
||||
+ ", corrupted=" + flags.isCorrupted(),
|
||||
"Module data: " + (moduleIds.isEmpty() ? "none" : String.join(", ", moduleIds)));
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("Region: " + region.getCoordinate().stableId()
|
||||
+ " lifecycle=" + region.getLifecycleState()
|
||||
+ " dirty=" + region.isDirty()
|
||||
+ " tick=" + region.getLastUpdatedSimulationTick());
|
||||
lines.add("Metrics:"
|
||||
+ " health=" + fmt(m.getEcosystemHealth())
|
||||
+ " poll=" + fmt(m.getPollutionScore())
|
||||
+ " soil=" + fmt(m.getSoilQuality())
|
||||
+ " water=" + fmt(m.getWaterQuality())
|
||||
+ " veg=" + fmt(m.getVegetationPressure())
|
||||
+ " res=" + fmt(m.getResourceDepletion())
|
||||
+ " recov=" + fmt(m.getRecoveryPressure()));
|
||||
lines.add("Flags:"
|
||||
+ " playerActivity=" + f.isHasPlayerActivity()
|
||||
+ " highPollution=" + f.isHasHighPollution()
|
||||
+ " 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;
|
||||
|
||||
/** Interval between simulation cycles, in game ticks (must be >= 1). */
|
||||
private int simulationIntervalTicks = 100;
|
||||
private int simulationIntervalTicks = 50;
|
||||
|
||||
/** Maximum number of regions processed per cycle (must be >= 1). */
|
||||
private int maxRegionsPerCycle = 50;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.livingworld.core.services;
|
||||
|
||||
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.LivingWorldLogger;
|
||||
import com.livingworld.regions.Region;
|
||||
@@ -18,12 +22,15 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/**
|
||||
* 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 Clock clock;
|
||||
private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
|
||||
private final List<ModuleCodecEntry> moduleCodecs = new ArrayList<>();
|
||||
private SaveMetadata metadata;
|
||||
|
||||
private record ModuleCodecEntry(
|
||||
String moduleId,
|
||||
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
|
||||
BiConsumer<PersistenceReader, RegionModuleData> decoder) {}
|
||||
|
||||
public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
|
||||
this(rootDirectory, modVersion, Clock.systemUTC());
|
||||
}
|
||||
@@ -136,6 +149,25 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
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() {
|
||||
try {
|
||||
Files.createDirectories(regionsDirectory);
|
||||
@@ -242,6 +274,13 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
properties.setProperty(
|
||||
"metric.recoveryPressure",
|
||||
Double.toString(metrics.getRecoveryPressure()));
|
||||
|
||||
for (ModuleCodecEntry codec : moduleCodecs) {
|
||||
String prefix = "mod." + codec.moduleId() + ".";
|
||||
codec.encoder().accept(
|
||||
region.getModuleData(),
|
||||
new PropertiesPersistenceWriter(properties, prefix));
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
@@ -270,6 +309,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
|
||||
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(
|
||||
UUID.fromString(required(properties, "id")),
|
||||
new RegionCoordinate(
|
||||
@@ -282,7 +329,7 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
||||
false,
|
||||
flags,
|
||||
metrics,
|
||||
new RegionModuleData());
|
||||
moduleData);
|
||||
region.validate();
|
||||
return region;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.livingworld.core.simulation;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -30,9 +31,14 @@ public interface RegionManager {
|
||||
*/
|
||||
List<Region> resolveAll(List<RegionCoordinate> coordinates);
|
||||
|
||||
/**
|
||||
* Returns all currently active (cached/loaded) regions.
|
||||
*/
|
||||
Collection<Region> getActiveRegions();
|
||||
|
||||
/**
|
||||
* Marks a region as dirty, indicating unsaved changes.
|
||||
*
|
||||
*
|
||||
* @param region the region to mark dirty (must not be null)
|
||||
*/
|
||||
void markDirty(Region region);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.livingworld.core.simulation;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -8,6 +9,8 @@ import com.livingworld.core.services.PersistenceService;
|
||||
import com.livingworld.core.services.TimeService;
|
||||
import com.livingworld.debug.DiagnosticCategory;
|
||||
import com.livingworld.debug.LivingWorldLogger;
|
||||
import com.livingworld.debug.SimulationProfileSnapshot;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.events.LivingWorldEventBus;
|
||||
import com.livingworld.modules.ModuleUpdateResult;
|
||||
import com.livingworld.modules.ModuleRegistry;
|
||||
@@ -61,6 +64,11 @@ public final class SimulationManager {
|
||||
this.scheduler.onMinecraftTick();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
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;
|
||||
|
||||
import com.livingworld.platform.BlockBreakInfo;
|
||||
import com.livingworld.platform.PlatformAdapter;
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import java.nio.file.Path;
|
||||
@@ -8,11 +9,14 @@ import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.SharedConstants;
|
||||
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.fml.ModList;
|
||||
import net.neoforged.fml.loading.FMLEnvironment;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
|
||||
/**
|
||||
@@ -25,17 +29,21 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
|
||||
private final Supplier<Path> worldSaveDirectory;
|
||||
private final Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar;
|
||||
private final Runnable serverTickHook;
|
||||
private final Consumer<BlockBreakInfo> blockBreakHandler;
|
||||
private boolean commandsRegistered;
|
||||
private boolean serverTickRegistered;
|
||||
private boolean playerEventsRegistered;
|
||||
|
||||
public NeoForgePlatformAdapter(
|
||||
Supplier<Path> worldSaveDirectory,
|
||||
Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar,
|
||||
Runnable serverTickHook) {
|
||||
Runnable serverTickHook,
|
||||
Consumer<BlockBreakInfo> blockBreakHandler) {
|
||||
this.worldSaveDirectory =
|
||||
Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory");
|
||||
this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar");
|
||||
this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook");
|
||||
this.blockBreakHandler = Objects.requireNonNull(blockBreakHandler, "blockBreakHandler");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,6 +100,39 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
|
||||
|
||||
@Override
|
||||
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.DEBUG));
|
||||
|
||||
for (int tick = 0; tick < 100; tick++) {
|
||||
for (int tick = 0; tick < 50; tick++) {
|
||||
bootstrap.onServerTick();
|
||||
}
|
||||
TimeService timeService = bootstrap.getServices().get(CoreServices.TIME);
|
||||
|
||||
@@ -1,36 +1,79 @@
|
||||
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.assertTrue;
|
||||
|
||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||
import com.livingworld.modules.recovery.SuccessionStage;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.livingworld.regions.Region;
|
||||
import com.livingworld.regions.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
|
||||
class RegionInfoFormatterTest {
|
||||
|
||||
@Test
|
||||
void includesIdentityLifecycleMetricsFlagsAndSortedModuleIds() {
|
||||
Region region = new RegionFactory().createNewRegion(
|
||||
private static Region region() {
|
||||
return new RegionFactory().createNewRegion(
|
||||
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());
|
||||
assertTrue(lines.get(0).contains("minecraft:overworld:-1:2"));
|
||||
assertTrue(lines.get(1).contains("ACTIVE"));
|
||||
assertTrue(lines.get(2).contains("pollution=75.0"));
|
||||
assertTrue(lines.get(3).contains("highPollution=true"));
|
||||
assertEquals("Module data: soil, water", lines.get(4));
|
||||
@Test
|
||||
void metricsLineContainsPollutionScore() {
|
||||
Region r = region();
|
||||
r.getMetrics().setPollutionScore(75);
|
||||
List<String> lines = RegionInfoFormatter.format(r);
|
||||
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
|
||||
|
||||
@@ -31,7 +31,7 @@ class SimulationConfigTest {
|
||||
@Test
|
||||
void defaultSimulationIntervalTicks() {
|
||||
final SimulationConfig config = new SimulationConfig();
|
||||
assertEquals(100, config.getSimulationIntervalTicks());
|
||||
assertEquals(50, config.getSimulationIntervalTicks());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -276,6 +276,6 @@ class SimulationConfigTest {
|
||||
final String result = config.toString();
|
||||
assertTrue(result.contains("SimulationConfig"));
|
||||
assertTrue(result.contains("regionSizeChunks=8"));
|
||||
assertTrue(result.contains("simulationIntervalTicks=100"));
|
||||
assertTrue(result.contains("simulationIntervalTicks=50"));
|
||||
}
|
||||
}
|
||||
@@ -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.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.RegionCoordinate;
|
||||
import com.livingworld.regions.RegionFactory;
|
||||
import com.livingworld.regions.RegionModuleData;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
@@ -99,6 +104,77 @@ class FileRegionPersistenceServiceTest {
|
||||
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() {
|
||||
return new FileRegionPersistenceService(
|
||||
temporaryDirectory,
|
||||
|
||||
@@ -233,6 +233,11 @@ class SimulationManagerTest {
|
||||
return coordinates.stream().map(regions::get).filter(java.util.Objects::nonNull).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Collection<Region> getActiveRegions() {
|
||||
return regions.values();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markDirty(Region region) {
|
||||
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();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Region> getActiveRegions() {
|
||||
return orderedRegions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markDirty(Region region) {
|
||||
region.markDirty();
|
||||
|
||||
Reference in New Issue
Block a user