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());
+ }
+ }
+}
diff --git a/src/main/java/com/livingworld/data/migration/RegionMigration.java b/src/main/java/com/livingworld/data/migration/RegionMigration.java
new file mode 100644
index 0000000..15e9eae
--- /dev/null
+++ b/src/main/java/com/livingworld/data/migration/RegionMigration.java
@@ -0,0 +1,32 @@
+package com.livingworld.data.migration;
+
+import java.util.Properties;
+
+/**
+ * A single schema migration step for region save data.
+ *
+ * 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.
+ *
+ *
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);
+}
diff --git a/src/main/java/com/livingworld/data/serialization/PropertiesPersistenceReader.java b/src/main/java/com/livingworld/data/serialization/PropertiesPersistenceReader.java
new file mode 100644
index 0000000..976d728
--- /dev/null
+++ b/src/main/java/com/livingworld/data/serialization/PropertiesPersistenceReader.java
@@ -0,0 +1,51 @@
+package com.livingworld.data.serialization;
+
+import java.util.Properties;
+
+/**
+ * {@link PersistenceReader} backed by a {@link Properties} object.
+ *
+ *
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.
+ */
+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;
+ }
+}
diff --git a/src/main/java/com/livingworld/data/serialization/PropertiesPersistenceWriter.java b/src/main/java/com/livingworld/data/serialization/PropertiesPersistenceWriter.java
new file mode 100644
index 0000000..4f14015
--- /dev/null
+++ b/src/main/java/com/livingworld/data/serialization/PropertiesPersistenceWriter.java
@@ -0,0 +1,47 @@
+package com.livingworld.data.serialization;
+
+import java.util.Properties;
+
+/**
+ * {@link PersistenceWriter} backed by a {@link Properties} object.
+ *
+ * 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"}.
+ */
+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));
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/ecosystem/EcosystemModule.java b/src/main/java/com/livingworld/modules/ecosystem/EcosystemModule.java
new file mode 100644
index 0000000..85a9dfb
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/ecosystem/EcosystemModule.java
@@ -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.
+ *
+ * This module runs last 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.
+ *
+ *
Ecosystem health formula
+ *
+ * health = soilQuality * 0.30
+ * + waterQuality * 0.20
+ * + (100 - pollutionScore) * 0.30
+ * + vegetationPressure * 0.20
+ *
+ *
+ * Stress model
+ * 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.
+ *
+ * Resilience and recovery
+ * 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() {}
+}
diff --git a/src/main/java/com/livingworld/modules/ecosystem/EcosystemRegionData.java b/src/main/java/com/livingworld/modules/ecosystem/EcosystemRegionData.java
new file mode 100644
index 0000000..bbd0db3
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/ecosystem/EcosystemRegionData.java
@@ -0,0 +1,115 @@
+package com.livingworld.modules.ecosystem;
+
+/**
+ * Per-region ecosystem summary state tracked by {@link EcosystemModule}.
+ *
+ * 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].
+ *
+ *
+ * - ecosystemHealth – weighted composite of soil, water, pollution and vegetation
+ *
- stress – accumulated ecological stress; rises when multiple metrics
+ * are in danger zones, recovers slowly in good conditions
+ *
- resilience – long-term stability; high resilience means the region
+ * resists and recovers from damage more quickly
+ *
- recoveryRate – current rate of ecological self-repair per tick
+ *
+ */
+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.
+ *
+ * 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.
+ *
+ *
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 + "}";
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/pollution/PollutionModule.java b/src/main/java/com/livingworld/modules/pollution/PollutionModule.java
new file mode 100644
index 0000000..7b7fddb
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/pollution/PollutionModule.java
@@ -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}.
+ *
+ *
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.
+ *
+ *
Per-tick rules
+ *
+ * - Air, ground, and water pollution each decay at different rates.
+ *
- Ground pollution slowly leaches into water.
+ *
- {@link RegionMetrics#getPollutionScore()} is recomputed as a weighted average.
+ *
- {@link RegionMetrics#getWaterQuality()} is reduced proportionally to water pollution.
+ *
+ *
+ * Configurable constants
+ * These are intentionally simple for V1 and expected to be tuned.
+ *
+ * - BASE_DECAY_RATE – fraction of pollution removed per tick before resistance
+ *
- GROUND_TO_WATER_LEACH – fraction of groundPollution that leaches to water each tick
+ *
- WATER_QUALITY_IMPACT – fraction by which water pollution degrades waterQuality
+ *
+ */
+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() {}
+}
diff --git a/src/main/java/com/livingworld/modules/pollution/PollutionRegionData.java b/src/main/java/com/livingworld/modules/pollution/PollutionRegionData.java
new file mode 100644
index 0000000..4bdf75c
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/pollution/PollutionRegionData.java
@@ -0,0 +1,103 @@
+package com.livingworld.modules.pollution;
+
+/**
+ * Per-region pollution state tracked by {@link PollutionModule}.
+ *
+ * Stores three independent pollution layers (air, ground, water) and a decay
+ * resistance score. All values are clamped to [0, 100].
+ *
+ *
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.
+ *
+ *
{@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 + "}";
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/recovery/RecoveryModule.java b/src/main/java/com/livingworld/modules/recovery/RecoveryModule.java
new file mode 100644
index 0000000..cd59566
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/recovery/RecoveryModule.java
@@ -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.
+ *
+ *
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.
+ *
+ *
Advancement
+ * 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.
+ *
+ * Regression
+ * Each tick that metrics fall badly below the current stage's minimums,
+ * {@code damageAccumulation} increases. At 70 accumulated damage the region
+ * regresses one stage.
+ *
+ * Recovery pressure metric
+ * 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() {}
+}
diff --git a/src/main/java/com/livingworld/modules/recovery/RecoveryRegionData.java b/src/main/java/com/livingworld/modules/recovery/RecoveryRegionData.java
new file mode 100644
index 0000000..6d1d04f
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/recovery/RecoveryRegionData.java
@@ -0,0 +1,124 @@
+package com.livingworld.modules.recovery;
+
+/**
+ * Per-region ecological recovery state tracked by {@link RecoveryModule}.
+ *
+ * Tracks where a region sits in the ecological succession sequence and how much
+ * progress it has made toward the next stage.
+ *
+ *
+ * - successionStage – current ecological stage (see {@link SuccessionStage})
+ *
- recoveryProgress – progress (0–100) toward advancing to the next stage
+ *
- damageAccumulation – accumulated ecological damage (0–100); when this
+ * exceeds a threshold the region regresses to the previous stage
+ *
+ */
+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 + "}";
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/recovery/SuccessionStage.java b/src/main/java/com/livingworld/modules/recovery/SuccessionStage.java
new file mode 100644
index 0000000..c614507
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/recovery/SuccessionStage.java
@@ -0,0 +1,122 @@
+package com.livingworld.modules.recovery;
+
+/**
+ * Ecological succession stages that a region can progress through or regress from.
+ *
+ * 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 advance 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
+ * regress to the previous succession stage.
+ *
+ *
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;
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/resources/ResourceDepletionModule.java b/src/main/java/com/livingworld/modules/resources/ResourceDepletionModule.java
new file mode 100644
index 0000000..1aee6a7
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/resources/ResourceDepletionModule.java
@@ -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()}.
+ *
+ *
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.
+ *
+ *
Regeneration rates (per tick)
+ *
+ * - mining – 0.01 % (geological timescale; very slow)
+ *
- logging – 0.5 % base + bonus when vegetationPressure is high
+ *
- farming – 0.3 % base + bonus when soilQuality is high
+ *
+ *
+ * Ecosystem feedback
+ * 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() {}
+}
diff --git a/src/main/java/com/livingworld/modules/resources/ResourceRegionData.java b/src/main/java/com/livingworld/modules/resources/ResourceRegionData.java
new file mode 100644
index 0000000..367449a
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/resources/ResourceRegionData.java
@@ -0,0 +1,123 @@
+package com.livingworld.modules.resources;
+
+/**
+ * Per-region resource depletion state tracked by {@link ResourceDepletionModule}.
+ *
+ * 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.
+ *
+ *
+ * - miningDepletion – how heavily the region's mineral resources have
+ * been extracted (very slow natural recovery)
+ *
- loggingDepletion – how heavily trees have been harvested (recovery
+ * is linked to vegetation succession)
+ *
- farmingDepletion – how heavily the land has been cultivated without
+ * rest (recovery driven by soil quality)
+ *
+ *
+ * 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 + "}";
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/soil/SoilModule.java b/src/main/java/com/livingworld/modules/soil/SoilModule.java
new file mode 100644
index 0000000..2273e94
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/soil/SoilModule.java
@@ -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()}.
+ *
+ *
This module runs after {@link com.livingworld.modules.pollution.PollutionModule}
+ * so it reads the current-tick pollution score when deciding contamination accumulation.
+ *
+ *
Per-tick rules
+ *
+ * - High pollution causes contamination to accumulate in the soil.
+ *
- Contamination steadily degrades fertility.
+ *
- Good vegetation cover promotes fertility recovery and resists erosion.
+ *
- Low fertility with low vegetation accelerates erosion.
+ *
- Soil quality is computed as a weighted score of fertility, contamination, and erosion.
+ *
+ */
+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() {}
+}
diff --git a/src/main/java/com/livingworld/modules/soil/SoilRegionData.java b/src/main/java/com/livingworld/modules/soil/SoilRegionData.java
new file mode 100644
index 0000000..76a81a8
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/soil/SoilRegionData.java
@@ -0,0 +1,123 @@
+package com.livingworld.modules.soil;
+
+/**
+ * Per-region soil state tracked by {@link SoilModule}.
+ *
+ * Five interacting values describe the physical and chemical condition of the
+ * land in a region. All values are clamped to [0, 100].
+ *
+ *
+ * - fertility – capacity to support plant growth (higher is better)
+ *
- moisture – water content in soil (too low or too high is harmful)
+ *
- contamination – chemical or biological pollution absorbed by soil
+ *
- compaction – density of soil; high compaction resists root growth
+ *
- erosion – structural loss of topsoil (higher is worse)
+ *
+ */
+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.
+ *
+ * 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.
+ *
+ *
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 + "}";
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/vegetation/VegetationModule.java b/src/main/java/com/livingworld/modules/vegetation/VegetationModule.java
new file mode 100644
index 0000000..75fbfa0
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/vegetation/VegetationModule.java
@@ -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()}.
+ *
+ *
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.
+ *
+ *
Succession model
+ * 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.
+ *
+ * Die-off model
+ * High pollution or very low soil quality kills vegetation in the reverse order
+ * (trees → shrubs → grass) and increases dead organic material.
+ *
+ * Dead vegetation
+ * 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() {}
+}
diff --git a/src/main/java/com/livingworld/modules/vegetation/VegetationRegionData.java b/src/main/java/com/livingworld/modules/vegetation/VegetationRegionData.java
new file mode 100644
index 0000000..ace3cc5
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/vegetation/VegetationRegionData.java
@@ -0,0 +1,130 @@
+package com.livingworld.modules.vegetation;
+
+/**
+ * Per-region vegetation state tracked by {@link VegetationModule}.
+ *
+ * Five values capture the biomass distribution and health of plant cover in a
+ * region. All values are clamped to [0, 100].
+ *
+ *
+ * - grassPressure – density and health of grass-layer plants
+ *
- flowerPressure – density and health of flowering plants
+ *
- shrubPressure – density and health of shrubs / undergrowth
+ *
- treePressure – density and health of tree canopy
+ *
- deadVegetation – accumulated dead organic material (higher = more decay burden)
+ *
+ *
+ * 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.
+ *
+ *
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.
+ *
+ *
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 + "}";
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/water/WaterModule.java b/src/main/java/com/livingworld/modules/water/WaterModule.java
new file mode 100644
index 0000000..98295dc
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/water/WaterModule.java
@@ -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.
+ *
+ *
This module runs after
+ * {@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.
+ *
+ *
Per-tick rules
+ *
+ * - Purification capacity is derived from current vegetation pressure
+ * (plants filter water).
+ *
- Low soil quality allows contamination to leach into groundwater,
+ * reducing water quality.
+ *
- Purification capacity partially restores water quality each tick.
+ *
- Water availability drifts toward a baseline unless stressed by
+ * drought or flood conditions (V1 placeholder).
+ *
+ */
+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() {}
+}
diff --git a/src/main/java/com/livingworld/modules/water/WaterRegionData.java b/src/main/java/com/livingworld/modules/water/WaterRegionData.java
new file mode 100644
index 0000000..302aba9
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/water/WaterRegionData.java
@@ -0,0 +1,87 @@
+package com.livingworld.modules.water;
+
+/**
+ * Per-region water state tracked by {@link WaterModule}.
+ *
+ * All values are clamped to [0, 100].
+ *
+ *
+ * - waterAvailability – how much fresh water exists in this region
+ *
- purificationCapacity – ecosystem's ability to filter water;
+ * driven by vegetation cover
+ *
- droughtRisk – likelihood of water shortage conditions
+ *
- floodRisk – likelihood of waterlogging or runoff damage
+ *
+ */
+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 + "}";
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectConsumer.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectConsumer.java
new file mode 100644
index 0000000..2c53670
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectConsumer.java
@@ -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.
+ *
+ * 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 -> {};
+}
diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectRequest.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectRequest.java
new file mode 100644
index 0000000..b964e49
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectRequest.java
@@ -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.
+ *
+ *
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);
+ }
+ }
+}
diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java
new file mode 100644
index 0000000..4bdf42f
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectType.java
@@ -0,0 +1,42 @@
+package com.livingworld.modules.worldeffects;
+
+/**
+ * Categories of visible world changes that the ecosystem simulation can request.
+ *
+ *
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,
+}
diff --git a/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java
new file mode 100644
index 0000000..44dbe2f
--- /dev/null
+++ b/src/main/java/com/livingworld/modules/worldeffects/WorldEffectsModule.java
@@ -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.
+ *
+ *
This module runs last 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.
+ *
+ *
This module contains no Minecraft imports. The platform boundary lives
+ * entirely within the consumer implementations.
+ *
+ *
Visible effects generated
+ *
+ * - {@link WorldEffectType#GRASS_DEGRADES_TO_DIRT} — when pollutionScore > 60
+ * and soilQuality < 30.
+ *
- {@link WorldEffectType#VEGETATION_SPREADS} — when vegetationPressure > 60
+ * and soilQuality > 50.
+ *
- {@link WorldEffectType#SAPLING_GROWTH_SLOWED} — when logging depletion
+ * exceeds 50 % (read from {@link ResourceRegionData}).
+ *
- {@link WorldEffectType#SAPLING_GROWTH_BOOSTED} — when the succession stage
+ * is {@link SuccessionStage#YOUNG_WOODLAND} or higher.
+ *
- {@link WorldEffectType#POLLUTION_VISUAL_INDICATOR} — when pollutionScore > 70.
+ *
+ *
+ * Registering consumers
+ * 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 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 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);
+ }
+ }
+}
diff --git a/src/main/java/com/livingworld/platform/BlockBreakInfo.java b/src/main/java/com/livingworld/platform/BlockBreakInfo.java
new file mode 100644
index 0000000..3af193f
--- /dev/null
+++ b/src/main/java/com/livingworld/platform/BlockBreakInfo.java
@@ -0,0 +1,29 @@
+package com.livingworld.platform;
+
+/**
+ * Platform-neutral description of a block broken by a player.
+ *
+ * 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.
+ *
+ * @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");
+ }
+ }
+}
diff --git a/src/main/java/com/livingworld/platform/neoforge/NeoForgePlatformAdapter.java b/src/main/java/com/livingworld/platform/neoforge/NeoForgePlatformAdapter.java
index 58d7187..6fc097e 100644
--- a/src/main/java/com/livingworld/platform/neoforge/NeoForgePlatformAdapter.java
+++ b/src/main/java/com/livingworld/platform/neoforge/NeoForgePlatformAdapter.java
@@ -1,5 +1,6 @@
package com.livingworld.platform.neoforge;
+import com.livingworld.platform.BlockBreakInfo;
import com.livingworld.platform.PlatformAdapter;
import com.mojang.brigadier.CommandDispatcher;
import java.nio.file.Path;
@@ -8,11 +9,14 @@ import java.util.function.Consumer;
import java.util.function.Supplier;
import net.minecraft.SharedConstants;
import net.minecraft.commands.CommandSourceStack;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.level.Level;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.fml.ModList;
import net.neoforged.fml.loading.FMLEnvironment;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
+import net.neoforged.neoforge.event.level.BlockEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
/**
@@ -25,17 +29,21 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
private final Supplier worldSaveDirectory;
private final Consumer> commandRegistrar;
private final Runnable serverTickHook;
+ private final Consumer blockBreakHandler;
private boolean commandsRegistered;
private boolean serverTickRegistered;
+ private boolean playerEventsRegistered;
public NeoForgePlatformAdapter(
Supplier worldSaveDirectory,
Consumer> commandRegistrar,
- Runnable serverTickHook) {
+ Runnable serverTickHook,
+ Consumer blockBreakHandler) {
this.worldSaveDirectory =
Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory");
this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar");
this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook");
+ this.blockBreakHandler = Objects.requireNonNull(blockBreakHandler, "blockBreakHandler");
}
@Override
@@ -92,6 +100,39 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
@Override
public void registerPlayerEventHooks() {
- // Player activity hooks are intentionally deferred until a module needs them.
+ if (playerEventsRegistered) {
+ return;
+ }
+ NeoForge.EVENT_BUS.addListener(BlockEvent.BreakEvent.class, this::onBlockBroken);
+ playerEventsRegistered = true;
+ }
+
+ /**
+ * Translates a NeoForge block-break event into a platform-neutral
+ * {@link BlockBreakInfo} and forwards it to the core handler.
+ *
+ * Only player-caused breaks are forwarded (non-null player). Creative-mode
+ * breaks are included intentionally so creative players can still trigger
+ * depletion for testing.
+ */
+ 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()));
}
}
diff --git a/src/test/java/com/livingworld/core/services/FileRegionPersistenceServiceTest.java b/src/test/java/com/livingworld/core/services/FileRegionPersistenceServiceTest.java
index 9cf214b..64a47d0 100644
--- a/src/test/java/com/livingworld/core/services/FileRegionPersistenceServiceTest.java
+++ b/src/test/java/com/livingworld/core/services/FileRegionPersistenceServiceTest.java
@@ -4,9 +4,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import com.livingworld.modules.pollution.PollutionRegionData;
+import com.livingworld.modules.recovery.RecoveryRegionData;
+import com.livingworld.modules.recovery.SuccessionStage;
+import com.livingworld.modules.soil.SoilRegionData;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory;
+import com.livingworld.regions.RegionModuleData;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
@@ -99,6 +104,77 @@ class FileRegionPersistenceServiceTest {
assertEquals(0, restarted.getDirtyRegionCount());
}
+ @Test
+ void roundTripsModuleData() {
+ FileRegionPersistenceService service = service();
+ service.registerModuleCodec(
+ "pollution",
+ (data, w) -> {
+ PollutionRegionData d = data.get("pollution", PollutionRegionData.class)
+ .orElseGet(PollutionRegionData::defaults);
+ w.writeDouble("airPollution", d.getAirPollution());
+ w.writeDouble("groundPollution", d.getGroundPollution());
+ w.writeDouble("waterPollution", d.getWaterPollution());
+ w.writeDouble("decayResistance", d.getDecayResistance());
+ },
+ (r, data) -> data.put("pollution", new PollutionRegionData(
+ r.readDouble("airPollution", 0.0),
+ r.readDouble("groundPollution", 0.0),
+ r.readDouble("waterPollution", 0.0),
+ r.readDouble("decayResistance", 20.0))));
+ service.registerModuleCodec(
+ "soil",
+ (data, w) -> {
+ SoilRegionData d = data.get("soil", SoilRegionData.class)
+ .orElseGet(SoilRegionData::defaults);
+ w.writeDouble("fertility", d.getFertility());
+ w.writeDouble("contamination", d.getContamination());
+ },
+ (r, data) -> data.put("soil", new SoilRegionData(
+ r.readDouble("fertility", 60.0),
+ r.readDouble("moisture", 50.0),
+ r.readDouble("contamination", 0.0),
+ r.readDouble("compaction", 10.0),
+ r.readDouble("erosion", 0.0))));
+ service.registerModuleCodec(
+ "recovery",
+ (data, w) -> {
+ RecoveryRegionData d = data.get("recovery", RecoveryRegionData.class)
+ .orElseGet(RecoveryRegionData::defaults);
+ w.writeString("successionStage", d.getSuccessionStage().name());
+ w.writeDouble("damageAccumulation", d.getDamageAccumulation());
+ },
+ (r, data) -> data.put("recovery", new RecoveryRegionData(
+ SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
+ r.readDouble("recoveryProgress", 0.0),
+ r.readDouble("damageAccumulation", 0.0))));
+
+ Region original = new RegionFactory().createNewRegion(
+ new RegionCoordinate("minecraft:overworld", 0, 0), 0);
+ RegionModuleData moduleData = original.getModuleData();
+ moduleData.put("pollution", new PollutionRegionData(42.0, 15.0, 8.0, 25.0));
+ moduleData.put("soil", new SoilRegionData(75.0, 60.0, 5.0, 20.0, 3.0));
+ moduleData.put("recovery", new RecoveryRegionData(SuccessionStage.YOUNG_WOODLAND, 66.0, 12.5));
+
+ service.saveRegion(original);
+ Region restored = service.loadRegion(original.getCoordinate()).orElseThrow();
+ RegionModuleData restoredData = restored.getModuleData();
+
+ PollutionRegionData pollution = restoredData.get("pollution", PollutionRegionData.class).orElseThrow();
+ assertEquals(42.0, pollution.getAirPollution());
+ assertEquals(15.0, pollution.getGroundPollution());
+ assertEquals(8.0, pollution.getWaterPollution());
+ assertEquals(25.0, pollution.getDecayResistance());
+
+ SoilRegionData soil = restoredData.get("soil", SoilRegionData.class).orElseThrow();
+ assertEquals(75.0, soil.getFertility());
+ assertEquals(5.0, soil.getContamination());
+
+ RecoveryRegionData recovery = restoredData.get("recovery", RecoveryRegionData.class).orElseThrow();
+ assertEquals(SuccessionStage.YOUNG_WOODLAND, recovery.getSuccessionStage());
+ assertEquals(12.5, recovery.getDamageAccumulation());
+ }
+
private FileRegionPersistenceService service() {
return new FileRegionPersistenceService(
temporaryDirectory,
diff --git a/src/test/java/com/livingworld/data/migration/MigrationManagerTest.java b/src/test/java/com/livingworld/data/migration/MigrationManagerTest.java
new file mode 100644
index 0000000..c37fab3
--- /dev/null
+++ b/src/test/java/com/livingworld/data/migration/MigrationManagerTest.java
@@ -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");
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/EcosystemModuleIntegrationTest.java b/src/test/java/com/livingworld/modules/EcosystemModuleIntegrationTest.java
new file mode 100644
index 0000000..da45b75
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/EcosystemModuleIntegrationTest.java
@@ -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 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);
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/Volume2IntegrationTest.java b/src/test/java/com/livingworld/modules/Volume2IntegrationTest.java
new file mode 100644
index 0000000..17b6544
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/Volume2IntegrationTest.java
@@ -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 effectsCapture;
+
+ private List 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());
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/ecosystem/EcosystemRegionDataTest.java b/src/test/java/com/livingworld/modules/ecosystem/EcosystemRegionDataTest.java
new file mode 100644
index 0000000..4c541f4
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/ecosystem/EcosystemRegionDataTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/pollution/PollutionRegionDataTest.java b/src/test/java/com/livingworld/modules/pollution/PollutionRegionDataTest.java
new file mode 100644
index 0000000..75558b5
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/pollution/PollutionRegionDataTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/recovery/RecoveryModuleTest.java b/src/test/java/com/livingworld/modules/recovery/RecoveryModuleTest.java
new file mode 100644
index 0000000..7f9993c
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/recovery/RecoveryModuleTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/resources/ResourceDepletionModuleTest.java b/src/test/java/com/livingworld/modules/resources/ResourceDepletionModuleTest.java
new file mode 100644
index 0000000..0ca0d4a
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/resources/ResourceDepletionModuleTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/soil/SoilRegionDataTest.java b/src/test/java/com/livingworld/modules/soil/SoilRegionDataTest.java
new file mode 100644
index 0000000..7ebc6d7
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/soil/SoilRegionDataTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/vegetation/VegetationRegionDataTest.java b/src/test/java/com/livingworld/modules/vegetation/VegetationRegionDataTest.java
new file mode 100644
index 0000000..3bc855a
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/vegetation/VegetationRegionDataTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/water/WaterModuleTest.java b/src/test/java/com/livingworld/modules/water/WaterModuleTest.java
new file mode 100644
index 0000000..f3d61f7
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/water/WaterModuleTest.java
@@ -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);
+ }
+}
diff --git a/src/test/java/com/livingworld/modules/worldeffects/WorldEffectsModuleTest.java b/src/test/java/com/livingworld/modules/worldeffects/WorldEffectsModuleTest.java
new file mode 100644
index 0000000..43c9d2c
--- /dev/null
+++ b/src/test/java/com/livingworld/modules/worldeffects/WorldEffectsModuleTest.java
@@ -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 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 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");
+ }
+}