diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 0d08e19..2bb6885 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -34,7 +34,8 @@ public class LivingWorldMod { NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter( bootstrap::getWorldSaveDirectory, bootstrap::registerCommands, - bootstrap::onServerTick); + bootstrap::onServerTick, + bootstrap::handleBlockBreak); this.bootstrap.initialize(platformAdapter); eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup()); diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index a5818fc..caaba28 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -17,7 +17,26 @@ import com.livingworld.events.LivingWorldEventBus; import com.livingworld.modules.ModuleContext; import com.livingworld.modules.ModuleRegistry; import com.livingworld.modules.ServerContext; +import com.livingworld.modules.ecosystem.EcosystemModule; +import com.livingworld.modules.pollution.PollutionModule; +import com.livingworld.modules.ecosystem.EcosystemRegionData; +import com.livingworld.modules.pollution.PollutionRegionData; +import com.livingworld.modules.recovery.RecoveryModule; +import com.livingworld.modules.recovery.RecoveryRegionData; +import com.livingworld.modules.recovery.SuccessionStage; +import com.livingworld.modules.resources.ResourceDepletionModule; +import com.livingworld.modules.resources.ResourceRegionData; +import com.livingworld.modules.soil.SoilModule; +import com.livingworld.modules.soil.SoilRegionData; +import com.livingworld.modules.vegetation.VegetationModule; +import com.livingworld.modules.vegetation.VegetationRegionData; +import com.livingworld.modules.water.WaterModule; +import com.livingworld.modules.water.WaterRegionData; +import com.livingworld.modules.worldeffects.WorldEffectsModule; +import com.livingworld.platform.BlockBreakInfo; import com.livingworld.platform.PlatformAdapter; +import com.livingworld.regions.Region; +import com.livingworld.regions.RegionCoordinate; import com.livingworld.regions.RegionFactory; import com.livingworld.regions.RegionLifecycleController; import com.livingworld.regions.RegionManager; @@ -39,6 +58,7 @@ public final class LivingWorldBootstrap { private RegionManager regionManager; private ModuleRegistry moduleRegistry; private SimulationManager simulationManager; + private WorldEffectsModule worldEffectsModule; private boolean initialized; private boolean serverReady; @@ -105,11 +125,61 @@ public final class LivingWorldBootstrap { requireServerReady(); ServerContext context = new ServerContext(); moduleRegistry.getEnabledModules().forEach(module -> module.onServerStarted(context)); + + // Register the world-effects consumer now that services are live. + // Logs what would be applied; actual block manipulation is Track C. + worldEffectsModule.registerConsumer(request -> + LivingWorldLogger.info( + DiagnosticCategory.SIMULATION, + "WorldEffect: " + request.type() + + " region=" + request.region() + + " intensity=" + String.format("%.2f", request.intensity()))); + LivingWorldLogger.info( DiagnosticCategory.BOOTSTRAP, "onServerStarted - server started."); } + /** + * Handles a block-break event forwarded from the platform adapter. + * + *

Determines whether the block represents mining, logging, or farming activity + * and records the corresponding depletion on the region's + * {@link ResourceRegionData}. Safe to call before the server is ready (returns + * immediately in that case).

+ * + * @param info platform-neutral description of the broken block + */ + public void handleBlockBreak(BlockBreakInfo info) { + if (!serverReady) { + return; + } + RegionCoordinate coord = RegionCoordinate.fromBlock( + info.dimensionId(), + info.blockX(), + info.blockZ(), + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); + + Region region = regionManager.getOrCreateRegion(coord); + ResourceRegionData resources = region.getModuleData() + .get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class) + .orElseGet(ResourceRegionData::defaults); + + String block = info.blockRegistryName(); + if (isLog(block) || isLeaves(block) || isWood(block)) { + resources.recordLogging(1.0); + } else if (isOre(block) || isStone(block)) { + resources.recordMining(1.0); + } else if (isCrop(block) || isFarmland(block)) { + resources.recordFarming(1.0); + } else { + return; // not a tracked resource — don't dirty the region + } + + region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources); + regionManager.markDirty(region); + } + /** * Called when the server is stopping. */ @@ -165,6 +235,7 @@ public final class LivingWorldBootstrap { new FileRegionPersistenceService( saveDirectory.resolve("living_world"), LivingWorldConstants.MOD_VERSION); + registerModuleCodecs(persistenceService); RegionCache regionCache = new RegionCache(); RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache); regionManager = new RegionManager( @@ -175,6 +246,7 @@ public final class LivingWorldBootstrap { new RegionLifecycleController(), config); moduleRegistry = new ModuleRegistry(); + registerEcosystemModules(moduleRegistry); LivingWorldEventBus eventBus = new LivingWorldEventBus(); DefaultTimeService timeService = new DefaultTimeService(); SimulationScheduler scheduler = new SimulationScheduler(config); @@ -202,6 +274,198 @@ public final class LivingWorldBootstrap { moduleRegistry.initializeAll(new ModuleContext(services)); } + // ------------------------------------------------------------------ + // Block categorisation helpers (registry-name based, no Minecraft imports) + // ------------------------------------------------------------------ + + private static boolean isLog(String block) { + return block.contains("_log") || block.contains("_stem"); + } + + private static boolean isWood(String block) { + return block.contains("_wood") || block.contains("_hyphae"); + } + + private static boolean isLeaves(String block) { + return block.contains("_leaves"); + } + + private static boolean isOre(String block) { + return block.contains("_ore"); + } + + private static boolean isStone(String block) { + return block.equals("minecraft:stone") + || block.equals("minecraft:deepslate") + || block.equals("minecraft:cobblestone") + || block.equals("minecraft:gravel") + || block.equals("minecraft:sand"); + } + + private static boolean isCrop(String block) { + return block.contains("wheat") || block.contains("carrot") + || block.contains("potato") || block.contains("beetroot") + || block.contains("sugar_cane") || block.contains("pumpkin") + || block.contains("melon"); + } + + private static boolean isFarmland(String block) { + return block.contains("farmland"); + } + + /** + * Returns the {@link WorldEffectsModule} so the platform adapter can register a + * block-change consumer after bootstrap. + * + *

Call only after {@link #onServerStarting(Path)} has returned.

+ */ + public WorldEffectsModule getWorldEffectsModule() { + requireServerReady(); + return worldEffectsModule; + } + + /** + * Registers per-region data codecs for all 7 data-bearing ecosystem modules. + * + *

WorldEffects has no persistent per-region data and is intentionally omitted.

+ */ + private static void registerModuleCodecs(FileRegionPersistenceService service) { + service.registerModuleCodec( + PollutionModule.MODULE_ID, + (data, w) -> { + PollutionRegionData d = data.get(PollutionModule.MODULE_ID, PollutionRegionData.class) + .orElseGet(PollutionRegionData::defaults); + w.writeDouble("airPollution", d.getAirPollution()); + w.writeDouble("groundPollution", d.getGroundPollution()); + w.writeDouble("waterPollution", d.getWaterPollution()); + w.writeDouble("decayResistance", d.getDecayResistance()); + }, + (r, data) -> data.put(PollutionModule.MODULE_ID, new PollutionRegionData( + r.readDouble("airPollution", 0.0), + r.readDouble("groundPollution", 0.0), + r.readDouble("waterPollution", 0.0), + r.readDouble("decayResistance", 20.0)))); + + service.registerModuleCodec( + SoilModule.MODULE_ID, + (data, w) -> { + SoilRegionData d = data.get(SoilModule.MODULE_ID, SoilRegionData.class) + .orElseGet(SoilRegionData::defaults); + w.writeDouble("fertility", d.getFertility()); + w.writeDouble("moisture", d.getMoisture()); + w.writeDouble("contamination", d.getContamination()); + w.writeDouble("compaction", d.getCompaction()); + w.writeDouble("erosion", d.getErosion()); + }, + (r, data) -> data.put(SoilModule.MODULE_ID, new SoilRegionData( + r.readDouble("fertility", 60.0), + r.readDouble("moisture", 50.0), + r.readDouble("contamination", 0.0), + r.readDouble("compaction", 10.0), + r.readDouble("erosion", 0.0)))); + + service.registerModuleCodec( + WaterModule.MODULE_ID, + (data, w) -> { + WaterRegionData d = data.get(WaterModule.MODULE_ID, WaterRegionData.class) + .orElseGet(WaterRegionData::defaults); + w.writeDouble("waterAvailability", d.getWaterAvailability()); + w.writeDouble("purificationCapacity", d.getPurificationCapacity()); + w.writeDouble("droughtRisk", d.getDroughtRisk()); + w.writeDouble("floodRisk", d.getFloodRisk()); + }, + (r, data) -> data.put(WaterModule.MODULE_ID, new WaterRegionData( + r.readDouble("waterAvailability", 60.0), + r.readDouble("purificationCapacity", 50.0), + r.readDouble("droughtRisk", 10.0), + r.readDouble("floodRisk", 10.0)))); + + service.registerModuleCodec( + VegetationModule.MODULE_ID, + (data, w) -> { + VegetationRegionData d = data.get(VegetationModule.MODULE_ID, VegetationRegionData.class) + .orElseGet(VegetationRegionData::defaults); + w.writeDouble("grassPressure", d.getGrassPressure()); + w.writeDouble("flowerPressure", d.getFlowerPressure()); + w.writeDouble("shrubPressure", d.getShrubPressure()); + w.writeDouble("treePressure", d.getTreePressure()); + w.writeDouble("deadVegetation", d.getDeadVegetation()); + }, + (r, data) -> data.put(VegetationModule.MODULE_ID, new VegetationRegionData( + r.readDouble("grassPressure", 50.0), + r.readDouble("flowerPressure", 30.0), + r.readDouble("shrubPressure", 30.0), + r.readDouble("treePressure", 40.0), + r.readDouble("deadVegetation", 5.0)))); + + service.registerModuleCodec( + ResourceDepletionModule.MODULE_ID, + (data, w) -> { + ResourceRegionData d = data.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class) + .orElseGet(ResourceRegionData::defaults); + w.writeDouble("miningDepletion", d.getMiningDepletion()); + w.writeDouble("loggingDepletion", d.getLoggingDepletion()); + w.writeDouble("farmingDepletion", d.getFarmingDepletion()); + }, + (r, data) -> data.put(ResourceDepletionModule.MODULE_ID, new ResourceRegionData( + r.readDouble("miningDepletion", 0.0), + r.readDouble("loggingDepletion", 0.0), + r.readDouble("farmingDepletion", 0.0)))); + + service.registerModuleCodec( + RecoveryModule.MODULE_ID, + (data, w) -> { + RecoveryRegionData d = data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class) + .orElseGet(RecoveryRegionData::defaults); + w.writeString("successionStage", d.getSuccessionStage().name()); + w.writeDouble("recoveryProgress", d.getRecoveryProgress()); + w.writeDouble("damageAccumulation", d.getDamageAccumulation()); + }, + (r, data) -> data.put(RecoveryModule.MODULE_ID, new RecoveryRegionData( + SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())), + r.readDouble("recoveryProgress", 0.0), + r.readDouble("damageAccumulation", 0.0)))); + + service.registerModuleCodec( + EcosystemModule.MODULE_ID, + (data, w) -> { + EcosystemRegionData d = data.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class) + .orElseGet(EcosystemRegionData::defaults); + w.writeDouble("ecosystemHealth", d.getEcosystemHealth()); + w.writeDouble("stress", d.getStress()); + w.writeDouble("resilience", d.getResilience()); + w.writeDouble("recoveryRate", d.getRecoveryRate()); + }, + (r, data) -> data.put(EcosystemModule.MODULE_ID, new EcosystemRegionData( + r.readDouble("ecosystemHealth", 60.0), + r.readDouble("stress", 20.0), + r.readDouble("resilience", 50.0), + r.readDouble("recoveryRate", 5.0)))); + } + + /** + * Registers all ecosystem simulation modules with the registry in pipeline order. + * + *

Order matters: each module may read metrics written by earlier modules in the + * same tick. The declared order here is the execution order inside + * {@link SimulationManager}.

+ */ + private void registerEcosystemModules(ModuleRegistry registry) { + registry.register(new PollutionModule()); + registry.register(new SoilModule()); + registry.register(new WaterModule()); + registry.register(new VegetationModule()); + registry.register(new ResourceDepletionModule()); + registry.register(new RecoveryModule()); + registry.register(new EcosystemModule()); + worldEffectsModule = new WorldEffectsModule(); + registry.register(worldEffectsModule); + LivingWorldLogger.info( + DiagnosticCategory.BOOTSTRAP, + "Registered 8 ecosystem modules (pollution → soil → water → vegetation" + + " → resources → recovery → ecosystem → worldeffects)."); + } + private void requireInitialized() { if (!initialized) { throw new IllegalStateException("bootstrap has not been initialized"); diff --git a/src/main/java/com/livingworld/core/services/FileRegionPersistenceService.java b/src/main/java/com/livingworld/core/services/FileRegionPersistenceService.java index 3b35e14..d7e1efa 100644 --- a/src/main/java/com/livingworld/core/services/FileRegionPersistenceService.java +++ b/src/main/java/com/livingworld/core/services/FileRegionPersistenceService.java @@ -1,6 +1,10 @@ package com.livingworld.core.services; import com.livingworld.data.saved.SaveMetadata; +import com.livingworld.data.serialization.PersistenceReader; +import com.livingworld.data.serialization.PersistenceWriter; +import com.livingworld.data.serialization.PropertiesPersistenceReader; +import com.livingworld.data.serialization.PropertiesPersistenceWriter; import com.livingworld.debug.DiagnosticCategory; import com.livingworld.debug.LivingWorldLogger; import com.livingworld.regions.Region; @@ -18,12 +22,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.Clock; +import java.util.ArrayList; import java.util.Base64; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Properties; import java.util.UUID; +import java.util.function.BiConsumer; /** * File-backed persistence for the Volume 1 region model. @@ -40,8 +47,14 @@ public final class FileRegionPersistenceService implements PersistenceService { private final String modVersion; private final Clock clock; private final Map dirtyRegions = new LinkedHashMap<>(); + private final List moduleCodecs = new ArrayList<>(); private SaveMetadata metadata; + private record ModuleCodecEntry( + String moduleId, + BiConsumer encoder, + BiConsumer decoder) {} + public FileRegionPersistenceService(Path rootDirectory, String modVersion) { this(rootDirectory, modVersion, Clock.systemUTC()); } @@ -136,6 +149,25 @@ public final class FileRegionPersistenceService implements PersistenceService { return dirtyRegions.size(); } + /** + * Registers a codec that serialises one module's per-region data. + * + *

Must be called before any region is saved or loaded. Codecs are applied + * in registration order during both encode and decode. Each codec writes to / + * reads from keys prefixed with {@code "mod.."} so modules cannot + * collide.

+ * + * @param moduleId the unique module identifier (used as key namespace) + * @param encoder writes module data from {@link RegionModuleData} to a writer + * @param decoder reads module data from a reader and populates {@link RegionModuleData} + */ + public synchronized void registerModuleCodec( + String moduleId, + BiConsumer encoder, + BiConsumer decoder) { + moduleCodecs.add(new ModuleCodecEntry(moduleId, encoder, decoder)); + } + private void initializeStorage() { try { Files.createDirectories(regionsDirectory); @@ -242,6 +274,13 @@ public final class FileRegionPersistenceService implements PersistenceService { properties.setProperty( "metric.recoveryPressure", Double.toString(metrics.getRecoveryPressure())); + + for (ModuleCodecEntry codec : moduleCodecs) { + String prefix = "mod." + codec.moduleId() + "."; + codec.encoder().accept( + region.getModuleData(), + new PropertiesPersistenceWriter(properties, prefix)); + } return properties; } @@ -270,6 +309,14 @@ public final class FileRegionPersistenceService implements PersistenceService { metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion")); metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure")); + RegionModuleData moduleData = new RegionModuleData(); + for (ModuleCodecEntry codec : moduleCodecs) { + String prefix = "mod." + codec.moduleId() + "."; + codec.decoder().accept( + new PropertiesPersistenceReader(properties, prefix), + moduleData); + } + Region region = new Region( UUID.fromString(required(properties, "id")), new RegionCoordinate( @@ -282,7 +329,7 @@ public final class FileRegionPersistenceService implements PersistenceService { false, flags, metrics, - new RegionModuleData()); + moduleData); region.validate(); return region; } diff --git a/src/main/java/com/livingworld/data/migration/MigrationManager.java b/src/main/java/com/livingworld/data/migration/MigrationManager.java new file mode 100644 index 0000000..de1d6ba --- /dev/null +++ b/src/main/java/com/livingworld/data/migration/MigrationManager.java @@ -0,0 +1,183 @@ +package com.livingworld.data.migration; + +import com.livingworld.debug.DiagnosticCategory; +import com.livingworld.debug.LivingWorldLogger; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; + +/** + * Detects the schema version of region save data and applies registered + * {@link RegionMigration} steps in order until the data reaches the target version. + * + *

Usage: + *

    + *
  1. Construct with the path to the migrations log file (or {@code null} to disable logging). + *
  2. Register one {@link RegionMigration} per version gap via {@link #register}. + *
  3. Call {@link #migrateIfNeeded} before decoding saved region properties. + *
+ */ +public final class MigrationManager { + + private final Map migrations = new TreeMap<>(); + private final Path logFile; + + /** + * @param logFile path to the append-only migrations log; {@code null} to disable file logging + */ + public MigrationManager(Path logFile) { + this.logFile = logFile; + } + + /** + * Registers a migration step. + * + * @throws IllegalArgumentException if the migration is null, if its version span is not + * exactly one, or if a migration for the same + * {@code fromVersion} is already registered + */ + public void register(RegionMigration migration) { + if (migration == null) { + throw new IllegalArgumentException("migration must not be null"); + } + int from = migration.fromVersion(); + int to = migration.toVersion(); + if (to != from + 1) { + throw new IllegalArgumentException( + "Migration must advance exactly one version; got " + from + " → " + to); + } + if (migrations.containsKey(from)) { + throw new IllegalArgumentException( + "Migration already registered for fromVersion " + from); + } + migrations.put(from, migration); + } + + /** + * Returns the number of registered migration steps. + */ + public int getRegisteredMigrationCount() { + return migrations.size(); + } + + /** + * Returns {@code true} when the data's {@code schemaVersion} already equals + * {@code targetVersion} and no migration is needed. + */ + public boolean isUpToDate(Properties data, int targetVersion) { + return readVersion(data) == targetVersion; + } + + /** + * Migrates the given save data to {@code targetVersion} if it is behind, and returns + * the (possibly modified) properties. Returns the original object unchanged when already + * at the target version. + * + * @throws IllegalStateException if the data is ahead of {@code targetVersion}, if + * {@code schemaVersion} is missing or invalid, or if the + * registered migrations do not cover the required range + */ + public Properties migrateIfNeeded(Properties data, int targetVersion) { + int currentVersion = readVersion(data); + if (currentVersion == targetVersion) { + return data; + } + if (currentVersion > targetVersion) { + throw new IllegalStateException( + "Cannot downgrade save data from schema version " + + currentVersion + " to " + targetVersion); + } + + List path = buildMigrationPath(currentVersion, targetVersion); + + Properties result = copyProperties(data); + for (RegionMigration step : path) { + result = step.apply(result); + result.setProperty("schemaVersion", Integer.toString(step.toVersion())); + recordStep(step.fromVersion(), step.toVersion()); + } + return result; + } + + // ------------------------------------------------------------------------- + // private helpers + // ------------------------------------------------------------------------- + + private List buildMigrationPath(int from, int to) { + List 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

+ *
    + *
  1. Air, ground, and water pollution each decay at different rates. + *
  2. Ground pollution slowly leaches into water. + *
  3. {@link RegionMetrics#getPollutionScore()} is recomputed as a weighted average. + *
  4. {@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

+ *
    + *
  1. High pollution causes contamination to accumulate in the soil. + *
  2. Contamination steadily degrades fertility. + *
  3. Good vegetation cover promotes fertility recovery and resists erosion. + *
  4. Low fertility with low vegetation accelerates erosion. + *
  5. 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

+ *
    + *
  1. Purification capacity is derived from current vegetation pressure + * (plants filter water). + *
  2. Low soil quality allows contamination to leach into groundwater, + * reducing water quality. + *
  3. Purification capacity partially restores water quality each tick. + *
  4. 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

+ *
    + *
  1. {@link WorldEffectType#GRASS_DEGRADES_TO_DIRT} — when pollutionScore > 60 + * and soilQuality < 30. + *
  2. {@link WorldEffectType#VEGETATION_SPREADS} — when vegetationPressure > 60 + * and soilQuality > 50. + *
  3. {@link WorldEffectType#SAPLING_GROWTH_SLOWED} — when logging depletion + * exceeds 50 % (read from {@link ResourceRegionData}). + *
  4. {@link WorldEffectType#SAPLING_GROWTH_BOOSTED} — when the succession stage + * is {@link SuccessionStage#YOUNG_WOODLAND} or higher. + *
  5. {@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"); + } +}