Add Volume 2 ecosystem modules, event hooks, and module data persistence
Eight simulation modules (pollution, soil, water, vegetation, resource depletion, recovery, ecosystem, world effects) form the full ecosystem pipeline. Each module owns typed RegionData and writes summary metrics back to RegionMetrics. Player block-break events are wired through BlockBreakInfo across the platform boundary; the NeoForge adapter translates BreakEvent and routes it to the bootstrap handler which records mining/logging/farming depletion on the affected region. Module state now survives server restarts: FileRegionPersistenceService accepts per-module codecs (via PropertiesPersistenceWriter/Reader) that serialise every RegionData instance alongside the region's core state. Bootstrap registers codecs for all seven data-bearing modules at startup. 395 tests, all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,7 +34,8 @@ public class LivingWorldMod {
|
|||||||
NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter(
|
NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter(
|
||||||
bootstrap::getWorldSaveDirectory,
|
bootstrap::getWorldSaveDirectory,
|
||||||
bootstrap::registerCommands,
|
bootstrap::registerCommands,
|
||||||
bootstrap::onServerTick);
|
bootstrap::onServerTick,
|
||||||
|
bootstrap::handleBlockBreak);
|
||||||
this.bootstrap.initialize(platformAdapter);
|
this.bootstrap.initialize(platformAdapter);
|
||||||
|
|
||||||
eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup());
|
eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup());
|
||||||
|
|||||||
@@ -17,7 +17,26 @@ import com.livingworld.events.LivingWorldEventBus;
|
|||||||
import com.livingworld.modules.ModuleContext;
|
import com.livingworld.modules.ModuleContext;
|
||||||
import com.livingworld.modules.ModuleRegistry;
|
import com.livingworld.modules.ModuleRegistry;
|
||||||
import com.livingworld.modules.ServerContext;
|
import com.livingworld.modules.ServerContext;
|
||||||
|
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||||
|
import com.livingworld.modules.pollution.PollutionModule;
|
||||||
|
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||||
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryModule;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
|
import com.livingworld.modules.resources.ResourceDepletionModule;
|
||||||
|
import com.livingworld.modules.resources.ResourceRegionData;
|
||||||
|
import com.livingworld.modules.soil.SoilModule;
|
||||||
|
import com.livingworld.modules.soil.SoilRegionData;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationModule;
|
||||||
|
import com.livingworld.modules.vegetation.VegetationRegionData;
|
||||||
|
import com.livingworld.modules.water.WaterModule;
|
||||||
|
import com.livingworld.modules.water.WaterRegionData;
|
||||||
|
import com.livingworld.modules.worldeffects.WorldEffectsModule;
|
||||||
|
import com.livingworld.platform.BlockBreakInfo;
|
||||||
import com.livingworld.platform.PlatformAdapter;
|
import com.livingworld.platform.PlatformAdapter;
|
||||||
|
import com.livingworld.regions.Region;
|
||||||
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
import com.livingworld.regions.RegionFactory;
|
import com.livingworld.regions.RegionFactory;
|
||||||
import com.livingworld.regions.RegionLifecycleController;
|
import com.livingworld.regions.RegionLifecycleController;
|
||||||
import com.livingworld.regions.RegionManager;
|
import com.livingworld.regions.RegionManager;
|
||||||
@@ -39,6 +58,7 @@ public final class LivingWorldBootstrap {
|
|||||||
private RegionManager regionManager;
|
private RegionManager regionManager;
|
||||||
private ModuleRegistry moduleRegistry;
|
private ModuleRegistry moduleRegistry;
|
||||||
private SimulationManager simulationManager;
|
private SimulationManager simulationManager;
|
||||||
|
private WorldEffectsModule worldEffectsModule;
|
||||||
private boolean initialized;
|
private boolean initialized;
|
||||||
private boolean serverReady;
|
private boolean serverReady;
|
||||||
|
|
||||||
@@ -105,11 +125,61 @@ public final class LivingWorldBootstrap {
|
|||||||
requireServerReady();
|
requireServerReady();
|
||||||
ServerContext context = new ServerContext();
|
ServerContext context = new ServerContext();
|
||||||
moduleRegistry.getEnabledModules().forEach(module -> module.onServerStarted(context));
|
moduleRegistry.getEnabledModules().forEach(module -> module.onServerStarted(context));
|
||||||
|
|
||||||
|
// Register the world-effects consumer now that services are live.
|
||||||
|
// Logs what would be applied; actual block manipulation is Track C.
|
||||||
|
worldEffectsModule.registerConsumer(request ->
|
||||||
|
LivingWorldLogger.info(
|
||||||
|
DiagnosticCategory.SIMULATION,
|
||||||
|
"WorldEffect: " + request.type()
|
||||||
|
+ " region=" + request.region()
|
||||||
|
+ " intensity=" + String.format("%.2f", request.intensity())));
|
||||||
|
|
||||||
LivingWorldLogger.info(
|
LivingWorldLogger.info(
|
||||||
DiagnosticCategory.BOOTSTRAP,
|
DiagnosticCategory.BOOTSTRAP,
|
||||||
"onServerStarted - server started.");
|
"onServerStarted - server started.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a block-break event forwarded from the platform adapter.
|
||||||
|
*
|
||||||
|
* <p>Determines whether the block represents mining, logging, or farming activity
|
||||||
|
* and records the corresponding depletion on the region's
|
||||||
|
* {@link ResourceRegionData}. Safe to call before the server is ready (returns
|
||||||
|
* immediately in that case).</p>
|
||||||
|
*
|
||||||
|
* @param info platform-neutral description of the broken block
|
||||||
|
*/
|
||||||
|
public void handleBlockBreak(BlockBreakInfo info) {
|
||||||
|
if (!serverReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RegionCoordinate coord = RegionCoordinate.fromBlock(
|
||||||
|
info.dimensionId(),
|
||||||
|
info.blockX(),
|
||||||
|
info.blockZ(),
|
||||||
|
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
|
||||||
|
|
||||||
|
Region region = regionManager.getOrCreateRegion(coord);
|
||||||
|
ResourceRegionData resources = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
|
||||||
|
.orElseGet(ResourceRegionData::defaults);
|
||||||
|
|
||||||
|
String block = info.blockRegistryName();
|
||||||
|
if (isLog(block) || isLeaves(block) || isWood(block)) {
|
||||||
|
resources.recordLogging(1.0);
|
||||||
|
} else if (isOre(block) || isStone(block)) {
|
||||||
|
resources.recordMining(1.0);
|
||||||
|
} else if (isCrop(block) || isFarmland(block)) {
|
||||||
|
resources.recordFarming(1.0);
|
||||||
|
} else {
|
||||||
|
return; // not a tracked resource — don't dirty the region
|
||||||
|
}
|
||||||
|
|
||||||
|
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
|
||||||
|
regionManager.markDirty(region);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the server is stopping.
|
* Called when the server is stopping.
|
||||||
*/
|
*/
|
||||||
@@ -165,6 +235,7 @@ public final class LivingWorldBootstrap {
|
|||||||
new FileRegionPersistenceService(
|
new FileRegionPersistenceService(
|
||||||
saveDirectory.resolve("living_world"),
|
saveDirectory.resolve("living_world"),
|
||||||
LivingWorldConstants.MOD_VERSION);
|
LivingWorldConstants.MOD_VERSION);
|
||||||
|
registerModuleCodecs(persistenceService);
|
||||||
RegionCache regionCache = new RegionCache();
|
RegionCache regionCache = new RegionCache();
|
||||||
RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache);
|
RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache);
|
||||||
regionManager = new RegionManager(
|
regionManager = new RegionManager(
|
||||||
@@ -175,6 +246,7 @@ public final class LivingWorldBootstrap {
|
|||||||
new RegionLifecycleController(),
|
new RegionLifecycleController(),
|
||||||
config);
|
config);
|
||||||
moduleRegistry = new ModuleRegistry();
|
moduleRegistry = new ModuleRegistry();
|
||||||
|
registerEcosystemModules(moduleRegistry);
|
||||||
LivingWorldEventBus eventBus = new LivingWorldEventBus();
|
LivingWorldEventBus eventBus = new LivingWorldEventBus();
|
||||||
DefaultTimeService timeService = new DefaultTimeService();
|
DefaultTimeService timeService = new DefaultTimeService();
|
||||||
SimulationScheduler scheduler = new SimulationScheduler(config);
|
SimulationScheduler scheduler = new SimulationScheduler(config);
|
||||||
@@ -202,6 +274,198 @@ public final class LivingWorldBootstrap {
|
|||||||
moduleRegistry.initializeAll(new ModuleContext(services));
|
moduleRegistry.initializeAll(new ModuleContext(services));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Block categorisation helpers (registry-name based, no Minecraft imports)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static boolean isLog(String block) {
|
||||||
|
return block.contains("_log") || block.contains("_stem");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isWood(String block) {
|
||||||
|
return block.contains("_wood") || block.contains("_hyphae");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isLeaves(String block) {
|
||||||
|
return block.contains("_leaves");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isOre(String block) {
|
||||||
|
return block.contains("_ore");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isStone(String block) {
|
||||||
|
return block.equals("minecraft:stone")
|
||||||
|
|| block.equals("minecraft:deepslate")
|
||||||
|
|| block.equals("minecraft:cobblestone")
|
||||||
|
|| block.equals("minecraft:gravel")
|
||||||
|
|| block.equals("minecraft:sand");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isCrop(String block) {
|
||||||
|
return block.contains("wheat") || block.contains("carrot")
|
||||||
|
|| block.contains("potato") || block.contains("beetroot")
|
||||||
|
|| block.contains("sugar_cane") || block.contains("pumpkin")
|
||||||
|
|| block.contains("melon");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isFarmland(String block) {
|
||||||
|
return block.contains("farmland");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the {@link WorldEffectsModule} so the platform adapter can register a
|
||||||
|
* block-change consumer after bootstrap.
|
||||||
|
*
|
||||||
|
* <p>Call only after {@link #onServerStarting(Path)} has returned.</p>
|
||||||
|
*/
|
||||||
|
public WorldEffectsModule getWorldEffectsModule() {
|
||||||
|
requireServerReady();
|
||||||
|
return worldEffectsModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers per-region data codecs for all 7 data-bearing ecosystem modules.
|
||||||
|
*
|
||||||
|
* <p>WorldEffects has no persistent per-region data and is intentionally omitted.</p>
|
||||||
|
*/
|
||||||
|
private static void registerModuleCodecs(FileRegionPersistenceService service) {
|
||||||
|
service.registerModuleCodec(
|
||||||
|
PollutionModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
PollutionRegionData d = data.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
|
||||||
|
.orElseGet(PollutionRegionData::defaults);
|
||||||
|
w.writeDouble("airPollution", d.getAirPollution());
|
||||||
|
w.writeDouble("groundPollution", d.getGroundPollution());
|
||||||
|
w.writeDouble("waterPollution", d.getWaterPollution());
|
||||||
|
w.writeDouble("decayResistance", d.getDecayResistance());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(PollutionModule.MODULE_ID, new PollutionRegionData(
|
||||||
|
r.readDouble("airPollution", 0.0),
|
||||||
|
r.readDouble("groundPollution", 0.0),
|
||||||
|
r.readDouble("waterPollution", 0.0),
|
||||||
|
r.readDouble("decayResistance", 20.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
SoilModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
SoilRegionData d = data.get(SoilModule.MODULE_ID, SoilRegionData.class)
|
||||||
|
.orElseGet(SoilRegionData::defaults);
|
||||||
|
w.writeDouble("fertility", d.getFertility());
|
||||||
|
w.writeDouble("moisture", d.getMoisture());
|
||||||
|
w.writeDouble("contamination", d.getContamination());
|
||||||
|
w.writeDouble("compaction", d.getCompaction());
|
||||||
|
w.writeDouble("erosion", d.getErosion());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(SoilModule.MODULE_ID, new SoilRegionData(
|
||||||
|
r.readDouble("fertility", 60.0),
|
||||||
|
r.readDouble("moisture", 50.0),
|
||||||
|
r.readDouble("contamination", 0.0),
|
||||||
|
r.readDouble("compaction", 10.0),
|
||||||
|
r.readDouble("erosion", 0.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
WaterModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
WaterRegionData d = data.get(WaterModule.MODULE_ID, WaterRegionData.class)
|
||||||
|
.orElseGet(WaterRegionData::defaults);
|
||||||
|
w.writeDouble("waterAvailability", d.getWaterAvailability());
|
||||||
|
w.writeDouble("purificationCapacity", d.getPurificationCapacity());
|
||||||
|
w.writeDouble("droughtRisk", d.getDroughtRisk());
|
||||||
|
w.writeDouble("floodRisk", d.getFloodRisk());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(WaterModule.MODULE_ID, new WaterRegionData(
|
||||||
|
r.readDouble("waterAvailability", 60.0),
|
||||||
|
r.readDouble("purificationCapacity", 50.0),
|
||||||
|
r.readDouble("droughtRisk", 10.0),
|
||||||
|
r.readDouble("floodRisk", 10.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
VegetationModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
VegetationRegionData d = data.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
|
||||||
|
.orElseGet(VegetationRegionData::defaults);
|
||||||
|
w.writeDouble("grassPressure", d.getGrassPressure());
|
||||||
|
w.writeDouble("flowerPressure", d.getFlowerPressure());
|
||||||
|
w.writeDouble("shrubPressure", d.getShrubPressure());
|
||||||
|
w.writeDouble("treePressure", d.getTreePressure());
|
||||||
|
w.writeDouble("deadVegetation", d.getDeadVegetation());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(VegetationModule.MODULE_ID, new VegetationRegionData(
|
||||||
|
r.readDouble("grassPressure", 50.0),
|
||||||
|
r.readDouble("flowerPressure", 30.0),
|
||||||
|
r.readDouble("shrubPressure", 30.0),
|
||||||
|
r.readDouble("treePressure", 40.0),
|
||||||
|
r.readDouble("deadVegetation", 5.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
ResourceDepletionModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
ResourceRegionData d = data.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
|
||||||
|
.orElseGet(ResourceRegionData::defaults);
|
||||||
|
w.writeDouble("miningDepletion", d.getMiningDepletion());
|
||||||
|
w.writeDouble("loggingDepletion", d.getLoggingDepletion());
|
||||||
|
w.writeDouble("farmingDepletion", d.getFarmingDepletion());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(ResourceDepletionModule.MODULE_ID, new ResourceRegionData(
|
||||||
|
r.readDouble("miningDepletion", 0.0),
|
||||||
|
r.readDouble("loggingDepletion", 0.0),
|
||||||
|
r.readDouble("farmingDepletion", 0.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
RecoveryModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
RecoveryRegionData d = data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
|
||||||
|
.orElseGet(RecoveryRegionData::defaults);
|
||||||
|
w.writeString("successionStage", d.getSuccessionStage().name());
|
||||||
|
w.writeDouble("recoveryProgress", d.getRecoveryProgress());
|
||||||
|
w.writeDouble("damageAccumulation", d.getDamageAccumulation());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(RecoveryModule.MODULE_ID, new RecoveryRegionData(
|
||||||
|
SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
|
||||||
|
r.readDouble("recoveryProgress", 0.0),
|
||||||
|
r.readDouble("damageAccumulation", 0.0))));
|
||||||
|
|
||||||
|
service.registerModuleCodec(
|
||||||
|
EcosystemModule.MODULE_ID,
|
||||||
|
(data, w) -> {
|
||||||
|
EcosystemRegionData d = data.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class)
|
||||||
|
.orElseGet(EcosystemRegionData::defaults);
|
||||||
|
w.writeDouble("ecosystemHealth", d.getEcosystemHealth());
|
||||||
|
w.writeDouble("stress", d.getStress());
|
||||||
|
w.writeDouble("resilience", d.getResilience());
|
||||||
|
w.writeDouble("recoveryRate", d.getRecoveryRate());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put(EcosystemModule.MODULE_ID, new EcosystemRegionData(
|
||||||
|
r.readDouble("ecosystemHealth", 60.0),
|
||||||
|
r.readDouble("stress", 20.0),
|
||||||
|
r.readDouble("resilience", 50.0),
|
||||||
|
r.readDouble("recoveryRate", 5.0))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers all ecosystem simulation modules with the registry in pipeline order.
|
||||||
|
*
|
||||||
|
* <p>Order matters: each module may read metrics written by earlier modules in the
|
||||||
|
* same tick. The declared order here is the execution order inside
|
||||||
|
* {@link SimulationManager}.</p>
|
||||||
|
*/
|
||||||
|
private void registerEcosystemModules(ModuleRegistry registry) {
|
||||||
|
registry.register(new PollutionModule());
|
||||||
|
registry.register(new SoilModule());
|
||||||
|
registry.register(new WaterModule());
|
||||||
|
registry.register(new VegetationModule());
|
||||||
|
registry.register(new ResourceDepletionModule());
|
||||||
|
registry.register(new RecoveryModule());
|
||||||
|
registry.register(new EcosystemModule());
|
||||||
|
worldEffectsModule = new WorldEffectsModule();
|
||||||
|
registry.register(worldEffectsModule);
|
||||||
|
LivingWorldLogger.info(
|
||||||
|
DiagnosticCategory.BOOTSTRAP,
|
||||||
|
"Registered 8 ecosystem modules (pollution → soil → water → vegetation"
|
||||||
|
+ " → resources → recovery → ecosystem → worldeffects).");
|
||||||
|
}
|
||||||
|
|
||||||
private void requireInitialized() {
|
private void requireInitialized() {
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
throw new IllegalStateException("bootstrap has not been initialized");
|
throw new IllegalStateException("bootstrap has not been initialized");
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package com.livingworld.core.services;
|
package com.livingworld.core.services;
|
||||||
|
|
||||||
import com.livingworld.data.saved.SaveMetadata;
|
import com.livingworld.data.saved.SaveMetadata;
|
||||||
|
import com.livingworld.data.serialization.PersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PersistenceWriter;
|
||||||
|
import com.livingworld.data.serialization.PropertiesPersistenceReader;
|
||||||
|
import com.livingworld.data.serialization.PropertiesPersistenceWriter;
|
||||||
import com.livingworld.debug.DiagnosticCategory;
|
import com.livingworld.debug.DiagnosticCategory;
|
||||||
import com.livingworld.debug.LivingWorldLogger;
|
import com.livingworld.debug.LivingWorldLogger;
|
||||||
import com.livingworld.regions.Region;
|
import com.livingworld.regions.Region;
|
||||||
@@ -18,12 +22,15 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.function.BiConsumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File-backed persistence for the Volume 1 region model.
|
* File-backed persistence for the Volume 1 region model.
|
||||||
@@ -40,8 +47,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
private final String modVersion;
|
private final String modVersion;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
|
private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
|
||||||
|
private final List<ModuleCodecEntry> moduleCodecs = new ArrayList<>();
|
||||||
private SaveMetadata metadata;
|
private SaveMetadata metadata;
|
||||||
|
|
||||||
|
private record ModuleCodecEntry(
|
||||||
|
String moduleId,
|
||||||
|
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
|
||||||
|
BiConsumer<PersistenceReader, RegionModuleData> decoder) {}
|
||||||
|
|
||||||
public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
|
public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
|
||||||
this(rootDirectory, modVersion, Clock.systemUTC());
|
this(rootDirectory, modVersion, Clock.systemUTC());
|
||||||
}
|
}
|
||||||
@@ -136,6 +149,25 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
return dirtyRegions.size();
|
return dirtyRegions.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a codec that serialises one module's per-region data.
|
||||||
|
*
|
||||||
|
* <p>Must be called before any region is saved or loaded. Codecs are applied
|
||||||
|
* in registration order during both encode and decode. Each codec writes to /
|
||||||
|
* reads from keys prefixed with {@code "mod.<moduleId>."} so modules cannot
|
||||||
|
* collide.</p>
|
||||||
|
*
|
||||||
|
* @param moduleId the unique module identifier (used as key namespace)
|
||||||
|
* @param encoder writes module data from {@link RegionModuleData} to a writer
|
||||||
|
* @param decoder reads module data from a reader and populates {@link RegionModuleData}
|
||||||
|
*/
|
||||||
|
public synchronized void registerModuleCodec(
|
||||||
|
String moduleId,
|
||||||
|
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
|
||||||
|
BiConsumer<PersistenceReader, RegionModuleData> decoder) {
|
||||||
|
moduleCodecs.add(new ModuleCodecEntry(moduleId, encoder, decoder));
|
||||||
|
}
|
||||||
|
|
||||||
private void initializeStorage() {
|
private void initializeStorage() {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(regionsDirectory);
|
Files.createDirectories(regionsDirectory);
|
||||||
@@ -242,6 +274,13 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
properties.setProperty(
|
properties.setProperty(
|
||||||
"metric.recoveryPressure",
|
"metric.recoveryPressure",
|
||||||
Double.toString(metrics.getRecoveryPressure()));
|
Double.toString(metrics.getRecoveryPressure()));
|
||||||
|
|
||||||
|
for (ModuleCodecEntry codec : moduleCodecs) {
|
||||||
|
String prefix = "mod." + codec.moduleId() + ".";
|
||||||
|
codec.encoder().accept(
|
||||||
|
region.getModuleData(),
|
||||||
|
new PropertiesPersistenceWriter(properties, prefix));
|
||||||
|
}
|
||||||
return properties;
|
return properties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +309,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
|
metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
|
||||||
metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure"));
|
metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure"));
|
||||||
|
|
||||||
|
RegionModuleData moduleData = new RegionModuleData();
|
||||||
|
for (ModuleCodecEntry codec : moduleCodecs) {
|
||||||
|
String prefix = "mod." + codec.moduleId() + ".";
|
||||||
|
codec.decoder().accept(
|
||||||
|
new PropertiesPersistenceReader(properties, prefix),
|
||||||
|
moduleData);
|
||||||
|
}
|
||||||
|
|
||||||
Region region = new Region(
|
Region region = new Region(
|
||||||
UUID.fromString(required(properties, "id")),
|
UUID.fromString(required(properties, "id")),
|
||||||
new RegionCoordinate(
|
new RegionCoordinate(
|
||||||
@@ -282,7 +329,7 @@ public final class FileRegionPersistenceService implements PersistenceService {
|
|||||||
false,
|
false,
|
||||||
flags,
|
flags,
|
||||||
metrics,
|
metrics,
|
||||||
new RegionModuleData());
|
moduleData);
|
||||||
region.validate();
|
region.validate();
|
||||||
return region;
|
return region;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.02;
|
||||||
|
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,124 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public RecoveryRegionData(
|
||||||
|
SuccessionStage successionStage,
|
||||||
|
double recoveryProgress,
|
||||||
|
double damageAccumulation) {
|
||||||
|
if (successionStage == null) {
|
||||||
|
throw new IllegalArgumentException("successionStage must not be null");
|
||||||
|
}
|
||||||
|
this.successionStage = successionStage;
|
||||||
|
this.recoveryProgress = clamp(recoveryProgress);
|
||||||
|
this.damageAccumulation = clamp(damageAccumulation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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; }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 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.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 + "}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = 30.0;
|
||||||
|
/** Fraction of excess pollution score that becomes contamination per tick. */
|
||||||
|
private static final double POLLUTION_TO_CONTAMINATION_RATE = 0.003;
|
||||||
|
/** Fertility reduction per unit of contamination per tick. */
|
||||||
|
private static final double CONTAMINATION_FERTILITY_DRAIN = 0.002;
|
||||||
|
/** 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 = 60.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,207 @@
|
|||||||
|
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.
|
||||||
|
private static final double GRASS_DEGRADE_POLLUTION_MIN = 60.0;
|
||||||
|
private static final double GRASS_DEGRADE_SOIL_MAX = 30.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;
|
||||||
|
private static final double POLLUTION_INDICATOR_MIN = 70.0;
|
||||||
|
|
||||||
|
private static final ModuleMetadata METADATA = new ModuleMetadata(
|
||||||
|
MODULE_ID,
|
||||||
|
"World Effects",
|
||||||
|
"1.0.0",
|
||||||
|
"Translates ecosystem state into visible block-change requests for the platform layer.",
|
||||||
|
"1",
|
||||||
|
List.of("pollution", "soil", "vegetation", "resources", "recovery"),
|
||||||
|
List.of(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false);
|
||||||
|
|
||||||
|
private final List<WorldEffectConsumer> consumers = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a consumer that will receive effect requests each simulation tick.
|
||||||
|
*
|
||||||
|
* @param consumer the consumer to register (must not be null)
|
||||||
|
*/
|
||||||
|
public void registerConsumer(WorldEffectConsumer consumer) {
|
||||||
|
if (consumer == null) throw new IllegalArgumentException("consumer must not be null");
|
||||||
|
consumers.add(consumer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns an unmodifiable view of all registered consumers. */
|
||||||
|
public List<WorldEffectConsumer> getConsumers() {
|
||||||
|
return Collections.unmodifiableList(consumers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModuleId() { return MODULE_ID; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleMetadata getMetadata() { return METADATA; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void initialize(ModuleContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServerStarted(ServerContext context) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void createDefaultRegionData(Region region) {
|
||||||
|
// No per-region data; this module only reads state, it does not persist any.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
|
||||||
|
if (context == null) throw new IllegalArgumentException("context must not be null");
|
||||||
|
if (consumers.isEmpty()) return ModuleUpdateResult.noChange();
|
||||||
|
|
||||||
|
Region region = context.getRegion();
|
||||||
|
RegionMetrics m = region.getMetrics();
|
||||||
|
boolean emitted = false;
|
||||||
|
|
||||||
|
// --- Effect 1: grass degrades when pollution is high and soil is poor ---
|
||||||
|
if (m.getPollutionScore() > GRASS_DEGRADE_POLLUTION_MIN
|
||||||
|
&& m.getSoilQuality() < GRASS_DEGRADE_SOIL_MAX) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
m.getPollutionScore() - GRASS_DEGRADE_POLLUTION_MIN, 40.0)
|
||||||
|
* computeIntensity(GRASS_DEGRADE_SOIL_MAX - m.getSoilQuality(), 30.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.GRASS_DEGRADES_TO_DIRT, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect 2: vegetation spreads when soil and vegetation pressure are healthy ---
|
||||||
|
if (m.getVegetationPressure() > VEG_SPREAD_VEG_MIN
|
||||||
|
&& m.getSoilQuality() > VEG_SPREAD_SOIL_MIN) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
m.getVegetationPressure() - VEG_SPREAD_VEG_MIN, 40.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.VEGETATION_SPREADS, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect 3: logging depletion slows sapling growth ---
|
||||||
|
ResourceRegionData resources = region.getModuleData()
|
||||||
|
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
|
||||||
|
.orElse(null);
|
||||||
|
if (resources != null && resources.getLoggingDepletion() > SAPLING_SLOW_LOGGING_MIN) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
resources.getLoggingDepletion() - SAPLING_SLOW_LOGGING_MIN, 50.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.SAPLING_GROWTH_SLOWED, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect 4: advanced succession boosts sapling growth ---
|
||||||
|
RecoveryRegionData recovery = region.getModuleData()
|
||||||
|
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
|
||||||
|
.orElse(null);
|
||||||
|
if (recovery != null
|
||||||
|
&& recovery.getSuccessionStage().ordinal()
|
||||||
|
>= SuccessionStage.YOUNG_WOODLAND.ordinal()) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
recovery.getSuccessionStage().ordinal()
|
||||||
|
- SuccessionStage.YOUNG_WOODLAND.ordinal() + 1.0, 2.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.SAPLING_GROWTH_BOOSTED, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Effect 5: pollution visual indicator ---
|
||||||
|
if (m.getPollutionScore() > POLLUTION_INDICATOR_MIN) {
|
||||||
|
double intensity = computeIntensity(
|
||||||
|
m.getPollutionScore() - POLLUTION_INDICATOR_MIN, 30.0);
|
||||||
|
emit(new WorldEffectRequest(
|
||||||
|
WorldEffectType.POLLUTION_VISUAL_INDICATOR, region.getCoordinate(), intensity));
|
||||||
|
emitted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return emitted ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLivingWorldEvent(LivingWorldEvent event) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void saveModuleData(PersistenceWriter writer) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void loadModuleData(PersistenceReader reader) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shutdown() { consumers.clear(); }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Computes effect intensity as a fraction of the excess over a range, clamped to [0, 1]. */
|
||||||
|
private static double computeIntensity(double excess, double range) {
|
||||||
|
return Math.min(1.0, Math.max(0.0, excess / range));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void emit(WorldEffectRequest request) {
|
||||||
|
for (WorldEffectConsumer consumer : consumers) {
|
||||||
|
consumer.consume(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.livingworld.platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-neutral description of a block broken by a player.
|
||||||
|
*
|
||||||
|
* <p>The NeoForge adapter constructs this from a {@code BlockEvent.BreakEvent}
|
||||||
|
* and passes it to the bootstrap handler so no Minecraft types cross the
|
||||||
|
* platform boundary into core simulation code.</p>
|
||||||
|
*
|
||||||
|
* @param dimensionId Minecraft dimension key string (e.g. {@code "minecraft:overworld"})
|
||||||
|
* @param blockX world X coordinate of the broken block
|
||||||
|
* @param blockZ world Z coordinate of the broken block
|
||||||
|
* @param blockRegistryName registry name of the broken block (e.g. {@code "minecraft:oak_log"})
|
||||||
|
*/
|
||||||
|
public record BlockBreakInfo(
|
||||||
|
String dimensionId,
|
||||||
|
int blockX,
|
||||||
|
int blockZ,
|
||||||
|
String blockRegistryName) {
|
||||||
|
|
||||||
|
public BlockBreakInfo {
|
||||||
|
if (dimensionId == null || dimensionId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("dimensionId must not be null or blank");
|
||||||
|
}
|
||||||
|
if (blockRegistryName == null || blockRegistryName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("blockRegistryName must not be null or blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.livingworld.platform.neoforge;
|
package com.livingworld.platform.neoforge;
|
||||||
|
|
||||||
|
import com.livingworld.platform.BlockBreakInfo;
|
||||||
import com.livingworld.platform.PlatformAdapter;
|
import com.livingworld.platform.PlatformAdapter;
|
||||||
import com.mojang.brigadier.CommandDispatcher;
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -8,11 +9,14 @@ import java.util.function.Consumer;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import net.minecraft.SharedConstants;
|
import net.minecraft.SharedConstants;
|
||||||
import net.minecraft.commands.CommandSourceStack;
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.resources.ResourceLocation;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
import net.neoforged.api.distmarker.Dist;
|
import net.neoforged.api.distmarker.Dist;
|
||||||
import net.neoforged.fml.ModList;
|
import net.neoforged.fml.ModList;
|
||||||
import net.neoforged.fml.loading.FMLEnvironment;
|
import net.neoforged.fml.loading.FMLEnvironment;
|
||||||
import net.neoforged.neoforge.common.NeoForge;
|
import net.neoforged.neoforge.common.NeoForge;
|
||||||
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||||
|
import net.neoforged.neoforge.event.level.BlockEvent;
|
||||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,17 +29,21 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
|
|||||||
private final Supplier<Path> worldSaveDirectory;
|
private final Supplier<Path> worldSaveDirectory;
|
||||||
private final Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar;
|
private final Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar;
|
||||||
private final Runnable serverTickHook;
|
private final Runnable serverTickHook;
|
||||||
|
private final Consumer<BlockBreakInfo> blockBreakHandler;
|
||||||
private boolean commandsRegistered;
|
private boolean commandsRegistered;
|
||||||
private boolean serverTickRegistered;
|
private boolean serverTickRegistered;
|
||||||
|
private boolean playerEventsRegistered;
|
||||||
|
|
||||||
public NeoForgePlatformAdapter(
|
public NeoForgePlatformAdapter(
|
||||||
Supplier<Path> worldSaveDirectory,
|
Supplier<Path> worldSaveDirectory,
|
||||||
Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar,
|
Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar,
|
||||||
Runnable serverTickHook) {
|
Runnable serverTickHook,
|
||||||
|
Consumer<BlockBreakInfo> blockBreakHandler) {
|
||||||
this.worldSaveDirectory =
|
this.worldSaveDirectory =
|
||||||
Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory");
|
Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory");
|
||||||
this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar");
|
this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar");
|
||||||
this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook");
|
this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook");
|
||||||
|
this.blockBreakHandler = Objects.requireNonNull(blockBreakHandler, "blockBreakHandler");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -92,6 +100,39 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void registerPlayerEventHooks() {
|
public void registerPlayerEventHooks() {
|
||||||
// Player activity hooks are intentionally deferred until a module needs them.
|
if (playerEventsRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NeoForge.EVENT_BUS.addListener(BlockEvent.BreakEvent.class, this::onBlockBroken);
|
||||||
|
playerEventsRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates a NeoForge block-break event into a platform-neutral
|
||||||
|
* {@link BlockBreakInfo} and forwards it to the core handler.
|
||||||
|
*
|
||||||
|
* <p>Only player-caused breaks are forwarded (non-null player). Creative-mode
|
||||||
|
* breaks are included intentionally so creative players can still trigger
|
||||||
|
* depletion for testing.</p>
|
||||||
|
*/
|
||||||
|
private void onBlockBroken(BlockEvent.BreakEvent event) {
|
||||||
|
if (event.getPlayer() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(event.getLevel() instanceof Level level)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String dimensionId = level.dimension().location().toString();
|
||||||
|
ResourceLocation blockId = event.getState().getBlockHolder()
|
||||||
|
.unwrapKey()
|
||||||
|
.map(key -> key.location())
|
||||||
|
.orElse(null);
|
||||||
|
if (blockId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int blockX = event.getPos().getX();
|
||||||
|
int blockZ = event.getPos().getZ();
|
||||||
|
blockBreakHandler.accept(
|
||||||
|
new BlockBreakInfo(dimensionId, blockX, blockZ, blockId.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
|
import com.livingworld.modules.recovery.SuccessionStage;
|
||||||
|
import com.livingworld.modules.soil.SoilRegionData;
|
||||||
import com.livingworld.regions.Region;
|
import com.livingworld.regions.Region;
|
||||||
import com.livingworld.regions.RegionCoordinate;
|
import com.livingworld.regions.RegionCoordinate;
|
||||||
import com.livingworld.regions.RegionFactory;
|
import com.livingworld.regions.RegionFactory;
|
||||||
|
import com.livingworld.regions.RegionModuleData;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -99,6 +104,77 @@ class FileRegionPersistenceServiceTest {
|
|||||||
assertEquals(0, restarted.getDirtyRegionCount());
|
assertEquals(0, restarted.getDirtyRegionCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTripsModuleData() {
|
||||||
|
FileRegionPersistenceService service = service();
|
||||||
|
service.registerModuleCodec(
|
||||||
|
"pollution",
|
||||||
|
(data, w) -> {
|
||||||
|
PollutionRegionData d = data.get("pollution", PollutionRegionData.class)
|
||||||
|
.orElseGet(PollutionRegionData::defaults);
|
||||||
|
w.writeDouble("airPollution", d.getAirPollution());
|
||||||
|
w.writeDouble("groundPollution", d.getGroundPollution());
|
||||||
|
w.writeDouble("waterPollution", d.getWaterPollution());
|
||||||
|
w.writeDouble("decayResistance", d.getDecayResistance());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put("pollution", new PollutionRegionData(
|
||||||
|
r.readDouble("airPollution", 0.0),
|
||||||
|
r.readDouble("groundPollution", 0.0),
|
||||||
|
r.readDouble("waterPollution", 0.0),
|
||||||
|
r.readDouble("decayResistance", 20.0))));
|
||||||
|
service.registerModuleCodec(
|
||||||
|
"soil",
|
||||||
|
(data, w) -> {
|
||||||
|
SoilRegionData d = data.get("soil", SoilRegionData.class)
|
||||||
|
.orElseGet(SoilRegionData::defaults);
|
||||||
|
w.writeDouble("fertility", d.getFertility());
|
||||||
|
w.writeDouble("contamination", d.getContamination());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put("soil", new SoilRegionData(
|
||||||
|
r.readDouble("fertility", 60.0),
|
||||||
|
r.readDouble("moisture", 50.0),
|
||||||
|
r.readDouble("contamination", 0.0),
|
||||||
|
r.readDouble("compaction", 10.0),
|
||||||
|
r.readDouble("erosion", 0.0))));
|
||||||
|
service.registerModuleCodec(
|
||||||
|
"recovery",
|
||||||
|
(data, w) -> {
|
||||||
|
RecoveryRegionData d = data.get("recovery", RecoveryRegionData.class)
|
||||||
|
.orElseGet(RecoveryRegionData::defaults);
|
||||||
|
w.writeString("successionStage", d.getSuccessionStage().name());
|
||||||
|
w.writeDouble("damageAccumulation", d.getDamageAccumulation());
|
||||||
|
},
|
||||||
|
(r, data) -> data.put("recovery", new RecoveryRegionData(
|
||||||
|
SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
|
||||||
|
r.readDouble("recoveryProgress", 0.0),
|
||||||
|
r.readDouble("damageAccumulation", 0.0))));
|
||||||
|
|
||||||
|
Region original = new RegionFactory().createNewRegion(
|
||||||
|
new RegionCoordinate("minecraft:overworld", 0, 0), 0);
|
||||||
|
RegionModuleData moduleData = original.getModuleData();
|
||||||
|
moduleData.put("pollution", new PollutionRegionData(42.0, 15.0, 8.0, 25.0));
|
||||||
|
moduleData.put("soil", new SoilRegionData(75.0, 60.0, 5.0, 20.0, 3.0));
|
||||||
|
moduleData.put("recovery", new RecoveryRegionData(SuccessionStage.YOUNG_WOODLAND, 66.0, 12.5));
|
||||||
|
|
||||||
|
service.saveRegion(original);
|
||||||
|
Region restored = service.loadRegion(original.getCoordinate()).orElseThrow();
|
||||||
|
RegionModuleData restoredData = restored.getModuleData();
|
||||||
|
|
||||||
|
PollutionRegionData pollution = restoredData.get("pollution", PollutionRegionData.class).orElseThrow();
|
||||||
|
assertEquals(42.0, pollution.getAirPollution());
|
||||||
|
assertEquals(15.0, pollution.getGroundPollution());
|
||||||
|
assertEquals(8.0, pollution.getWaterPollution());
|
||||||
|
assertEquals(25.0, pollution.getDecayResistance());
|
||||||
|
|
||||||
|
SoilRegionData soil = restoredData.get("soil", SoilRegionData.class).orElseThrow();
|
||||||
|
assertEquals(75.0, soil.getFertility());
|
||||||
|
assertEquals(5.0, soil.getContamination());
|
||||||
|
|
||||||
|
RecoveryRegionData recovery = restoredData.get("recovery", RecoveryRegionData.class).orElseThrow();
|
||||||
|
assertEquals(SuccessionStage.YOUNG_WOODLAND, recovery.getSuccessionStage());
|
||||||
|
assertEquals(12.5, recovery.getDamageAccumulation());
|
||||||
|
}
|
||||||
|
|
||||||
private FileRegionPersistenceService service() {
|
private FileRegionPersistenceService service() {
|
||||||
return new FileRegionPersistenceService(
|
return new FileRegionPersistenceService(
|
||||||
temporaryDirectory,
|
temporaryDirectory,
|
||||||
|
|||||||
@@ -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(30.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(50.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user