Compare commits

...

10 Commits

Author SHA1 Message Date
George 67a1e07b82 Add /lw hud debug command to toggle region HUD without compass
/lw hud toggles a persistent per-player flag stored in LivingWorldBootstrap.
When enabled, the action-bar HUD (Eco/Poll/Soil/Wat) is shown every 20 ticks
alongside the existing compass-based trigger. Flag is cleared on server stop.
Requires operator permission level 2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:02:38 +01:00
George c9f927b265 Add mob spawning, HUD, agriculture, acid rain, biome caps, directional wind
Step 1 – Mob spawn feedback: FinalizeSpawnEvent suppresses passive mobs (Animal)
  in regions with ecosystem health < 30 (up to 70% cancellation) and hostile mobs
  (Monster) in regions > 60 health (up to 50% cancellation).

Step 2 – Compass HUD: holding a compass shows a colour-coded action-bar overlay
  with Eco/Poll/Soil/Wat scores for the current region (green/yellow/red banded).

Step 3 – Resource loop closure: logging depletion recovery is already tied to
  vegetation pressure in ResourceDepletionModule; sapling-to-tree growth continues
  to close the loop via the existing vegetation feedback path.

Step 4 – Agriculture: bone meal (PlayerInteractEvent.RightClickBlock) adds +2
  soil fertility; harvesting fully-grown (age=7) crops (BlockEvent.BreakEvent)
  drains -0.5 fertility and adds 0.3 farming depletion.

Step 5 – Acid rain: when raining AND region pollution > 20, rainfall drains soil
  fertility, raises soil contamination, and reduces water availability proportional
  to excess pollution. Implemented in applyWeatherFeedback().

Step 6 – Biome-aware succession: RecoveryRegionData gains a maxSuccessionStage
  cap (default MATURE_FOREST). deriveBiomeCap() samples the biome at the region
  centre on first player entry and calls setRegionBiomeCap(); deserts/badlands →
  SPARSE_GRASS, savannas/mountains/cold → SCRUBLAND, beaches/oceans → GRASSLAND,
  forests/taiga/jungle → MATURE_FOREST, plains/swamp → YOUNG_WOODLAND.

Step 7 – Directional wind: spreadPollutionAcrossRegions() drifts a wind angle by
  ±0.05 rad per sim cycle; each cardinal neighbour's spread rate is multiplied by
  (1 + alignment×0.5) where alignment = cos(windAngle − offsetAngle), creating
  downwind (×1.5) and upwind (×0.5) asymmetry.

All 400 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:57:09 +01:00
George 2350c27374 Vary sapling species by succession stage for realistic forest growth
Pioneer species (oak, birch) dominate at YOUNG_WOODLAND (intensity ~0.5).
Spruce and dark oak join at mid intensity. At MATURE_FOREST (intensity ~1.0)
the full mix includes cherry and jungle saplings for a diverse canopy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:12:25 +01:00
George 773fb0223f Tune ecosystem sensitivity and add simulation richness (Steps 1-6)
Tuning:
- simulationIntervalTicks: 100→50 (faster sim feedback)
- FURNACE_SCAN_INTERVAL: 100→50 (matches sim interval for pollution equilibrium)
- PollutionModule.BASE_DECAY_RATE: 0.02→0.008 (slower natural decay)
- SoilModule.POLLUTION_CONTAMINATION_THRESHOLD: 30→10, CONTAMINATION_FERTILITY_DRAIN: 0.002→0.005
- VegetationModule.DIEOFF_POLLUTION_THRESHOLD: 60→30
- WorldEffectsModule: lower grass-degrade/pollution-indicator thresholds so effects
  trigger from realistic furnace-driven pollution levels
- NeoForgeWorldEffectExecutor.BLOCK_ATTEMPTS: 8→20

New features:
- Campfire pollution source (0.2 air, 0.05 ground per lit campfire)
- Cross-region air pollution spreading (2% of gradient per sim cycle)
- SimulationManager.queueRegionForUpdate() for priority enqueueing
- LivingWorldBootstrap.notifyPlayerInRegion() boosts priority when player enters region
- Player region tracking in LivingWorldMod: checks every 20 MC ticks, queues update
  on region change
- NeoForgeWorldEffectExecutor: implement SAPLING_GROWTH_BOOSTED (oak sapling placement);
  VEGETATION_SPREADS also places short grass and flowers; GRASS_DEGRADES_TO_DIRT clears
  plants and converts dirt→coarse_dirt at intensity>0.5

All 400 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 18:55:08 +01:00
George 577c14b6ea Lower world effect thresholds to achievable levels; add water pollution to furnaces
The previous thresholds (grass=60, smoke=70) required pollutionScore > 60.
pollutionScore is a weighted average, so even if air pollution caps at 100
the score only reaches 40 from air alone — the thresholds were physically
unreachable from furnace burning.

New thresholds: grass degradation fires at pollutionScore > 15 (soilQuality
gate raised to 75 so it doesn't block — soil defaults to 60), smoke
particles fire at > 10. With 4 furnaces the score reaches ~15 in about
20 real simulation ticks (~2 minutes of play).

Also added acid-rain water pollution (0.1/furnace/scan) — coal burning
emits SO₂ which contributes to the weighted score alongside the existing
air and ground components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:13:57 +01:00
George dfa84a9347 Wire furnace burning to air pollution input
PollutionModule only decayed pollution; no source ever fed it. Every 100
server ticks (one simulation interval), LivingWorldMod now scans each
active region's loaded chunks for lit AbstractFurnaceBlockEntity instances
(covers standard furnace, blast furnace, smoker) using getChunkNow so
unloaded chunks are skipped cheaply.

Per lit furnace per scan: +0.5 air, +0.1 ground pollution. With 4 furnaces
this drives air pollution to an equilibrium of ~55 (decay rate 3.6%/tick),
giving a clearly visible pollution score in /lw region info.

Bootstrap gains getActiveRegions() and handleFurnaceActivity() for the
platform layer to call without needing Minecraft imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:57:11 +01:00
George 881f716115 Fix two live simulation bugs: profiler crash + regions never updating
forceUpdateRegion crashed with "no profiling cycle is active" because
runModulesForRegion calls profiler.beginModule() which requires an active
cycle. Wrapped the call in startCycle/endCycle in a try/finally.

Regions showed tick=0 and no module data because SimulationScheduler's
priority queue was never populated for normal rolling updates — only
explicit queueRegion() calls worked. Fixed by auto-enqueueing all active
regions in onMinecraftServerTick() just before each simulation cycle fires.
Auto-enqueueing is placed there (not in runSimulationCycle()) so unit tests
that drive cycles directly are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:42:11 +01:00
George 6e6de00f0d Add debug commands, force-update, and weather feedback loop
Track E — commands:
  /lw region info — now shows all 7 module data classes (air/ground/water pollution,
    soil fertility/moisture/contamination, succession stage, depletion levels, etc.)
  /lw region info <x> <z> — inspect any region by block coordinates
  /lw region force-update — runs full module pipeline on current region immediately
  /lw stats — shows last cycle duration, per-module timings, budget overrun flag

Track F — weather feedback:
  Each simulation tick, active overworld regions receive a moisture boost when it
  rains (+0.3 waterAvailability, -0.15 droughtRisk) and a drought increase when
  dry (+0.05 droughtRisk). The rain state is supplied by LivingWorldMod via a
  BooleanSupplier set on ServerStartedEvent and cleared on stop.

400 tests, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:20:48 +01:00
George 4fd9bb97aa Wire world effect executor — ecosystem simulation now mutates blocks in-game
NeoForgeWorldEffectExecutor translates WorldEffectRequests into actual Minecraft
block operations: GRASS_DEGRADES_TO_DIRT replaces grass with dirt, VEGETATION_SPREADS
spreads grass onto lit dirt, POLLUTION_VISUAL_INDICATOR spawns smoke particles.
SAPLING_GROWTH_SLOWED/BOOSTED are stubs pending mixin hooks.

Block writes are guarded by isLoaded() checks so no chunks are force-loaded.
Intensity scales the number of block candidates attempted per tick (max 8).
MinecraftServer is captured in LivingWorldMod on ServerStartedEvent and cleared
on stop; the executor receives it via Supplier to stay null-safe across restarts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:01:57 +01:00
George 6427677db5 Add Volume 2 ecosystem modules, event hooks, and module data persistence
Eight simulation modules (pollution, soil, water, vegetation, resource depletion,
recovery, ecosystem, world effects) form the full ecosystem pipeline. Each module
owns typed RegionData and writes summary metrics back to RegionMetrics.

Player block-break events are wired through BlockBreakInfo across the platform
boundary; the NeoForge adapter translates BreakEvent and routes it to the
bootstrap handler which records mining/logging/farming depletion on the
affected region.

Module state now survives server restarts: FileRegionPersistenceService accepts
per-module codecs (via PropertiesPersistenceWriter/Reader) that serialise every
RegionData instance alongside the region's core state. Bootstrap registers codecs
for all seven data-bearing modules at startup.

395 tests, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 15:53:06 +01:00
54 changed files with 6427 additions and 78 deletions
@@ -4,16 +4,55 @@ import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.entity.living.FinalizeSpawnEvent;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
import net.neoforged.neoforge.event.level.BlockEvent;
import net.neoforged.neoforge.event.server.ServerStartedEvent; import net.neoforged.neoforge.event.server.ServerStartedEvent;
import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStartingEvent;
import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.BiomeTags;
import net.minecraft.tags.BlockTags;
import net.minecraft.world.entity.animal.Animal;
import net.minecraft.world.entity.monster.Monster;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.CampfireBlockEntity;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.chunk.LevelChunk;
import net.minecraft.world.level.levelgen.Heightmap;
import net.minecraft.world.level.storage.LevelResource; import net.minecraft.world.level.storage.LevelResource;
import net.neoforged.neoforge.event.tick.ServerTickEvent;
import com.livingworld.bootstrap.LivingWorldBootstrap; import com.livingworld.bootstrap.LivingWorldBootstrap;
import com.livingworld.core.LivingWorldConstants; import com.livingworld.core.LivingWorldConstants;
import com.livingworld.debug.DiagnosticCategory; import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger; import com.livingworld.debug.LivingWorldLogger;
import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.platform.neoforge.NeoForgePlatformAdapter; import com.livingworld.platform.neoforge.NeoForgePlatformAdapter;
import com.livingworld.platform.neoforge.NeoForgeWorldEffectExecutor;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionMetrics;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
/** /**
* Mod entrypoint for Living World. * Mod entrypoint for Living World.
@@ -25,7 +64,28 @@ import com.livingworld.platform.neoforge.NeoForgePlatformAdapter;
public class LivingWorldMod { public class LivingWorldMod {
public static final String MOD_ID = LivingWorldConstants.MOD_ID; public static final String MOD_ID = LivingWorldConstants.MOD_ID;
private static final int FURNACE_SCAN_INTERVAL = 50;
private static final double AIR_POLLUTION_PER_FURNACE = 0.5;
private static final double GROUND_POLLUTION_PER_FURNACE = 0.1;
private static final double WATER_POLLUTION_PER_FURNACE = 0.1;
private static final double AIR_POLLUTION_PER_CAMPFIRE = 0.2;
private static final double GROUND_POLLUTION_PER_CAMPFIRE = 0.05;
private static final int PLAYER_CHECK_INTERVAL = 20;
/** Passive mobs suppressed in regions with ecosystem health below this. */
private static final double PASSIVE_SUPPRESS_HEALTH = 30.0;
/** Hostile mobs suppressed in regions with ecosystem health above this. */
private static final double HOSTILE_SUPPRESS_HEALTH = 60.0;
private final LivingWorldBootstrap bootstrap; private final LivingWorldBootstrap bootstrap;
private final Random random = new Random();
private MinecraftServer minecraftServer;
private int furnaceScanTick = 0;
private int playerCheckTick = 0;
private final Map<UUID, RegionCoordinate> playerRegionCache = new HashMap<>();
private final Set<RegionCoordinate> biomeInitialized = new HashSet<>();
public LivingWorldMod(IEventBus eventBus) { public LivingWorldMod(IEventBus eventBus) {
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting..."); LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World mod starting...");
@@ -34,7 +94,8 @@ public class LivingWorldMod {
NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter( NeoForgePlatformAdapter platformAdapter = new NeoForgePlatformAdapter(
bootstrap::getWorldSaveDirectory, bootstrap::getWorldSaveDirectory,
bootstrap::registerCommands, bootstrap::registerCommands,
bootstrap::onServerTick); bootstrap::onServerTick,
bootstrap::handleBlockBreak);
this.bootstrap.initialize(platformAdapter); this.bootstrap.initialize(platformAdapter);
eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup()); eventBus.addListener(FMLCommonSetupEvent.class, event -> bootstrap.onCommonSetup());
@@ -42,11 +103,197 @@ public class LivingWorldMod {
ServerStartingEvent.class, ServerStartingEvent.class,
event -> bootstrap.onServerStarting( event -> bootstrap.onServerStarting(
event.getServer().getWorldPath(LevelResource.ROOT))); event.getServer().getWorldPath(LevelResource.ROOT)));
NeoForge.EVENT_BUS.addListener( NeoForge.EVENT_BUS.addListener(ServerStartedEvent.class, event -> {
ServerStartedEvent.class, event -> bootstrap.onServerStarted()); this.minecraftServer = event.getServer();
NeoForge.EVENT_BUS.addListener( bootstrap.onServerStarted();
ServerStoppingEvent.class, event -> bootstrap.onServerStopping()); bootstrap.getWorldEffectsModule().registerConsumer(
new NeoForgeWorldEffectExecutor(() -> minecraftServer));
bootstrap.setOverworldRaining(
() -> minecraftServer != null && minecraftServer.overworld().isRaining());
});
NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> {
bootstrap.onServerStopping();
bootstrap.setOverworldRaining(null);
playerRegionCache.clear();
biomeInitialized.clear();
this.minecraftServer = null;
});
NeoForge.EVENT_BUS.addListener(ServerTickEvent.Post.class, event -> {
if (minecraftServer == null || !bootstrap.isServerReady()) return;
if (++furnaceScanTick % FURNACE_SCAN_INTERVAL == 0) {
scanAndRecordFurnaceActivity();
}
if (++playerCheckTick % PLAYER_CHECK_INTERVAL == 0) {
checkPlayerRegions();
}
});
// Step 1: Mob spawn feedback — passive mobs suppressed in degraded regions,
// hostile mobs suppressed in healthy ones.
NeoForge.EVENT_BUS.addListener(FinalizeSpawnEvent.class, event -> {
if (!bootstrap.isServerReady()) return;
if (!(event.getLevel() instanceof ServerLevel level)) return;
String dimId = level.dimension().location().toString();
Optional<RegionMetrics> metricsOpt =
bootstrap.getMetricsAt(dimId, event.getX(), event.getZ());
if (metricsOpt.isEmpty()) return;
double health = metricsOpt.get().getEcosystemHealth();
if (event.getEntity() instanceof Animal) {
if (health < PASSIVE_SUPPRESS_HEALTH) {
double chance = (PASSIVE_SUPPRESS_HEALTH - health) / PASSIVE_SUPPRESS_HEALTH * 0.7;
if (random.nextDouble() < chance) event.setSpawnCancelled(true);
}
} else if (event.getEntity() instanceof Monster) {
if (health > HOSTILE_SUPPRESS_HEALTH) {
double chance = (health - HOSTILE_SUPPRESS_HEALTH) / (100.0 - HOSTILE_SUPPRESS_HEALTH) * 0.5;
if (random.nextDouble() < chance) event.setSpawnCancelled(true);
}
}
});
// Step 4: Agriculture — bone meal boosts soil fertility.
NeoForge.EVENT_BUS.addListener(PlayerInteractEvent.RightClickBlock.class, event -> {
if (!bootstrap.isServerReady()) return;
if (!event.getItemStack().is(Items.BONE_MEAL)) return;
if (!(event.getLevel() instanceof ServerLevel level)) return;
BlockPos pos = event.getPos();
bootstrap.handleBoneMeal(level.dimension().location().toString(), pos.getX(), pos.getZ());
});
// Step 4: Agriculture — harvesting fully-grown crops drains soil fertility.
NeoForge.EVENT_BUS.addListener(BlockEvent.BreakEvent.class, event -> {
if (!bootstrap.isServerReady()) return;
if (!(event.getLevel() instanceof ServerLevel level)) return;
var state = event.getState();
if (!state.is(BlockTags.CROPS)) return;
if (!state.hasProperty(BlockStateProperties.AGE_7)) return;
if (state.getValue(BlockStateProperties.AGE_7) < 7) return;
BlockPos pos = event.getPos();
bootstrap.handleCropHarvest(level.dimension().location().toString(), pos.getX(), pos.getZ());
});
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully."); LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully.");
} }
private void scanAndRecordFurnaceActivity() {
for (Region region : bootstrap.getActiveRegions()) {
String dimensionId = region.getCoordinate().dimensionId();
ServerLevel level = minecraftServer.getLevel(
ResourceKey.create(Registries.DIMENSION, ResourceLocation.parse(dimensionId)));
if (level == null) continue;
int baseChunkX = region.getCoordinate().x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS;
int baseChunkZ = region.getCoordinate().z() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS;
int furnaces = 0;
int campfires = 0;
for (int cx = baseChunkX; cx < baseChunkX + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; cx++) {
for (int cz = baseChunkZ; cz < baseChunkZ + LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS; cz++) {
LevelChunk chunk = level.getChunkSource().getChunkNow(cx, cz);
if (chunk == null) continue;
for (BlockEntity be : chunk.getBlockEntities().values()) {
if (be instanceof AbstractFurnaceBlockEntity
&& be.getBlockState().hasProperty(BlockStateProperties.LIT)
&& Boolean.TRUE.equals(be.getBlockState().getValue(BlockStateProperties.LIT))) {
furnaces++;
} else if (be instanceof CampfireBlockEntity
&& be.getBlockState().hasProperty(BlockStateProperties.LIT)
&& Boolean.TRUE.equals(be.getBlockState().getValue(BlockStateProperties.LIT))) {
campfires++;
}
}
}
}
double air = furnaces * AIR_POLLUTION_PER_FURNACE + campfires * AIR_POLLUTION_PER_CAMPFIRE;
double ground = furnaces * GROUND_POLLUTION_PER_FURNACE + campfires * GROUND_POLLUTION_PER_CAMPFIRE;
double water = furnaces * WATER_POLLUTION_PER_FURNACE;
if (air > 0 || ground > 0) {
bootstrap.handleFurnaceActivity(region, air, ground, water);
}
}
}
private void checkPlayerRegions() {
for (ServerPlayer player : minecraftServer.getPlayerList().getPlayers()) {
String dimId = player.level().dimension().location().toString();
int regionX = (int) Math.floor(player.getX() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
int regionZ = (int) Math.floor(player.getZ() / (LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16.0));
RegionCoordinate coord = new RegionCoordinate(dimId, regionX, regionZ);
RegionCoordinate previous = playerRegionCache.put(player.getUUID(), coord);
if (!coord.equals(previous)) {
bootstrap.notifyPlayerInRegion(coord);
// Step 6: Biome-aware succession — derive and apply cap once per region.
if (!biomeInitialized.contains(coord)
&& player.level() instanceof ServerLevel serverLevel) {
SuccessionStage cap = deriveBiomeCap(serverLevel, coord);
bootstrap.setRegionBiomeCap(coord, cap);
biomeInitialized.add(coord);
}
}
// Step 2: Compass HUD — display region health while holding a compass, or debug HUD is on.
boolean showHud = player.getMainHandItem().is(Items.COMPASS)
|| player.getOffhandItem().is(Items.COMPASS)
|| bootstrap.isHudEnabled(player.getUUID());
if (showHud) {
Optional<RegionMetrics> metricsOpt = bootstrap.getMetricsAt(dimId, player.getX(), player.getZ());
metricsOpt.ifPresent(m -> player.displayClientMessage(buildHud(coord, m), true));
}
}
}
/** Step 6: Samples the dominant biome at the region centre and returns a succession ceiling. */
private SuccessionStage deriveBiomeCap(ServerLevel level, RegionCoordinate coord) {
int cx = coord.x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 + 64;
int cz = coord.z() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 + 64;
int cy = level.getHeight(Heightmap.Types.WORLD_SURFACE, cx, cz);
Holder<Biome> biome = level.getBiome(new BlockPos(cx, cy, cz));
// Temperature ≥ 2.0 = desert or badlands (no IS_DESERT tag in MC 1.21).
float temp = biome.value().getBaseTemperature();
if (biome.is(BiomeTags.IS_BADLANDS) || temp >= 2.0f) {
return SuccessionStage.SPARSE_GRASS;
} else if (biome.is(BiomeTags.IS_SAVANNA)) {
return SuccessionStage.SCRUBLAND;
} else if (biome.is(BiomeTags.IS_MOUNTAIN) || temp < 0.15f) {
return SuccessionStage.SCRUBLAND;
} else if (biome.is(BiomeTags.IS_BEACH) || biome.is(BiomeTags.IS_OCEAN)
|| biome.is(BiomeTags.IS_DEEP_OCEAN)) {
return SuccessionStage.GRASSLAND;
} else if (biome.is(BiomeTags.IS_FOREST) || biome.is(BiomeTags.IS_TAIGA)
|| biome.is(BiomeTags.IS_JUNGLE)) {
return SuccessionStage.MATURE_FOREST;
} else {
return SuccessionStage.YOUNG_WOODLAND;
}
}
/** Step 2: Builds the action-bar HUD component for a player holding a compass. */
private Component buildHud(RegionCoordinate coord, RegionMetrics m) {
double eco = m.getEcosystemHealth();
double poll = m.getPollutionScore();
double soil = m.getSoilQuality();
double wat = m.getWaterQuality();
ChatFormatting ecoCol = eco > 60 ? ChatFormatting.GREEN : eco > 30 ? ChatFormatting.YELLOW : ChatFormatting.RED;
ChatFormatting pollCol = poll < 15 ? ChatFormatting.GREEN : poll < 40 ? ChatFormatting.YELLOW : ChatFormatting.RED;
ChatFormatting soilCol = soil > 50 ? ChatFormatting.GREEN : soil > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED;
ChatFormatting watCol = wat > 50 ? ChatFormatting.GREEN : wat > 25 ? ChatFormatting.YELLOW : ChatFormatting.RED;
return Component.empty()
.append(Component.literal("[LW] ").withStyle(ChatFormatting.GOLD))
.append(Component.literal(String.format("(%d,%d) ", coord.x(), coord.z())).withStyle(ChatFormatting.GRAY))
.append(Component.literal("Eco:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.0f ", eco)).withStyle(ecoCol))
.append(Component.literal("Poll:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.1f ", poll)).withStyle(pollCol))
.append(Component.literal("Soil:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.0f ", soil)).withStyle(soilCol))
.append(Component.literal("Wat:").withStyle(ChatFormatting.WHITE))
.append(Component.literal(String.format("%.0f", wat)).withStyle(watCol));
}
} }
@@ -17,7 +17,27 @@ import com.livingworld.events.LivingWorldEventBus;
import com.livingworld.modules.ModuleContext; import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleRegistry; import com.livingworld.modules.ModuleRegistry;
import com.livingworld.modules.ServerContext; import com.livingworld.modules.ServerContext;
import com.livingworld.modules.ecosystem.EcosystemModule;
import com.livingworld.modules.pollution.PollutionModule;
import com.livingworld.modules.ecosystem.EcosystemRegionData;
import java.util.function.BooleanSupplier;
import com.livingworld.modules.pollution.PollutionRegionData;
import com.livingworld.modules.recovery.RecoveryModule;
import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.modules.resources.ResourceDepletionModule;
import com.livingworld.modules.resources.ResourceRegionData;
import com.livingworld.modules.soil.SoilModule;
import com.livingworld.modules.soil.SoilRegionData;
import com.livingworld.modules.vegetation.VegetationModule;
import com.livingworld.modules.vegetation.VegetationRegionData;
import com.livingworld.modules.water.WaterModule;
import com.livingworld.modules.water.WaterRegionData;
import com.livingworld.modules.worldeffects.WorldEffectsModule;
import com.livingworld.platform.BlockBreakInfo;
import com.livingworld.platform.PlatformAdapter; import com.livingworld.platform.PlatformAdapter;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory; import com.livingworld.regions.RegionFactory;
import com.livingworld.regions.RegionLifecycleController; import com.livingworld.regions.RegionLifecycleController;
import com.livingworld.regions.RegionManager; import com.livingworld.regions.RegionManager;
@@ -26,6 +46,16 @@ import com.livingworld.regions.cache.RegionCache;
import com.livingworld.regions.query.RegionQueryEngine; import com.livingworld.regions.query.RegionQueryEngine;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.UUID;
import com.livingworld.regions.RegionMetrics;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
/** /**
@@ -33,12 +63,26 @@ import net.minecraft.commands.CommandSourceStack;
*/ */
public final class LivingWorldBootstrap { public final class LivingWorldBootstrap {
private static final double RAIN_MOISTURE_GAIN = 0.3;
private static final double RAIN_DROUGHT_RELIEF = 0.15;
private static final double DRY_DROUGHT_INCREASE = 0.05;
private static final double ACID_RAIN_THRESHOLD = 20.0;
private static final double ACID_FERTILITY_DRAIN = 0.0005;
private static final double WIND_BOOST = 0.5;
private static final double WIND_DRIFT_MAX = 0.05; // radians per sim cycle
private double windAngle = 0.0;
private final Random windRandom = new Random();
private final Set<UUID> hudEnabledPlayers = new HashSet<>();
private PlatformAdapter platformAdapter; private PlatformAdapter platformAdapter;
private Path worldSaveDirectory; private Path worldSaveDirectory;
private ServiceRegistry services; private ServiceRegistry services;
private RegionManager regionManager; private RegionManager regionManager;
private ModuleRegistry moduleRegistry; private ModuleRegistry moduleRegistry;
private SimulationManager simulationManager; private SimulationManager simulationManager;
private WorldEffectsModule worldEffectsModule;
private BooleanSupplier overworldRaining = () -> false;
private boolean initialized; private boolean initialized;
private boolean serverReady; private boolean serverReady;
@@ -110,6 +154,46 @@ public final class LivingWorldBootstrap {
"onServerStarted - server started."); "onServerStarted - server started.");
} }
/**
* Handles a block-break event forwarded from the platform adapter.
*
* <p>Determines whether the block represents mining, logging, or farming activity
* and records the corresponding depletion on the region's
* {@link ResourceRegionData}. Safe to call before the server is ready (returns
* immediately in that case).</p>
*
* @param info platform-neutral description of the broken block
*/
public void handleBlockBreak(BlockBreakInfo info) {
if (!serverReady) {
return;
}
RegionCoordinate coord = RegionCoordinate.fromBlock(
info.dimensionId(),
info.blockX(),
info.blockZ(),
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
Region region = regionManager.getOrCreateRegion(coord);
ResourceRegionData resources = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
.orElseGet(ResourceRegionData::defaults);
String block = info.blockRegistryName();
if (isLog(block) || isLeaves(block) || isWood(block)) {
resources.recordLogging(1.0);
} else if (isOre(block) || isStone(block)) {
resources.recordMining(1.0);
} else if (isCrop(block) || isFarmland(block)) {
resources.recordFarming(1.0);
} else {
return; // not a tracked resource — don't dirty the region
}
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
regionManager.markDirty(region);
}
/** /**
* Called when the server is stopping. * Called when the server is stopping.
*/ */
@@ -119,12 +203,123 @@ public final class LivingWorldBootstrap {
} }
regionManager.flushAll(); regionManager.flushAll();
moduleRegistry.shutdownAll(); moduleRegistry.shutdownAll();
hudEnabledPlayers.clear();
serverReady = false; serverReady = false;
LivingWorldLogger.info( LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP, DiagnosticCategory.BOOTSTRAP,
"onServerStopping - persistence flushed and modules stopped."); "onServerStopping - persistence flushed and modules stopped.");
} }
/**
* Sets the supplier that reports whether it is currently raining in the overworld.
* Called from the platform layer after the server starts and cleared on stop.
*/
/** Returns all currently cached (active) regions. Safe to call from the platform layer. */
public Collection<Region> getActiveRegions() {
if (!serverReady) return List.of();
return regionManager.getActiveRegions();
}
/**
* Records pollution produced by burning activity (e.g. furnaces) in a region.
* Called from the platform layer once per simulation interval.
*
* @param waterAmount acid-rain component — SO₂ from coal dissolves in atmospheric moisture
*/
public void handleFurnaceActivity(Region region, double airAmount, double groundAmount, double waterAmount) {
if (!serverReady || region == null) return;
PollutionRegionData data = region.getModuleData()
.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
.orElseGet(PollutionRegionData::defaults);
data.addPollution(airAmount, groundAmount, waterAmount);
region.getModuleData().put(PollutionModule.MODULE_ID, data);
regionManager.markDirty(region);
}
/** Returns the live metrics for the region containing the given world position. */
public Optional<RegionMetrics> getMetricsAt(String dimensionId, double x, double z) {
if (!serverReady) return Optional.empty();
RegionCoordinate coord = RegionCoordinate.fromBlock(
dimensionId, (int) x, (int) z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
return regionManager.resolve(coord).map(Region::getMetrics);
}
/** Called when a sapling grows into a tree — reduces logging depletion in that region. */
public void handleTreeGrowth(RegionCoordinate coord) {
if (!serverReady || coord == null) return;
regionManager.resolve(coord).ifPresent(region -> {
ResourceRegionData res = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
.orElse(null);
if (res == null) return;
res.setLoggingDepletion(Math.max(0, res.getLoggingDepletion() - 2.0));
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, res);
regionManager.markDirty(region);
});
}
/** Called when a player applies bone meal — boosts soil fertility. */
public void handleBoneMeal(String dimensionId, int x, int z) {
if (!serverReady) return;
RegionCoordinate coord = RegionCoordinate.fromBlock(
dimensionId, x, z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
regionManager.resolve(coord).ifPresent(region -> {
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (soil == null) return;
soil.setFertility(Math.min(100, soil.getFertility() + 2.0));
region.getModuleData().put(SoilModule.MODULE_ID, soil);
regionManager.markDirty(region);
});
}
/** Called when a player harvests a fully-grown crop — drains soil fertility. */
public void handleCropHarvest(String dimensionId, int x, int z) {
if (!serverReady) return;
RegionCoordinate coord = RegionCoordinate.fromBlock(
dimensionId, x, z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
regionManager.resolve(coord).ifPresent(region -> {
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (soil == null) return;
soil.setFertility(Math.max(0, soil.getFertility() - 0.5));
ResourceRegionData res = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
.orElse(null);
if (res != null) {
res.recordFarming(0.3);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, res);
}
region.getModuleData().put(SoilModule.MODULE_ID, soil);
regionManager.markDirty(region);
});
}
/** Sets the biome-derived succession ceiling for a region. */
public void setRegionBiomeCap(RegionCoordinate coord, SuccessionStage maxStage) {
if (!serverReady || coord == null || maxStage == null) return;
regionManager.resolve(coord).ifPresent(region -> {
RecoveryRegionData recovery = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
.orElseGet(RecoveryRegionData::defaults);
recovery.setMaxSuccessionStage(maxStage);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
regionManager.markDirty(region);
});
}
public void notifyPlayerInRegion(RegionCoordinate coordinate) {
if (!serverReady || coordinate == null) return;
regionManager.getOrCreateRegion(coordinate);
simulationManager.queueRegionForUpdate(coordinate, 10, com.livingworld.core.simulation.UpdateReason.PLAYER_NEARBY);
}
public void setOverworldRaining(BooleanSupplier supplier) {
this.overworldRaining = supplier != null ? supplier : () -> false;
}
public void onServerTick() { public void onServerTick() {
if (!serverReady) { if (!serverReady) {
return; return;
@@ -132,17 +327,116 @@ public final class LivingWorldBootstrap {
long previousSimulationTick = simulationManager.getSimulationTickCounter(); long previousSimulationTick = simulationManager.getSimulationTickCounter();
simulationManager.onMinecraftServerTick(); simulationManager.onMinecraftServerTick();
if (simulationManager.getSimulationTickCounter() != previousSimulationTick) { if (simulationManager.getSimulationTickCounter() != previousSimulationTick) {
applyWeatherFeedback();
spreadPollutionAcrossRegions();
regionManager.saveDirtyRegions(); regionManager.saveDirtyRegions();
} }
} }
private static final double POLLUTION_SPREAD_RATE = 0.02;
private void spreadPollutionAcrossRegions() {
Collection<Region> active = regionManager.getActiveRegions();
if (active.size() < 2) return;
// Wind drifts slowly each sim cycle.
windAngle += (windRandom.nextDouble() * 2.0 - 1.0) * WIND_DRIFT_MAX;
Map<RegionCoordinate, Region> byCoord = new HashMap<>();
for (Region r : active) byCoord.put(r.getCoordinate(), r);
// dx, dz, and the angle that offset points in (atan2(dz, dx))
int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (Region region : active) {
RegionCoordinate coord = region.getCoordinate();
PollutionRegionData data = region.getModuleData()
.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
.orElse(null);
if (data == null) continue;
for (int[] off : offsets) {
RegionCoordinate neighbourCoord = new RegionCoordinate(
coord.dimensionId(), coord.x() + off[0], coord.z() + off[1]);
Region neighbour = byCoord.get(neighbourCoord);
if (neighbour == null) continue;
PollutionRegionData neighbourData = neighbour.getModuleData()
.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
.orElse(null);
if (neighbourData == null) continue;
double diff = data.getAirPollution() - neighbourData.getAirPollution();
if (diff <= 0) continue;
// Wind alignment: positive = downwind (faster spread), negative = upwind (slower).
double offsetAngle = Math.atan2(off[1], off[0]);
double alignment = Math.cos(windAngle - offsetAngle);
double rate = POLLUTION_SPREAD_RATE * (1.0 + alignment * WIND_BOOST);
double transfer = diff * rate;
data.addPollution(-transfer, 0, 0);
neighbourData.addPollution(transfer, 0, 0);
neighbour.getModuleData().put(PollutionModule.MODULE_ID, neighbourData);
regionManager.markDirty(neighbour);
}
region.getModuleData().put(PollutionModule.MODULE_ID, data);
regionManager.markDirty(region);
}
}
private void applyWeatherFeedback() {
boolean raining = overworldRaining.getAsBoolean();
for (Region region : regionManager.getActiveRegions()) {
if (!"minecraft:overworld".equals(region.getCoordinate().dimensionId())) {
continue;
}
WaterRegionData water = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElse(null);
if (water == null) {
continue;
}
if (raining) {
water.setWaterAvailability(water.getWaterAvailability() + RAIN_MOISTURE_GAIN);
water.setDroughtRisk(water.getDroughtRisk() - RAIN_DROUGHT_RELIEF);
// Acid rain: polluted rainfall degrades soil and water quality.
double pollutionScore = region.getMetrics().getPollutionScore();
if (pollutionScore > ACID_RAIN_THRESHOLD) {
double acidStrength = (pollutionScore - ACID_RAIN_THRESHOLD) * ACID_FERTILITY_DRAIN;
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElse(null);
if (soil != null) {
soil.setFertility(Math.max(0, soil.getFertility() - acidStrength));
soil.setContamination(Math.min(100, soil.getContamination() + acidStrength * 0.5));
region.getModuleData().put(SoilModule.MODULE_ID, soil);
}
water.setWaterAvailability(
Math.max(0, water.getWaterAvailability() - acidStrength * 0.3));
}
} else {
water.setDroughtRisk(water.getDroughtRisk() + DRY_DROUGHT_INCREASE);
}
region.getModuleData().put(WaterModule.MODULE_ID, water);
regionManager.markDirty(region);
}
}
/** Toggles the debug HUD for the given player. Returns true if HUD is now enabled. */
public boolean toggleHud(UUID playerId) {
if (hudEnabledPlayers.remove(playerId)) return false;
hudEnabledPlayers.add(playerId);
return true;
}
public boolean isHudEnabled(UUID playerId) {
return hudEnabledPlayers.contains(playerId);
}
public void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher) { public void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher) {
requireInitialized(); requireInitialized();
LivingWorldCommandRoot.registerDeferred( LivingWorldCommandRoot.registerDeferred(
dispatcher, dispatcher,
() -> requireService(regionManager, "regionManager"), () -> requireService(regionManager, "regionManager"),
() -> requireService(moduleRegistry, "moduleRegistry"), () -> requireService(moduleRegistry, "moduleRegistry"),
() -> requireService(simulationManager, "simulationManager")); () -> requireService(simulationManager, "simulationManager"),
this::toggleHud);
} }
public Path getWorldSaveDirectory() { public Path getWorldSaveDirectory() {
@@ -165,6 +459,7 @@ public final class LivingWorldBootstrap {
new FileRegionPersistenceService( new FileRegionPersistenceService(
saveDirectory.resolve("living_world"), saveDirectory.resolve("living_world"),
LivingWorldConstants.MOD_VERSION); LivingWorldConstants.MOD_VERSION);
registerModuleCodecs(persistenceService);
RegionCache regionCache = new RegionCache(); RegionCache regionCache = new RegionCache();
RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache); RegionQueryEngine queryEngine = new RegionQueryEngine(regionCache);
regionManager = new RegionManager( regionManager = new RegionManager(
@@ -175,6 +470,7 @@ public final class LivingWorldBootstrap {
new RegionLifecycleController(), new RegionLifecycleController(),
config); config);
moduleRegistry = new ModuleRegistry(); moduleRegistry = new ModuleRegistry();
registerEcosystemModules(moduleRegistry);
LivingWorldEventBus eventBus = new LivingWorldEventBus(); LivingWorldEventBus eventBus = new LivingWorldEventBus();
DefaultTimeService timeService = new DefaultTimeService(); DefaultTimeService timeService = new DefaultTimeService();
SimulationScheduler scheduler = new SimulationScheduler(config); SimulationScheduler scheduler = new SimulationScheduler(config);
@@ -202,6 +498,198 @@ public final class LivingWorldBootstrap {
moduleRegistry.initializeAll(new ModuleContext(services)); moduleRegistry.initializeAll(new ModuleContext(services));
} }
// ------------------------------------------------------------------
// Block categorisation helpers (registry-name based, no Minecraft imports)
// ------------------------------------------------------------------
private static boolean isLog(String block) {
return block.contains("_log") || block.contains("_stem");
}
private static boolean isWood(String block) {
return block.contains("_wood") || block.contains("_hyphae");
}
private static boolean isLeaves(String block) {
return block.contains("_leaves");
}
private static boolean isOre(String block) {
return block.contains("_ore");
}
private static boolean isStone(String block) {
return block.equals("minecraft:stone")
|| block.equals("minecraft:deepslate")
|| block.equals("minecraft:cobblestone")
|| block.equals("minecraft:gravel")
|| block.equals("minecraft:sand");
}
private static boolean isCrop(String block) {
return block.contains("wheat") || block.contains("carrot")
|| block.contains("potato") || block.contains("beetroot")
|| block.contains("sugar_cane") || block.contains("pumpkin")
|| block.contains("melon");
}
private static boolean isFarmland(String block) {
return block.contains("farmland");
}
/**
* Returns the {@link WorldEffectsModule} so the platform adapter can register a
* block-change consumer after bootstrap.
*
* <p>Call only after {@link #onServerStarting(Path)} has returned.</p>
*/
public WorldEffectsModule getWorldEffectsModule() {
requireServerReady();
return worldEffectsModule;
}
/**
* Registers per-region data codecs for all 7 data-bearing ecosystem modules.
*
* <p>WorldEffects has no persistent per-region data and is intentionally omitted.</p>
*/
private static void registerModuleCodecs(FileRegionPersistenceService service) {
service.registerModuleCodec(
PollutionModule.MODULE_ID,
(data, w) -> {
PollutionRegionData d = data.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
.orElseGet(PollutionRegionData::defaults);
w.writeDouble("airPollution", d.getAirPollution());
w.writeDouble("groundPollution", d.getGroundPollution());
w.writeDouble("waterPollution", d.getWaterPollution());
w.writeDouble("decayResistance", d.getDecayResistance());
},
(r, data) -> data.put(PollutionModule.MODULE_ID, new PollutionRegionData(
r.readDouble("airPollution", 0.0),
r.readDouble("groundPollution", 0.0),
r.readDouble("waterPollution", 0.0),
r.readDouble("decayResistance", 20.0))));
service.registerModuleCodec(
SoilModule.MODULE_ID,
(data, w) -> {
SoilRegionData d = data.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElseGet(SoilRegionData::defaults);
w.writeDouble("fertility", d.getFertility());
w.writeDouble("moisture", d.getMoisture());
w.writeDouble("contamination", d.getContamination());
w.writeDouble("compaction", d.getCompaction());
w.writeDouble("erosion", d.getErosion());
},
(r, data) -> data.put(SoilModule.MODULE_ID, new SoilRegionData(
r.readDouble("fertility", 60.0),
r.readDouble("moisture", 50.0),
r.readDouble("contamination", 0.0),
r.readDouble("compaction", 10.0),
r.readDouble("erosion", 0.0))));
service.registerModuleCodec(
WaterModule.MODULE_ID,
(data, w) -> {
WaterRegionData d = data.get(WaterModule.MODULE_ID, WaterRegionData.class)
.orElseGet(WaterRegionData::defaults);
w.writeDouble("waterAvailability", d.getWaterAvailability());
w.writeDouble("purificationCapacity", d.getPurificationCapacity());
w.writeDouble("droughtRisk", d.getDroughtRisk());
w.writeDouble("floodRisk", d.getFloodRisk());
},
(r, data) -> data.put(WaterModule.MODULE_ID, new WaterRegionData(
r.readDouble("waterAvailability", 60.0),
r.readDouble("purificationCapacity", 50.0),
r.readDouble("droughtRisk", 10.0),
r.readDouble("floodRisk", 10.0))));
service.registerModuleCodec(
VegetationModule.MODULE_ID,
(data, w) -> {
VegetationRegionData d = data.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
.orElseGet(VegetationRegionData::defaults);
w.writeDouble("grassPressure", d.getGrassPressure());
w.writeDouble("flowerPressure", d.getFlowerPressure());
w.writeDouble("shrubPressure", d.getShrubPressure());
w.writeDouble("treePressure", d.getTreePressure());
w.writeDouble("deadVegetation", d.getDeadVegetation());
},
(r, data) -> data.put(VegetationModule.MODULE_ID, new VegetationRegionData(
r.readDouble("grassPressure", 50.0),
r.readDouble("flowerPressure", 30.0),
r.readDouble("shrubPressure", 30.0),
r.readDouble("treePressure", 40.0),
r.readDouble("deadVegetation", 5.0))));
service.registerModuleCodec(
ResourceDepletionModule.MODULE_ID,
(data, w) -> {
ResourceRegionData d = data.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
.orElseGet(ResourceRegionData::defaults);
w.writeDouble("miningDepletion", d.getMiningDepletion());
w.writeDouble("loggingDepletion", d.getLoggingDepletion());
w.writeDouble("farmingDepletion", d.getFarmingDepletion());
},
(r, data) -> data.put(ResourceDepletionModule.MODULE_ID, new ResourceRegionData(
r.readDouble("miningDepletion", 0.0),
r.readDouble("loggingDepletion", 0.0),
r.readDouble("farmingDepletion", 0.0))));
service.registerModuleCodec(
RecoveryModule.MODULE_ID,
(data, w) -> {
RecoveryRegionData d = data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
.orElseGet(RecoveryRegionData::defaults);
w.writeString("successionStage", d.getSuccessionStage().name());
w.writeDouble("recoveryProgress", d.getRecoveryProgress());
w.writeDouble("damageAccumulation", d.getDamageAccumulation());
},
(r, data) -> data.put(RecoveryModule.MODULE_ID, new RecoveryRegionData(
SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
r.readDouble("recoveryProgress", 0.0),
r.readDouble("damageAccumulation", 0.0))));
service.registerModuleCodec(
EcosystemModule.MODULE_ID,
(data, w) -> {
EcosystemRegionData d = data.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class)
.orElseGet(EcosystemRegionData::defaults);
w.writeDouble("ecosystemHealth", d.getEcosystemHealth());
w.writeDouble("stress", d.getStress());
w.writeDouble("resilience", d.getResilience());
w.writeDouble("recoveryRate", d.getRecoveryRate());
},
(r, data) -> data.put(EcosystemModule.MODULE_ID, new EcosystemRegionData(
r.readDouble("ecosystemHealth", 60.0),
r.readDouble("stress", 20.0),
r.readDouble("resilience", 50.0),
r.readDouble("recoveryRate", 5.0))));
}
/**
* Registers all ecosystem simulation modules with the registry in pipeline order.
*
* <p>Order matters: each module may read metrics written by earlier modules in the
* same tick. The declared order here is the execution order inside
* {@link SimulationManager}.</p>
*/
private void registerEcosystemModules(ModuleRegistry registry) {
registry.register(new PollutionModule());
registry.register(new SoilModule());
registry.register(new WaterModule());
registry.register(new VegetationModule());
registry.register(new ResourceDepletionModule());
registry.register(new RecoveryModule());
registry.register(new EcosystemModule());
worldEffectsModule = new WorldEffectsModule();
registry.register(worldEffectsModule);
LivingWorldLogger.info(
DiagnosticCategory.BOOTSTRAP,
"Registered 8 ecosystem modules (pollution → soil → water → vegetation"
+ " → resources → recovery → ecosystem → worldeffects).");
}
private void requireInitialized() { private void requireInitialized() {
if (!initialized) { if (!initialized) {
throw new IllegalStateException("bootstrap has not been initialized"); throw new IllegalStateException("bootstrap has not been initialized");
@@ -0,0 +1,39 @@
package com.livingworld.commands;
import com.livingworld.core.simulation.SimulationManager;
import com.livingworld.regions.RegionManager;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.network.chat.Component;
import net.minecraft.world.phys.Vec3;
/**
* Forces the full module pipeline to run on a single region immediately,
* bypassing the scheduler. Useful for observing simulation effects without
* waiting for the next scheduled tick.
*/
public final class ForceUpdateCommand {
private ForceUpdateCommand() {
}
public static int executeAtSelf(
CommandSourceStack source,
RegionManager regionManager,
SimulationManager simulationManager) {
if (source == null) throw new IllegalArgumentException("source must not be null");
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
if (simulationManager == null) throw new IllegalArgumentException("simulationManager must not be null");
Vec3 pos = source.getPosition();
String dimensionId = source.getLevel().dimension().location().toString();
// Ensure the region is loaded/created before asking the simulation manager to update it.
var region = regionManager.getOrCreateRegionAtBlock(
dimensionId, (int) Math.floor(pos.x), (int) Math.floor(pos.z));
simulationManager.forceUpdateRegion(region.getCoordinate());
String msg = "Forced update on region " + region.getCoordinate().stableId()
+ " — run '/lw region info' to see the result.";
source.sendSuccess(() -> Component.literal(msg), true);
return 1;
}
}
@@ -1,14 +1,18 @@
package com.livingworld.commands; package com.livingworld.commands;
import java.util.stream.Collectors; import java.util.UUID;
import java.util.function.Function;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import com.livingworld.core.simulation.SimulationManager; import com.livingworld.core.simulation.SimulationManager;
import com.livingworld.modules.ModuleRegistry; import com.livingworld.modules.ModuleRegistry;
@@ -33,14 +37,16 @@ public final class LivingWorldCommandRoot {
dispatcher, dispatcher,
() -> regionManager, () -> regionManager,
() -> moduleRegistry, () -> moduleRegistry,
() -> simulationManager); () -> simulationManager,
uuid -> false);
} }
public static void registerDeferred( public static void registerDeferred(
CommandDispatcher<CommandSourceStack> dispatcher, CommandDispatcher<CommandSourceStack> dispatcher,
Supplier<RegionManager> regionManager, Supplier<RegionManager> regionManager,
Supplier<ModuleRegistry> moduleRegistry, Supplier<ModuleRegistry> moduleRegistry,
Supplier<SimulationManager> simulationManager) { Supplier<SimulationManager> simulationManager,
Function<UUID, Boolean> hudToggle) {
if (dispatcher == null) { if (dispatcher == null) {
throw new IllegalArgumentException("dispatcher must not be null"); throw new IllegalArgumentException("dispatcher must not be null");
} }
@@ -64,14 +70,30 @@ public final class LivingWorldCommandRoot {
requireService(simulationManager, "simulationManager")))) requireService(simulationManager, "simulationManager"))))
.then(Commands.literal("region") .then(Commands.literal("region")
.then(Commands.literal("info") .then(Commands.literal("info")
.executes(context -> RegionInfoCommand.execute( .executes(context -> RegionInfoCommand.executeAtSelf(
context.getSource(), context.getSource(),
requireService(regionManager, "regionManager"))))) requireService(regionManager, "regionManager")))
.then(Commands.argument("x", IntegerArgumentType.integer())
.then(Commands.argument("z", IntegerArgumentType.integer())
.executes(context -> RegionInfoCommand.executeAt(
context.getSource(),
requireService(regionManager, "regionManager"),
IntegerArgumentType.getInteger(context, "x"),
IntegerArgumentType.getInteger(context, "z"))))))
.then(Commands.literal("force-update")
.executes(context -> ForceUpdateCommand.executeAtSelf(
context.getSource(),
requireService(regionManager, "regionManager"),
requireService(simulationManager, "simulationManager")))))
.then(Commands.literal("modules") .then(Commands.literal("modules")
.then(Commands.literal("list") .then(Commands.literal("list")
.executes(context -> listModules( .executes(context -> listModules(
context.getSource(), context.getSource(),
requireService(moduleRegistry, "moduleRegistry"))))) requireService(moduleRegistry, "moduleRegistry")))))
.then(Commands.literal("stats")
.executes(context -> StatsCommand.execute(
context.getSource(),
requireService(simulationManager, "simulationManager"))))
.then(Commands.literal("simulate") .then(Commands.literal("simulate")
.then(Commands.argument( .then(Commands.argument(
"ticks", "ticks",
@@ -80,7 +102,24 @@ public final class LivingWorldCommandRoot {
.executes(context -> SimulateCommand.execute( .executes(context -> SimulateCommand.execute(
context.getSource(), context.getSource(),
requireService(simulationManager, "simulationManager"), requireService(simulationManager, "simulationManager"),
IntegerArgumentType.getInteger(context, "ticks")))))); IntegerArgumentType.getInteger(context, "ticks")))))
.then(Commands.literal("hud")
.executes(context -> toggleHud(context.getSource(), hudToggle))));
}
private static int toggleHud(CommandSourceStack source, Function<UUID, Boolean> hudToggle) {
ServerPlayer player;
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/lw hud can only be used by a player"));
return 0;
}
boolean nowEnabled = hudToggle.apply(player.getUUID());
source.sendSuccess(
() -> Component.literal("Region HUD " + (nowEnabled ? "enabled" : "disabled")),
false);
return 1;
} }
private static <T> T requireService(Supplier<T> supplier, String name) { private static <T> T requireService(Supplier<T> supplier, String name) {
@@ -1,44 +1,55 @@
package com.livingworld.commands; package com.livingworld.commands;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionManager;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.world.phys.Vec3; import net.minecraft.world.phys.Vec3;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionManager;
/** /**
* Prints the Living World region state at the command source position. * Prints the Living World region state — either at the caller's position or
* at explicit block coordinates.
*/ */
public final class RegionInfoCommand { public final class RegionInfoCommand {
private RegionInfoCommand() { private RegionInfoCommand() {
} }
public static int execute( /** Uses the command-source position (player standing location). */
public static int executeAtSelf(
CommandSourceStack source, CommandSourceStack source,
RegionManager regionManager) { RegionManager regionManager) {
if (source == null) { if (source == null) throw new IllegalArgumentException("source must not be null");
throw new IllegalArgumentException("source must not be null"); if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
}
if (regionManager == null) {
throw new IllegalArgumentException("regionManager must not be null");
}
Vec3 position = source.getPosition(); Vec3 position = source.getPosition();
String dimensionId = source.getLevel().dimension().location().toString(); String dimensionId = source.getLevel().dimension().location().toString();
Region region = regionManager.getOrCreateRegionAtBlock( Region region = regionManager.getOrCreateRegionAtBlock(
dimensionId, dimensionId,
floorToBlock(position.x), (int) Math.floor(position.x),
floorToBlock(position.z)); (int) Math.floor(position.z));
return sendLines(source, region);
}
/** Uses explicit block X/Z coordinates in the caller's current dimension. */
public static int executeAt(
CommandSourceStack source,
RegionManager regionManager,
int blockX,
int blockZ) {
if (source == null) throw new IllegalArgumentException("source must not be null");
if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null");
String dimensionId = source.getLevel().dimension().location().toString();
Region region = regionManager.getOrCreateRegionAtBlock(dimensionId, blockX, blockZ);
return sendLines(source, region);
}
private static int sendLines(CommandSourceStack source, Region region) {
for (String line : RegionInfoFormatter.format(region)) { for (String line : RegionInfoFormatter.format(region)) {
source.sendSuccess(() -> Component.literal(line), false); source.sendSuccess(() -> Component.literal(line), false);
} }
return 1; return 1;
} }
private static int floorToBlock(double coordinate) {
return (int) Math.floor(coordinate);
}
} }
@@ -1,12 +1,25 @@
package com.livingworld.commands; package com.livingworld.commands;
import java.util.List; import com.livingworld.modules.ecosystem.EcosystemModule;
import java.util.Set; import com.livingworld.modules.ecosystem.EcosystemRegionData;
import java.util.TreeSet; import com.livingworld.modules.pollution.PollutionModule;
import com.livingworld.modules.pollution.PollutionRegionData;
import com.livingworld.modules.recovery.RecoveryModule;
import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.resources.ResourceDepletionModule;
import com.livingworld.modules.resources.ResourceRegionData;
import com.livingworld.modules.soil.SoilModule;
import com.livingworld.modules.soil.SoilRegionData;
import com.livingworld.modules.vegetation.VegetationModule;
import com.livingworld.modules.vegetation.VegetationRegionData;
import com.livingworld.modules.water.WaterModule;
import com.livingworld.modules.water.WaterRegionData;
import com.livingworld.regions.Region; import com.livingworld.regions.Region;
import com.livingworld.regions.RegionFlags; import com.livingworld.regions.RegionFlags;
import com.livingworld.regions.RegionMetrics; import com.livingworld.regions.RegionMetrics;
import com.livingworld.regions.RegionModuleData;
import java.util.ArrayList;
import java.util.List;
/** /**
* Formats region diagnostics without depending on Minecraft classes. * Formats region diagnostics without depending on Minecraft classes.
@@ -21,26 +34,84 @@ public final class RegionInfoFormatter {
throw new IllegalArgumentException("region must not be null"); throw new IllegalArgumentException("region must not be null");
} }
RegionMetrics metrics = region.getMetrics(); RegionMetrics m = region.getMetrics();
RegionFlags flags = region.getFlags(); RegionFlags f = region.getFlags();
Set<String> moduleIds = new TreeSet<>(region.getModuleData().moduleIds()); RegionModuleData data = region.getModuleData();
return List.of( List<String> lines = new ArrayList<>();
"Region: " + region.getCoordinate().stableId(), lines.add("Region: " + region.getCoordinate().stableId()
"Lifecycle: " + region.getLifecycleState() + ", dirty=" + region.isDirty(), + " lifecycle=" + region.getLifecycleState()
"Metrics: ecosystemHealth=" + metrics.getEcosystemHealth() + " dirty=" + region.isDirty()
+ ", pollution=" + metrics.getPollutionScore() + " tick=" + region.getLastUpdatedSimulationTick());
+ ", soilQuality=" + metrics.getSoilQuality() lines.add("Metrics:"
+ ", waterQuality=" + metrics.getWaterQuality() + " health=" + fmt(m.getEcosystemHealth())
+ ", vegetationPressure=" + metrics.getVegetationPressure() + " poll=" + fmt(m.getPollutionScore())
+ ", resourceDepletion=" + metrics.getResourceDepletion() + " soil=" + fmt(m.getSoilQuality())
+ ", recoveryPressure=" + metrics.getRecoveryPressure(), + " water=" + fmt(m.getWaterQuality())
"Flags: playerActivity=" + flags.isHasPlayerActivity() + " veg=" + fmt(m.getVegetationPressure())
+ ", highPollution=" + flags.isHasHighPollution() + " res=" + fmt(m.getResourceDepletion())
+ ", lowSoilQuality=" + flags.isHasLowSoilQuality() + " recov=" + fmt(m.getRecoveryPressure()));
+ ", activeEcosystemEvent=" + flags.isHasActiveEcosystemEvent() lines.add("Flags:"
+ ", forceLoaded=" + flags.isForceLoadedBySimulation() + " playerActivity=" + f.isHasPlayerActivity()
+ ", corrupted=" + flags.isCorrupted(), + " highPollution=" + f.isHasHighPollution()
"Module data: " + (moduleIds.isEmpty() ? "none" : String.join(", ", moduleIds))); + " lowSoil=" + f.isHasLowSoilQuality()
+ " ecoEvent=" + f.isHasActiveEcosystemEvent()
+ " forceLoaded=" + f.isForceLoadedBySimulation());
lines.add("--- Module Data ---");
data.get(PollutionModule.MODULE_ID, PollutionRegionData.class).ifPresentOrElse(
d -> lines.add(" pollution: air=" + fmt(d.getAirPollution())
+ " ground=" + fmt(d.getGroundPollution())
+ " water=" + fmt(d.getWaterPollution())
+ " decay=" + fmt(d.getDecayResistance())),
() -> lines.add(" pollution: (no data)"));
data.get(SoilModule.MODULE_ID, SoilRegionData.class).ifPresentOrElse(
d -> lines.add(" soil: fertility=" + fmt(d.getFertility())
+ " moisture=" + fmt(d.getMoisture())
+ " contam=" + fmt(d.getContamination())
+ " compact=" + fmt(d.getCompaction())
+ " erosion=" + fmt(d.getErosion())),
() -> lines.add(" soil: (no data)"));
data.get(WaterModule.MODULE_ID, WaterRegionData.class).ifPresentOrElse(
d -> lines.add(" water: avail=" + fmt(d.getWaterAvailability())
+ " purif=" + fmt(d.getPurificationCapacity())
+ " drought=" + fmt(d.getDroughtRisk())
+ " flood=" + fmt(d.getFloodRisk())),
() -> lines.add(" water: (no data)"));
data.get(VegetationModule.MODULE_ID, VegetationRegionData.class).ifPresentOrElse(
d -> lines.add(" vegetation: grass=" + fmt(d.getGrassPressure())
+ " flower=" + fmt(d.getFlowerPressure())
+ " shrub=" + fmt(d.getShrubPressure())
+ " tree=" + fmt(d.getTreePressure())
+ " dead=" + fmt(d.getDeadVegetation())),
() -> lines.add(" vegetation: (no data)"));
data.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).ifPresentOrElse(
d -> lines.add(" resources: mining=" + fmt(d.getMiningDepletion())
+ " logging=" + fmt(d.getLoggingDepletion())
+ " farming=" + fmt(d.getFarmingDepletion())),
() -> lines.add(" resources: (no data)"));
data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).ifPresentOrElse(
d -> lines.add(" recovery: stage=" + d.getSuccessionStage()
+ " progress=" + fmt(d.getRecoveryProgress())
+ " damage=" + fmt(d.getDamageAccumulation())),
() -> lines.add(" recovery: (no data)"));
data.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class).ifPresentOrElse(
d -> lines.add(" ecosystem: health=" + fmt(d.getEcosystemHealth())
+ " stress=" + fmt(d.getStress())
+ " resilience=" + fmt(d.getResilience())
+ " rate=" + fmt(d.getRecoveryRate())),
() -> lines.add(" ecosystem: (no data)"));
return lines;
}
private static String fmt(double v) {
return String.format("%.1f", v);
} }
} }
@@ -0,0 +1,42 @@
package com.livingworld.commands;
import com.livingworld.core.simulation.SimulationManager;
import com.livingworld.debug.SimulationProfileSnapshot;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.network.chat.Component;
import java.util.StringJoiner;
/**
* Shows the profiler snapshot from the last simulation cycle.
*/
public final class StatsCommand {
private StatsCommand() {
}
public static int execute(CommandSourceStack source, SimulationManager simulationManager) {
if (source == null) throw new IllegalArgumentException("source must not be null");
if (simulationManager == null) throw new IllegalArgumentException("simulationManager must not be null");
SimulationProfileSnapshot snap = simulationManager.createProfileSnapshot();
String cycleMs = String.format("%.2f", snap.totalCycleNanos() / 1_000_000.0);
String header = "LW stats:"
+ " cycle=" + cycleMs + "ms"
+ " events=" + snap.eventsPublished()
+ " regions=" + snap.regionsUpdated()
+ " saves=" + snap.savesPerformed()
+ " budget_overrun=" + snap.budgetExceeded()
+ " sim_tick=" + simulationManager.getSimulationTickCounter();
source.sendSuccess(() -> Component.literal(header), false);
if (!snap.moduleTimings().isEmpty()) {
StringJoiner timings = new StringJoiner(" ");
snap.moduleTimings().forEach((id, nanos) ->
timings.add(id + "=" + String.format("%.2f", nanos / 1_000_000.0) + "ms"));
String moduleLine = "Modules: " + timings;
source.sendSuccess(() -> Component.literal(moduleLine), false);
}
return 1;
}
}
@@ -18,7 +18,7 @@ public final class SimulationConfig {
private int regionSizeChunks = 8; private int regionSizeChunks = 8;
/** Interval between simulation cycles, in game ticks (must be >= 1). */ /** Interval between simulation cycles, in game ticks (must be >= 1). */
private int simulationIntervalTicks = 100; private int simulationIntervalTicks = 50;
/** Maximum number of regions processed per cycle (must be >= 1). */ /** Maximum number of regions processed per cycle (must be >= 1). */
private int maxRegionsPerCycle = 50; private int maxRegionsPerCycle = 50;
@@ -1,6 +1,10 @@
package com.livingworld.core.services; package com.livingworld.core.services;
import com.livingworld.data.saved.SaveMetadata; import com.livingworld.data.saved.SaveMetadata;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.data.serialization.PropertiesPersistenceReader;
import com.livingworld.data.serialization.PropertiesPersistenceWriter;
import com.livingworld.debug.DiagnosticCategory; import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger; import com.livingworld.debug.LivingWorldLogger;
import com.livingworld.regions.Region; import com.livingworld.regions.Region;
@@ -18,12 +22,15 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.time.Clock; import java.time.Clock;
import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Properties; import java.util.Properties;
import java.util.UUID; import java.util.UUID;
import java.util.function.BiConsumer;
/** /**
* File-backed persistence for the Volume 1 region model. * File-backed persistence for the Volume 1 region model.
@@ -40,8 +47,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
private final String modVersion; private final String modVersion;
private final Clock clock; private final Clock clock;
private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>(); private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
private final List<ModuleCodecEntry> moduleCodecs = new ArrayList<>();
private SaveMetadata metadata; private SaveMetadata metadata;
private record ModuleCodecEntry(
String moduleId,
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
BiConsumer<PersistenceReader, RegionModuleData> decoder) {}
public FileRegionPersistenceService(Path rootDirectory, String modVersion) { public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
this(rootDirectory, modVersion, Clock.systemUTC()); this(rootDirectory, modVersion, Clock.systemUTC());
} }
@@ -136,6 +149,25 @@ public final class FileRegionPersistenceService implements PersistenceService {
return dirtyRegions.size(); return dirtyRegions.size();
} }
/**
* Registers a codec that serialises one module's per-region data.
*
* <p>Must be called before any region is saved or loaded. Codecs are applied
* in registration order during both encode and decode. Each codec writes to /
* reads from keys prefixed with {@code "mod.<moduleId>."} so modules cannot
* collide.</p>
*
* @param moduleId the unique module identifier (used as key namespace)
* @param encoder writes module data from {@link RegionModuleData} to a writer
* @param decoder reads module data from a reader and populates {@link RegionModuleData}
*/
public synchronized void registerModuleCodec(
String moduleId,
BiConsumer<RegionModuleData, PersistenceWriter> encoder,
BiConsumer<PersistenceReader, RegionModuleData> decoder) {
moduleCodecs.add(new ModuleCodecEntry(moduleId, encoder, decoder));
}
private void initializeStorage() { private void initializeStorage() {
try { try {
Files.createDirectories(regionsDirectory); Files.createDirectories(regionsDirectory);
@@ -242,6 +274,13 @@ public final class FileRegionPersistenceService implements PersistenceService {
properties.setProperty( properties.setProperty(
"metric.recoveryPressure", "metric.recoveryPressure",
Double.toString(metrics.getRecoveryPressure())); Double.toString(metrics.getRecoveryPressure()));
for (ModuleCodecEntry codec : moduleCodecs) {
String prefix = "mod." + codec.moduleId() + ".";
codec.encoder().accept(
region.getModuleData(),
new PropertiesPersistenceWriter(properties, prefix));
}
return properties; return properties;
} }
@@ -270,6 +309,14 @@ public final class FileRegionPersistenceService implements PersistenceService {
metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion")); metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure")); metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure"));
RegionModuleData moduleData = new RegionModuleData();
for (ModuleCodecEntry codec : moduleCodecs) {
String prefix = "mod." + codec.moduleId() + ".";
codec.decoder().accept(
new PropertiesPersistenceReader(properties, prefix),
moduleData);
}
Region region = new Region( Region region = new Region(
UUID.fromString(required(properties, "id")), UUID.fromString(required(properties, "id")),
new RegionCoordinate( new RegionCoordinate(
@@ -282,7 +329,7 @@ public final class FileRegionPersistenceService implements PersistenceService {
false, false,
flags, flags,
metrics, metrics,
new RegionModuleData()); moduleData);
region.validate(); region.validate();
return region; return region;
} }
@@ -1,5 +1,6 @@
package com.livingworld.core.simulation; package com.livingworld.core.simulation;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -30,6 +31,11 @@ public interface RegionManager {
*/ */
List<Region> resolveAll(List<RegionCoordinate> coordinates); List<Region> resolveAll(List<RegionCoordinate> coordinates);
/**
* Returns all currently active (cached/loaded) regions.
*/
Collection<Region> getActiveRegions();
/** /**
* Marks a region as dirty, indicating unsaved changes. * Marks a region as dirty, indicating unsaved changes.
* *
@@ -1,5 +1,6 @@
package com.livingworld.core.simulation; package com.livingworld.core.simulation;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -8,6 +9,8 @@ import com.livingworld.core.services.PersistenceService;
import com.livingworld.core.services.TimeService; import com.livingworld.core.services.TimeService;
import com.livingworld.debug.DiagnosticCategory; import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger; import com.livingworld.debug.LivingWorldLogger;
import com.livingworld.debug.SimulationProfileSnapshot;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.events.LivingWorldEventBus; import com.livingworld.events.LivingWorldEventBus;
import com.livingworld.modules.ModuleUpdateResult; import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.ModuleRegistry; import com.livingworld.modules.ModuleRegistry;
@@ -61,6 +64,11 @@ public final class SimulationManager {
this.scheduler.onMinecraftTick(); this.scheduler.onMinecraftTick();
if (this.scheduler.shouldRunSimulationCycle()) { if (this.scheduler.shouldRunSimulationCycle()) {
long tick = this.timeService.getSimulationTick();
for (Region r : this.regionManager.getActiveRegions()) {
this.scheduler.queueRegion(new RegionUpdateJob(
r.getCoordinate(), 0, tick, null, UpdateReason.NORMAL_ROLLING_UPDATE));
}
runSimulationCycle(); runSimulationCycle();
} }
} }
@@ -192,6 +200,49 @@ public final class SimulationManager {
} }
} }
/**
* Forces the full module pipeline to run on a single region immediately,
* bypassing the scheduler. Intended for debug commands only.
*/
public void forceUpdateRegion(RegionCoordinate coordinate) {
if (coordinate == null) {
throw new IllegalArgumentException("coordinate must not be null");
}
Optional<Region> region = regionManager.resolve(coordinate);
if (region.isEmpty()) {
return;
}
long tick = getSimulationTickCounter();
RegionUpdateJob job = new RegionUpdateJob(
coordinate, 100, tick, null, UpdateReason.FORCED_DEBUG_COMMAND);
if (this.profiler != null) {
this.profiler.startCycle(tick);
}
try {
runModulesForRegion(region.get(), job, tick);
} finally {
if (this.profiler != null) {
this.profiler.endCycle(0);
}
}
}
/** Enqueues a region for a priority update, e.g. because a player entered it. */
public void queueRegionForUpdate(RegionCoordinate coordinate, int priority, UpdateReason reason) {
if (coordinate == null) throw new IllegalArgumentException("coordinate must not be null");
if (reason == null) throw new IllegalArgumentException("reason must not be null");
long tick = getSimulationTickCounter();
this.scheduler.queueRegion(new RegionUpdateJob(coordinate, priority, tick, null, reason));
}
/** Returns a profile snapshot of the last completed simulation cycle. */
public SimulationProfileSnapshot createProfileSnapshot() {
if (profiler instanceof com.livingworld.debug.SimulationProfiler concrete) {
return concrete.createSnapshot();
}
return new SimulationProfileSnapshot(0L, java.util.Map.of(), 0, 0, 0, false);
}
public long getMinecraftTickCounter() { public long getMinecraftTickCounter() {
return this.scheduler.getMinecraftTickCounter(); return this.scheduler.getMinecraftTickCounter();
} }
@@ -0,0 +1,183 @@
package com.livingworld.data.migration;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
/**
* Detects the schema version of region save data and applies registered
* {@link RegionMigration} steps in order until the data reaches the target version.
*
* <p>Usage:
* <ol>
* <li>Construct with the path to the migrations log file (or {@code null} to disable logging).
* <li>Register one {@link RegionMigration} per version gap via {@link #register}.
* <li>Call {@link #migrateIfNeeded} before decoding saved region properties.
* </ol>
*/
public final class MigrationManager {
private final Map<Integer, RegionMigration> migrations = new TreeMap<>();
private final Path logFile;
/**
* @param logFile path to the append-only migrations log; {@code null} to disable file logging
*/
public MigrationManager(Path logFile) {
this.logFile = logFile;
}
/**
* Registers a migration step.
*
* @throws IllegalArgumentException if the migration is null, if its version span is not
* exactly one, or if a migration for the same
* {@code fromVersion} is already registered
*/
public void register(RegionMigration migration) {
if (migration == null) {
throw new IllegalArgumentException("migration must not be null");
}
int from = migration.fromVersion();
int to = migration.toVersion();
if (to != from + 1) {
throw new IllegalArgumentException(
"Migration must advance exactly one version; got " + from + "" + to);
}
if (migrations.containsKey(from)) {
throw new IllegalArgumentException(
"Migration already registered for fromVersion " + from);
}
migrations.put(from, migration);
}
/**
* Returns the number of registered migration steps.
*/
public int getRegisteredMigrationCount() {
return migrations.size();
}
/**
* Returns {@code true} when the data's {@code schemaVersion} already equals
* {@code targetVersion} and no migration is needed.
*/
public boolean isUpToDate(Properties data, int targetVersion) {
return readVersion(data) == targetVersion;
}
/**
* Migrates the given save data to {@code targetVersion} if it is behind, and returns
* the (possibly modified) properties. Returns the original object unchanged when already
* at the target version.
*
* @throws IllegalStateException if the data is ahead of {@code targetVersion}, if
* {@code schemaVersion} is missing or invalid, or if the
* registered migrations do not cover the required range
*/
public Properties migrateIfNeeded(Properties data, int targetVersion) {
int currentVersion = readVersion(data);
if (currentVersion == targetVersion) {
return data;
}
if (currentVersion > targetVersion) {
throw new IllegalStateException(
"Cannot downgrade save data from schema version "
+ currentVersion + " to " + targetVersion);
}
List<RegionMigration> path = buildMigrationPath(currentVersion, targetVersion);
Properties result = copyProperties(data);
for (RegionMigration step : path) {
result = step.apply(result);
result.setProperty("schemaVersion", Integer.toString(step.toVersion()));
recordStep(step.fromVersion(), step.toVersion());
}
return result;
}
// -------------------------------------------------------------------------
// private helpers
// -------------------------------------------------------------------------
private List<RegionMigration> buildMigrationPath(int from, int to) {
List<RegionMigration> path = new ArrayList<>();
int version = from;
while (version < to) {
RegionMigration step = migrations.get(version);
if (step == null) {
throw new IllegalStateException(
"No migration registered for schema version " + version
+ " (target version: " + to + ")");
}
path.add(step);
version++;
}
return path;
}
private static int readVersion(Properties data) {
if (data == null) {
throw new IllegalArgumentException("data must not be null");
}
String raw = data.getProperty("schemaVersion");
if (raw == null || raw.isBlank()) {
throw new IllegalStateException("Save data is missing required key: schemaVersion");
}
int version;
try {
version = Integer.parseInt(raw.trim());
} catch (NumberFormatException e) {
throw new IllegalStateException("Invalid schemaVersion value: " + raw, e);
}
if (version <= 0) {
throw new IllegalStateException("schemaVersion must be > 0, got: " + version);
}
return version;
}
private static Properties copyProperties(Properties source) {
Properties copy = new Properties();
for (String key : source.stringPropertyNames()) {
copy.setProperty(key, source.getProperty(key));
}
return copy;
}
private void recordStep(int from, int to) {
LivingWorldLogger.info(
DiagnosticCategory.PERSISTENCE,
"Applied region schema migration: " + from + "" + to);
if (logFile == null) {
return;
}
String entry = Instant.now() + " Migrated region schema " + from + "" + to
+ System.lineSeparator();
try {
if (logFile.getParent() != null) {
Files.createDirectories(logFile.getParent());
}
Files.writeString(
logFile,
entry,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);
} catch (IOException e) {
LivingWorldLogger.warn(
DiagnosticCategory.PERSISTENCE,
"Failed to write to migrations log: " + e.getMessage());
}
}
}
@@ -0,0 +1,32 @@
package com.livingworld.data.migration;
import java.util.Properties;
/**
* A single schema migration step for region save data.
*
* <p>Each migration advances exactly one schema version. Implementations must be
* deterministic: given the same input {@link Properties}, they must always produce
* the same output. The {@link MigrationManager} updates {@code schemaVersion} in the
* result automatically; implementations must not set it themselves.
*/
public interface RegionMigration {
/** The schema version this migration reads from. */
int fromVersion();
/**
* The schema version this migration produces.
*
* <p>Must equal {@link #fromVersion()} + 1.
*/
int toVersion();
/**
* Applies this migration to the given save data.
*
* @param data a copy of the raw region save properties at {@link #fromVersion()}
* @return a new or mutated {@link Properties} containing the migrated data
*/
Properties apply(Properties data);
}
@@ -0,0 +1,51 @@
package com.livingworld.data.serialization;
import java.util.Properties;
/**
* {@link PersistenceReader} backed by a {@link Properties} object.
*
* <p>All key lookups are namespaced by a caller-supplied prefix, mirroring
* the convention used by {@link PropertiesPersistenceWriter}. Missing keys
* return the supplied default value rather than throwing.</p>
*/
public final class PropertiesPersistenceReader implements PersistenceReader {
private final Properties props;
private final String prefix;
public PropertiesPersistenceReader(Properties props, String prefix) {
this.props = props;
this.prefix = prefix;
}
@Override
public String readString(String key, String defaultValue) {
String value = props.getProperty(prefix + key);
return value != null ? value : defaultValue;
}
@Override
public int readInt(String key, int defaultValue) {
String value = props.getProperty(prefix + key);
return value != null ? Integer.parseInt(value) : defaultValue;
}
@Override
public long readLong(String key, long defaultValue) {
String value = props.getProperty(prefix + key);
return value != null ? Long.parseLong(value) : defaultValue;
}
@Override
public double readDouble(String key, double defaultValue) {
String value = props.getProperty(prefix + key);
return value != null ? Double.parseDouble(value) : defaultValue;
}
@Override
public boolean readBoolean(String key, boolean defaultValue) {
String value = props.getProperty(prefix + key);
return value != null ? Boolean.parseBoolean(value) : defaultValue;
}
}
@@ -0,0 +1,47 @@
package com.livingworld.data.serialization;
import java.util.Properties;
/**
* {@link PersistenceWriter} backed by a {@link Properties} object.
*
* <p>All keys are namespaced by a caller-supplied prefix so that multiple
* modules can write to the same {@code Properties} without collision.
* For example, a prefix of {@code "mod.pollution."} and a key of
* {@code "airPollution"} stores the value under {@code "mod.pollution.airPollution"}.</p>
*/
public final class PropertiesPersistenceWriter implements PersistenceWriter {
private final Properties props;
private final String prefix;
public PropertiesPersistenceWriter(Properties props, String prefix) {
this.props = props;
this.prefix = prefix;
}
@Override
public void writeString(String key, String value) {
props.setProperty(prefix + key, value);
}
@Override
public void writeInt(String key, int value) {
props.setProperty(prefix + key, Integer.toString(value));
}
@Override
public void writeLong(String key, long value) {
props.setProperty(prefix + key, Long.toString(value));
}
@Override
public void writeDouble(String key, double value) {
props.setProperty(prefix + key, Double.toString(value));
}
@Override
public void writeBoolean(String key, boolean value) {
props.setProperty(prefix + key, Boolean.toString(value));
}
}
@@ -0,0 +1,174 @@
package com.livingworld.modules.ecosystem;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleMetadata;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionMetrics;
import java.util.List;
/**
* Integrates all ecosystem signals into a composite health score and manages
* long-term stress and resilience dynamics.
*
* <p>This module runs <em>last</em> in the ecosystem update order, after
* {@link com.livingworld.modules.pollution.PollutionModule},
* {@link com.livingworld.modules.soil.SoilModule}, and
* {@link com.livingworld.modules.vegetation.VegetationModule}, so it reads
* fully updated current-tick values for all metrics.
*
* <h3>Ecosystem health formula</h3>
* <pre>
* health = soilQuality * 0.30
* + waterQuality * 0.20
* + (100 - pollutionScore) * 0.30
* + vegetationPressure * 0.20
* </pre>
*
* <h3>Stress model</h3>
* Danger zones are defined for each metric. Each metric in its danger zone
* contributes 2.0 stress per tick. Stress decays by 0.5 per tick when no
* dangers are active.
*
* <h3>Resilience and recovery</h3>
* Resilience is a slow-moving trend indicator. It increases when the ecosystem
* is healthy and decreases under prolonged stress. Recovery rate is derived
* from resilience and current stress level.
*/
public final class EcosystemModule implements SimulationModule {
public static final String MODULE_ID = "ecosystem";
// Weights for the composite health score (must sum to 1.0).
private static final double WEIGHT_SOIL = 0.30;
private static final double WEIGHT_WATER = 0.20;
private static final double WEIGHT_POLLUTION = 0.30;
private static final double WEIGHT_VEGETATION = 0.20;
// Danger zone thresholds (values outside these ranges add stress).
private static final double DANGER_SOIL_LOW = 20.0;
private static final double DANGER_POLLUTION_HIGH = 70.0;
private static final double DANGER_VEGETATION_LOW = 10.0;
private static final double DANGER_WATER_LOW = 20.0;
private static final double STRESS_PER_DANGER = 2.0;
private static final double STRESS_DECAY_PER_TICK = 0.5;
private static final double RESILIENCE_GROWTH_RATE = 0.02;
private static final double RESILIENCE_DRAIN_RATE = 0.01;
private static final double CHANGE_THRESHOLD = 0.01;
private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID,
"Ecosystem",
"1.0.0",
"Computes composite ecosystem health and manages long-term stress and resilience.",
"1",
List.of("pollution", "soil", "vegetation"),
List.of(),
true,
true,
false);
@Override
public String getModuleId() { return MODULE_ID; }
@Override
public ModuleMetadata getMetadata() { return METADATA; }
@Override
public void initialize(ModuleContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
}
@Override
public void onServerStarted(ServerContext context) {}
@Override
public void createDefaultRegionData(Region region) {
if (region == null) throw new IllegalArgumentException("region must not be null");
if (!region.getModuleData().contains(MODULE_ID)) {
region.getModuleData().put(MODULE_ID, EcosystemRegionData.defaults());
}
}
@Override
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
Region region = context.getRegion();
EcosystemRegionData data = region.getModuleData()
.get(MODULE_ID, EcosystemRegionData.class)
.orElseGet(EcosystemRegionData::defaults);
RegionMetrics metrics = region.getMetrics();
double prevHealth = metrics.getEcosystemHealth();
double prevRecoveryPressure = metrics.getRecoveryPressure();
// --- Composite health ---
double health = metrics.getSoilQuality() * WEIGHT_SOIL
+ metrics.getWaterQuality() * WEIGHT_WATER
+ (100.0 - metrics.getPollutionScore()) * WEIGHT_POLLUTION
+ metrics.getVegetationPressure() * WEIGHT_VEGETATION;
health = Math.max(0.0, Math.min(100.0, health));
// --- Stress accumulation ---
int dangerCount = 0;
if (metrics.getSoilQuality() < DANGER_SOIL_LOW) dangerCount++;
if (metrics.getPollutionScore() > DANGER_POLLUTION_HIGH) dangerCount++;
if (metrics.getVegetationPressure() < DANGER_VEGETATION_LOW) dangerCount++;
if (metrics.getWaterQuality() < DANGER_WATER_LOW) dangerCount++;
double newStress;
if (dangerCount > 0) {
newStress = data.getStress() + dangerCount * STRESS_PER_DANGER;
} else {
newStress = Math.max(0.0, data.getStress() - STRESS_DECAY_PER_TICK);
}
data.setStress(newStress);
// --- Resilience trend ---
if (health > 60.0) {
data.setResilience(data.getResilience() + RESILIENCE_GROWTH_RATE);
} else {
data.setResilience(data.getResilience() - RESILIENCE_DRAIN_RATE);
}
// --- Recovery rate: high resilience and low stress produce fast recovery ---
double recoveryRate = data.getResilience() * (1.0 - data.getStress() / 100.0) * 0.10 + 1.0;
data.setRecoveryRate(Math.min(100.0, recoveryRate));
data.setEcosystemHealth(health);
// --- Summary metrics ---
metrics.setEcosystemHealth(health);
// Recovery pressure is high when the ecosystem is suffering and needs attention.
double recoveryPressure = Math.max(0.0, (100.0 - health) + data.getStress() * 0.30);
metrics.setRecoveryPressure(Math.min(100.0, recoveryPressure));
region.getModuleData().put(MODULE_ID, data);
boolean changed =
Math.abs(metrics.getEcosystemHealth() - prevHealth) > CHANGE_THRESHOLD
|| Math.abs(metrics.getRecoveryPressure() - prevRecoveryPressure) > CHANGE_THRESHOLD;
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
}
@Override
public void onLivingWorldEvent(LivingWorldEvent event) {}
@Override
public void saveModuleData(PersistenceWriter writer) {}
@Override
public void loadModuleData(PersistenceReader reader) {}
@Override
public void shutdown() {}
}
@@ -0,0 +1,115 @@
package com.livingworld.modules.ecosystem;
/**
* Per-region ecosystem summary state tracked by {@link EcosystemModule}.
*
* <p>These values integrate signals from all other ecosystem modules to represent
* the overall ecological condition of a region. All values are clamped to [0, 100].
*
* <ul>
* <li><b>ecosystemHealth</b> weighted composite of soil, water, pollution and vegetation
* <li><b>stress</b> accumulated ecological stress; rises when multiple metrics
* are in danger zones, recovers slowly in good conditions
* <li><b>resilience</b> long-term stability; high resilience means the region
* resists and recovers from damage more quickly
* <li><b>recoveryRate</b> current rate of ecological self-repair per tick
* </ul>
*/
public final class EcosystemRegionData {
private static final double MIN = 0.0;
private static final double MAX = 100.0;
private double ecosystemHealth;
private double stress;
private double resilience;
private double recoveryRate;
public EcosystemRegionData(
double ecosystemHealth,
double stress,
double resilience,
double recoveryRate) {
this.ecosystemHealth = clamp(ecosystemHealth);
this.stress = clamp(stress);
this.resilience = clamp(resilience);
this.recoveryRate = clamp(recoveryRate);
}
/** Returns a default moderate-health ecosystem state. */
public static EcosystemRegionData defaults() {
return new EcosystemRegionData(60.0, 20.0, 50.0, 5.0);
}
// ------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------
public double getEcosystemHealth() { return ecosystemHealth; }
public double getStress() { return stress; }
public double getResilience() { return resilience; }
public double getRecoveryRate() { return recoveryRate; }
// ------------------------------------------------------------------
// Mutation
// ------------------------------------------------------------------
/**
* Records an ecological stress event of the given magnitude.
*
* <p>Stress accumulates and slowly erodes resilience.
*
* @param amount positive stress magnitude
*/
public void applyStress(double amount) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
stress = clamp(stress + amount);
resilience = clamp(resilience - amount * 0.1);
}
/**
* Records an ecological recovery event of the given magnitude.
*
* <p>Reduces stress and gradually rebuilds resilience.
*
* @param amount positive recovery magnitude
*/
public void applyRecovery(double amount) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
stress = clamp(stress - amount);
resilience = clamp(resilience + amount * 0.05);
}
public void setEcosystemHealth(double v) { ecosystemHealth = clamp(v); }
public void setStress(double v) { stress = clamp(v); }
public void setResilience(double v) { resilience = clamp(v); }
public void setRecoveryRate(double v) { recoveryRate = clamp(v); }
/** Clamps all fields to [0, 100]. */
public void normalize() {
ecosystemHealth = clamp(ecosystemHealth);
stress = clamp(stress);
resilience = clamp(resilience);
recoveryRate = clamp(recoveryRate);
}
/** Returns an independent copy. */
public EcosystemRegionData copy() {
return new EcosystemRegionData(ecosystemHealth, stress, resilience, recoveryRate);
}
// ------------------------------------------------------------------
private static double clamp(double v) {
return Math.min(MAX, Math.max(MIN, v));
}
@Override
public String toString() {
return "EcosystemRegionData{"
+ "health=" + ecosystemHealth
+ ", stress=" + stress
+ ", resilience=" + resilience
+ ", recoveryRate=" + recoveryRate + "}";
}
}
@@ -0,0 +1,135 @@
package com.livingworld.modules.pollution;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleMetadata;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionMetrics;
import java.util.List;
/**
* Simulates natural pollution decay and computes the summary pollution metrics
* written to {@link RegionMetrics}.
*
* <p>This module should run first in the ecosystem update order so that
* {@link com.livingworld.modules.soil.SoilModule} and
* {@link com.livingworld.modules.vegetation.VegetationModule} see current-tick
* pollution values when they execute.
*
* <h3>Per-tick rules</h3>
* <ol>
* <li>Air, ground, and water pollution each decay at different rates.
* <li>Ground pollution slowly leaches into water.
* <li>{@link RegionMetrics#getPollutionScore()} is recomputed as a weighted average.
* <li>{@link RegionMetrics#getWaterQuality()} is reduced proportionally to water pollution.
* </ol>
*
* <h3>Configurable constants</h3>
* These are intentionally simple for V1 and expected to be tuned.
* <ul>
* <li>BASE_DECAY_RATE fraction of pollution removed per tick before resistance
* <li>GROUND_TO_WATER_LEACH fraction of groundPollution that leaches to water each tick
* <li>WATER_QUALITY_IMPACT fraction by which water pollution degrades waterQuality
* </ul>
*/
public final class PollutionModule implements SimulationModule {
public static final String MODULE_ID = "pollution";
private static final double BASE_DECAY_RATE = 0.008;
private static final double GROUND_TO_WATER_LEACH = 0.005;
private static final double WATER_QUALITY_IMPACT = 0.05;
private static final double CHANGE_THRESHOLD = 0.01;
private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID,
"Pollution",
"1.0.0",
"Simulates pollution decay and spread across regions.",
"1",
List.of(),
List.of(),
true,
true,
false);
@Override
public String getModuleId() { return MODULE_ID; }
@Override
public ModuleMetadata getMetadata() { return METADATA; }
@Override
public void initialize(ModuleContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
}
@Override
public void onServerStarted(ServerContext context) {}
@Override
public void createDefaultRegionData(Region region) {
if (region == null) throw new IllegalArgumentException("region must not be null");
if (!region.getModuleData().contains(MODULE_ID)) {
region.getModuleData().put(MODULE_ID, PollutionRegionData.defaults());
}
}
@Override
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
Region region = context.getRegion();
PollutionRegionData data = region.getModuleData()
.get(MODULE_ID, PollutionRegionData.class)
.orElseGet(PollutionRegionData::defaults);
double prevPollutionScore = region.getMetrics().getPollutionScore();
double prevWaterQuality = region.getMetrics().getWaterQuality();
// Natural decay: air decays fastest, water slowest (modulated by resistance).
data.decay(BASE_DECAY_RATE);
// Ground pollution slowly leaches into water even after decay.
double leach = data.getGroundPollution() * GROUND_TO_WATER_LEACH;
data.addPollution(0.0, 0.0, leach);
// Summary metric: weighted average emphasising waterPollution as most damaging.
double pollutionScore = (data.getAirPollution() * 0.40
+ data.getGroundPollution() * 0.35
+ data.getWaterPollution() * 0.25);
region.getMetrics().setPollutionScore(pollutionScore);
// Water quality degrades proportionally to water pollution this tick.
double waterQualityDrop = data.getWaterPollution() * WATER_QUALITY_IMPACT;
region.getMetrics().setWaterQuality(
Math.max(0.0, region.getMetrics().getWaterQuality() - waterQualityDrop));
// Persist updated data.
region.getModuleData().put(MODULE_ID, data);
boolean changed =
Math.abs(region.getMetrics().getPollutionScore() - prevPollutionScore) > CHANGE_THRESHOLD
|| Math.abs(region.getMetrics().getWaterQuality() - prevWaterQuality) > CHANGE_THRESHOLD;
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
}
@Override
public void onLivingWorldEvent(LivingWorldEvent event) {}
@Override
public void saveModuleData(PersistenceWriter writer) {}
@Override
public void loadModuleData(PersistenceReader reader) {}
@Override
public void shutdown() {}
}
@@ -0,0 +1,103 @@
package com.livingworld.modules.pollution;
/**
* Per-region pollution state tracked by {@link PollutionModule}.
*
* <p>Stores three independent pollution layers (air, ground, water) and a decay
* resistance score. All values are clamped to [0, 100].
*
* <p>Decay resistance slows natural recovery. A brand-new region has zero
* pollution and low decay resistance, meaning pollution introduced there will
* dissipate quickly.
*/
public final class PollutionRegionData {
private static final double MIN = 0.0;
private static final double MAX = 100.0;
private double airPollution;
private double groundPollution;
private double waterPollution;
private double decayResistance;
public PollutionRegionData(
double airPollution,
double groundPollution,
double waterPollution,
double decayResistance) {
this.airPollution = clamp(airPollution);
this.groundPollution = clamp(groundPollution);
this.waterPollution = clamp(waterPollution);
this.decayResistance = clamp(decayResistance);
}
/** Returns a clean region with low decay resistance. */
public static PollutionRegionData defaults() {
return new PollutionRegionData(0.0, 0.0, 0.0, 20.0);
}
// ------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------
public double getAirPollution() { return airPollution; }
public double getGroundPollution() { return groundPollution; }
public double getWaterPollution() { return waterPollution; }
public double getDecayResistance() { return decayResistance; }
// ------------------------------------------------------------------
// Mutation
// ------------------------------------------------------------------
/** Adds pollution to each layer; values are clamped after addition. */
public void addPollution(double air, double ground, double water) {
airPollution = clamp(airPollution + air);
groundPollution = clamp(groundPollution + ground);
waterPollution = clamp(waterPollution + water);
}
/**
* Applies a single natural-decay tick.
*
* <p>{@code baseRate} is the fraction removed per tick before resistance is
* applied (e.g. 0.02 = 2 %). Decay resistance reduces the effective rate:
* effective = baseRate * (1 - decayResistance / 200).
*
* @param baseRate fraction to decay per tick (01)
*/
public void decay(double baseRate) {
double resistanceFactor = 1.0 - (decayResistance / 200.0);
double effectiveRate = Math.max(0.0, baseRate * resistanceFactor);
airPollution = clamp(airPollution * (1.0 - effectiveRate * 2.0));
groundPollution = clamp(groundPollution * (1.0 - effectiveRate * 0.5));
waterPollution = clamp(waterPollution * (1.0 - effectiveRate * 0.3));
}
/** Clamps all fields to [0, 100]. */
public void normalize() {
airPollution = clamp(airPollution);
groundPollution = clamp(groundPollution);
waterPollution = clamp(waterPollution);
decayResistance = clamp(decayResistance);
}
/** Returns an independent copy. */
public PollutionRegionData copy() {
return new PollutionRegionData(airPollution, groundPollution, waterPollution, decayResistance);
}
// ------------------------------------------------------------------
private static double clamp(double v) {
return Math.min(MAX, Math.max(MIN, v));
}
@Override
public String toString() {
return "PollutionRegionData{"
+ "air=" + airPollution
+ ", ground=" + groundPollution
+ ", water=" + waterPollution
+ ", decayResistance=" + decayResistance + "}";
}
}
@@ -0,0 +1,153 @@
package com.livingworld.modules.recovery;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleMetadata;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionMetrics;
import java.util.List;
/**
* Manages ecological succession: the gradual progression of a region from bare
* ground through grassland, scrubland, and young woodland to mature forest —
* and the regression back toward bare ground when conditions deteriorate.
*
* <p>This module runs near the end of the pipeline (after pollution, soil,
* water, vegetation, and resources have updated) so that succession decisions
* are made against fully current metrics.
*
* <h3>Advancement</h3>
* Each tick that metrics meet a stage's thresholds, {@code recoveryProgress}
* increases by the ecosystem's {@code recoveryPressure}-derived rate. When
* it reaches 100, the region advances one succession stage.
*
* <h3>Regression</h3>
* Each tick that metrics fall badly below the current stage's minimums,
* {@code damageAccumulation} increases. At 70 accumulated damage the region
* regresses one stage.
*
* <h3>Recovery pressure metric</h3>
* The module modifies {@link RegionMetrics#getRecoveryPressure()} to reflect
* how far the region is from mature forest (EcosystemModule also writes this;
* this module's write takes precedence because it runs later).
*/
public final class RecoveryModule implements SimulationModule {
public static final String MODULE_ID = "recovery";
/** Base recovery progress per tick (added when conditions are met). */
private static final double BASE_PROGRESS_PER_TICK = 0.5;
/** Extra recovery progress per point of ecosystemHealth above 50. */
private static final double HEALTH_PROGRESS_BONUS = 0.02;
/** Damage accumulated per tick when conditions are badly violated. */
private static final double DAMAGE_PER_BAD_TICK = 3.0;
/** Damage decays this fraction per tick when conditions are OK. */
private static final double DAMAGE_DECAY_RATE = 0.05;
private static final double CHANGE_THRESHOLD = 0.01;
private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID,
"Recovery & Succession",
"1.0.0",
"Manages ecological succession stages from barren ground to mature forest.",
"1",
List.of("pollution", "soil", "vegetation"),
List.of("ecosystem"),
true,
true,
false);
@Override
public String getModuleId() { return MODULE_ID; }
@Override
public ModuleMetadata getMetadata() { return METADATA; }
@Override
public void initialize(ModuleContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
}
@Override
public void onServerStarted(ServerContext context) {}
@Override
public void createDefaultRegionData(Region region) {
if (region == null) throw new IllegalArgumentException("region must not be null");
if (!region.getModuleData().contains(MODULE_ID)) {
region.getModuleData().put(MODULE_ID, RecoveryRegionData.defaults());
}
}
@Override
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
Region region = context.getRegion();
RecoveryRegionData data = region.getModuleData()
.get(MODULE_ID, RecoveryRegionData.class)
.orElseGet(RecoveryRegionData::defaults);
RegionMetrics metrics = region.getMetrics();
SuccessionStage stageBefore = data.getSuccessionStage();
double progressBefore = data.getRecoveryProgress();
double soil = metrics.getSoilQuality();
double poll = metrics.getPollutionScore();
double veg = metrics.getVegetationPressure();
if (data.getSuccessionStage().conditionsMetForAdvancement(soil, poll, veg)) {
// Conditions are good: advance recovery progress.
double progressGain = BASE_PROGRESS_PER_TICK;
if (metrics.getEcosystemHealth() > 50.0) {
progressGain += (metrics.getEcosystemHealth() - 50.0) * HEALTH_PROGRESS_BONUS;
}
data.advanceProgress(progressGain, soil, poll, veg);
// Damage decays passively when conditions are good (bypass accumulateDamage
// to avoid triggering regression with a zero-damage call that might fire at 70+).
data = new RecoveryRegionData(
data.getSuccessionStage(),
data.getRecoveryProgress(),
Math.max(0.0, data.getDamageAccumulation() * (1.0 - DAMAGE_DECAY_RATE)));
} else if (data.getSuccessionStage().conditionsMissedForRegression(soil, poll, veg)) {
// Conditions are bad: accumulate damage toward regression.
data.accumulateDamage(DAMAGE_PER_BAD_TICK, soil, poll, veg);
}
// Recovery pressure reflects distance from mature forest.
int stagesFromPeak = SuccessionStage.MATURE_FOREST.ordinal()
- data.getSuccessionStage().ordinal();
double recoveryPressure = Math.min(100.0, stagesFromPeak * 20.0
+ data.getDamageAccumulation() * 0.30);
metrics.setRecoveryPressure(recoveryPressure);
region.getModuleData().put(MODULE_ID, data);
boolean stageChanged = data.getSuccessionStage() != stageBefore;
boolean progressChanged = Math.abs(data.getRecoveryProgress() - progressBefore)
> CHANGE_THRESHOLD;
return (stageChanged || progressChanged)
? ModuleUpdateResult.changed()
: ModuleUpdateResult.noChange();
}
@Override
public void onLivingWorldEvent(LivingWorldEvent event) {}
@Override
public void saveModuleData(PersistenceWriter writer) {}
@Override
public void loadModuleData(PersistenceReader reader) {}
@Override
public void shutdown() {}
}
@@ -0,0 +1,152 @@
package com.livingworld.modules.recovery;
/**
* Per-region ecological recovery state tracked by {@link RecoveryModule}.
*
* <p>Tracks where a region sits in the ecological succession sequence and how much
* progress it has made toward the next stage.
*
* <ul>
* <li><b>successionStage</b> current ecological stage (see {@link SuccessionStage})
* <li><b>recoveryProgress</b> progress (0100) toward advancing to the next stage
* <li><b>damageAccumulation</b> accumulated ecological damage (0100); when this
* exceeds a threshold the region regresses to the previous stage
* </ul>
*/
public final class RecoveryRegionData {
private static final double MIN = 0.0;
private static final double MAX = 100.0;
private SuccessionStage successionStage;
private double recoveryProgress;
private double damageAccumulation;
/** Biome-derived ceiling — region cannot advance past this stage. */
private SuccessionStage maxSuccessionStage;
public RecoveryRegionData(
SuccessionStage successionStage,
double recoveryProgress,
double damageAccumulation) {
this(successionStage, recoveryProgress, damageAccumulation, SuccessionStage.MATURE_FOREST);
}
public RecoveryRegionData(
SuccessionStage successionStage,
double recoveryProgress,
double damageAccumulation,
SuccessionStage maxSuccessionStage) {
if (successionStage == null) {
throw new IllegalArgumentException("successionStage must not be null");
}
if (maxSuccessionStage == null) {
throw new IllegalArgumentException("maxSuccessionStage must not be null");
}
this.successionStage = successionStage;
this.recoveryProgress = clamp(recoveryProgress);
this.damageAccumulation = clamp(damageAccumulation);
this.maxSuccessionStage = maxSuccessionStage;
}
/** Returns a region at grassland stage with no accumulated damage. */
public static RecoveryRegionData defaults() {
return new RecoveryRegionData(SuccessionStage.GRASSLAND, 0.0, 0.0);
}
// ------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------
public SuccessionStage getSuccessionStage() { return successionStage; }
public double getRecoveryProgress() { return recoveryProgress; }
public double getDamageAccumulation() { return damageAccumulation; }
public SuccessionStage getMaxSuccessionStage() { return maxSuccessionStage; }
public void setMaxSuccessionStage(SuccessionStage cap) {
if (cap == null) throw new IllegalArgumentException("cap must not be null");
this.maxSuccessionStage = cap;
// If current stage already exceeds the new cap, clamp it.
if (successionStage.ordinal() > cap.ordinal()) {
successionStage = cap;
recoveryProgress = 0.0;
}
}
// ------------------------------------------------------------------
// Mutation
// ------------------------------------------------------------------
/**
* Advances recovery progress. If progress reaches 100 and conditions allow,
* the stage advances and progress resets.
*
* @param amount positive progress to add
* @param soilQuality current soilQuality metric
* @param pollutionScore current pollutionScore metric
* @param vegetationPressure current vegetationPressure metric
*/
public void advanceProgress(double amount,
double soilQuality,
double pollutionScore,
double vegetationPressure) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
recoveryProgress = clamp(recoveryProgress + amount);
if (recoveryProgress >= 100.0
&& successionStage.hasNext()
&& successionStage.next().ordinal() <= maxSuccessionStage.ordinal()
&& successionStage.conditionsMetForAdvancement(soilQuality, pollutionScore, vegetationPressure)) {
successionStage = successionStage.next();
recoveryProgress = 0.0;
damageAccumulation = clamp(damageAccumulation - 10.0); // partial healing on advance
}
}
/**
* Accumulates ecological damage. If damage exceeds 70, the region regresses
* one succession stage and damage resets partially.
*
* @param amount positive damage to accumulate
* @param soilQuality current soilQuality metric
* @param pollutionScore current pollutionScore metric
* @param vegetationPressure current vegetationPressure metric
*/
public void accumulateDamage(double amount,
double soilQuality,
double pollutionScore,
double vegetationPressure) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
damageAccumulation = clamp(damageAccumulation + amount);
if (damageAccumulation >= 70.0
&& successionStage.conditionsMissedForRegression(soilQuality, pollutionScore, vegetationPressure)) {
successionStage = successionStage.prev();
damageAccumulation = 30.0; // partial reset so regression isn't instantaneous
recoveryProgress = 0.0;
}
}
/** Clamps all numeric fields to [0, 100]. */
public void normalize() {
recoveryProgress = clamp(recoveryProgress);
damageAccumulation = clamp(damageAccumulation);
}
/** Returns an independent copy. */
public RecoveryRegionData copy() {
return new RecoveryRegionData(successionStage, recoveryProgress, damageAccumulation, maxSuccessionStage);
}
// ------------------------------------------------------------------
private static double clamp(double v) {
return Math.min(MAX, Math.max(MIN, v));
}
@Override
public String toString() {
return "RecoveryRegionData{"
+ "stage=" + successionStage
+ ", progress=" + recoveryProgress
+ ", damage=" + damageAccumulation
+ ", maxStage=" + maxSuccessionStage + "}";
}
}
@@ -0,0 +1,122 @@
package com.livingworld.modules.recovery;
/**
* Ecological succession stages that a region can progress through or regress from.
*
* <p>Stages are ordered from most degraded ({@link #BARREN}) to most developed
* ({@link #MATURE_FOREST}). Advancement requires conditions to meet each stage's
* thresholds; sustained damage can cause regression.
*/
public enum SuccessionStage {
/** No significant vegetation; exposed or contaminated soil. */
BARREN(
/* minSoilQuality */ 0.0,
/* maxPollutionScore */ 100.0,
/* minVegetationPressure */ 0.0),
/** Patchy grass cover starting to establish. */
SPARSE_GRASS(
10.0,
80.0,
10.0),
/** Continuous grass cover with scattered herbs. */
GRASSLAND(
25.0,
70.0,
20.0),
/** Shrubs and mixed vegetation; beginning of structural complexity. */
SCRUBLAND(
40.0,
60.0,
35.0),
/** Young trees intermixed with shrubs; canopy forming. */
YOUNG_WOODLAND(
50.0,
50.0,
45.0),
/** Established tree canopy; full ecological complexity. */
MATURE_FOREST(
60.0,
40.0,
55.0);
// ------------------------------------------------------------------
// Stage thresholds
// ------------------------------------------------------------------
/** Minimum soilQuality metric required to enter (and stay in) this stage. */
public final double minSoilQuality;
/** Maximum pollutionScore metric allowed to enter (and stay in) this stage. */
public final double maxPollutionScore;
/** Minimum vegetationPressure metric required to enter (and stay in) this stage. */
public final double minVegetationPressure;
SuccessionStage(double minSoilQuality, double maxPollutionScore, double minVegetationPressure) {
this.minSoilQuality = minSoilQuality;
this.maxPollutionScore = maxPollutionScore;
this.minVegetationPressure = minVegetationPressure;
}
// ------------------------------------------------------------------
// Navigation
// ------------------------------------------------------------------
/** Returns {@code true} if this stage is not {@link #MATURE_FOREST}. */
public boolean hasNext() {
return ordinal() < MATURE_FOREST.ordinal();
}
/** Returns {@code true} if this stage is not {@link #BARREN}. */
public boolean hasPrev() {
return ordinal() > BARREN.ordinal();
}
/** Returns the next stage, or this stage if already {@link #MATURE_FOREST}. */
public SuccessionStage next() {
return hasNext() ? values()[ordinal() + 1] : this;
}
/** Returns the previous stage, or this stage if already {@link #BARREN}. */
public SuccessionStage prev() {
return hasPrev() ? values()[ordinal() - 1] : this;
}
// ------------------------------------------------------------------
// Condition checks (based on RegionMetrics)
// ------------------------------------------------------------------
/**
* Returns {@code true} when current ecosystem metrics are good enough for this
* region to <em>advance</em> to the next succession stage.
*/
public boolean conditionsMetForAdvancement(
double soilQuality, double pollutionScore, double vegetationPressure) {
if (!hasNext()) return false;
SuccessionStage target = next();
return soilQuality >= target.minSoilQuality
&& pollutionScore <= target.maxPollutionScore
&& vegetationPressure >= target.minVegetationPressure;
}
/**
* Returns {@code true} when current metrics have deteriorated enough to
* <em>regress</em> to the previous succession stage.
*
* <p>Regression threshold is set tighter than advancement (conditions must be
* significantly worse than the current stage's minimums).
*/
public boolean conditionsMissedForRegression(
double soilQuality, double pollutionScore, double vegetationPressure) {
if (!hasPrev()) return false;
return soilQuality < minSoilQuality * 0.5
|| pollutionScore > maxPollutionScore * 1.2
|| vegetationPressure < minVegetationPressure * 0.5;
}
}
@@ -0,0 +1,131 @@
package com.livingworld.modules.resources;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleMetadata;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.regions.Region;
import java.util.List;
/**
* Tracks how intensively each resource type has been harvested and models
* natural regeneration, writing {@link com.livingworld.regions.RegionMetrics#getResourceDepletion()}.
*
* <p>Depletion events (mining, logging, farming) are normally recorded by the
* NeoForge platform adapter when players interact with the world. This module
* handles the autonomous decay (regeneration) side.
*
* <h3>Regeneration rates (per tick)</h3>
* <ul>
* <li><b>mining</b> 0.01 % (geological timescale; very slow)
* <li><b>logging</b> 0.5 % base + bonus when vegetationPressure is high
* <li><b>farming</b> 0.3 % base + bonus when soilQuality is high
* </ul>
*
* <h3>Ecosystem feedback</h3>
* High logging depletion suppresses tree growth (read by VegetationModule via
* {@link com.livingworld.regions.RegionMetrics#getResourceDepletion()}).
*/
public final class ResourceDepletionModule implements SimulationModule {
public static final String MODULE_ID = "resources";
private static final double MINING_REGEN_RATE = 0.0001;
private static final double LOGGING_BASE_REGEN = 0.005;
private static final double LOGGING_VEG_BONUS_RATE = 0.005;
private static final double LOGGING_VEG_THRESHOLD = 50.0;
private static final double FARMING_BASE_REGEN = 0.003;
private static final double FARMING_SOIL_BONUS_RATE = 0.003;
private static final double FARMING_SOIL_THRESHOLD = 50.0;
private static final double CHANGE_THRESHOLD = 0.01;
private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID,
"Resource Depletion",
"1.0.0",
"Tracks resource exhaustion and natural regeneration.",
"1",
List.of(),
List.of("soil", "vegetation"),
true,
true,
false);
@Override
public String getModuleId() { return MODULE_ID; }
@Override
public ModuleMetadata getMetadata() { return METADATA; }
@Override
public void initialize(ModuleContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
}
@Override
public void onServerStarted(ServerContext context) {}
@Override
public void createDefaultRegionData(Region region) {
if (region == null) throw new IllegalArgumentException("region must not be null");
if (!region.getModuleData().contains(MODULE_ID)) {
region.getModuleData().put(MODULE_ID, ResourceRegionData.defaults());
}
}
@Override
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
Region region = context.getRegion();
ResourceRegionData data = region.getModuleData()
.get(MODULE_ID, ResourceRegionData.class)
.orElseGet(ResourceRegionData::defaults);
double prevTotal = data.totalDepletion();
// Mining regenerates extremely slowly (geological timescale).
data.setMiningDepletion(data.getMiningDepletion() * (1.0 - MINING_REGEN_RATE));
// Logging regenerates faster when vegetation is recovering.
double loggingRegen = LOGGING_BASE_REGEN;
if (region.getMetrics().getVegetationPressure() > LOGGING_VEG_THRESHOLD) {
loggingRegen += (region.getMetrics().getVegetationPressure() - LOGGING_VEG_THRESHOLD)
* LOGGING_VEG_BONUS_RATE;
}
data.setLoggingDepletion(Math.max(0.0, data.getLoggingDepletion() - loggingRegen));
// Farming depletion recovers faster with good soil quality.
double farmingRegen = FARMING_BASE_REGEN;
if (region.getMetrics().getSoilQuality() > FARMING_SOIL_THRESHOLD) {
farmingRegen += (region.getMetrics().getSoilQuality() - FARMING_SOIL_THRESHOLD)
* FARMING_SOIL_BONUS_RATE;
}
data.setFarmingDepletion(Math.max(0.0, data.getFarmingDepletion() - farmingRegen));
// Write summary metric.
region.getMetrics().setResourceDepletion(data.totalDepletion());
region.getModuleData().put(MODULE_ID, data);
boolean changed = Math.abs(data.totalDepletion() - prevTotal) > CHANGE_THRESHOLD;
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
}
@Override
public void onLivingWorldEvent(LivingWorldEvent event) {}
@Override
public void saveModuleData(PersistenceWriter writer) {}
@Override
public void loadModuleData(PersistenceReader reader) {}
@Override
public void shutdown() {}
}
@@ -0,0 +1,123 @@
package com.livingworld.modules.resources;
/**
* Per-region resource depletion state tracked by {@link ResourceDepletionModule}.
*
* <p>Three independent depletion layers model how intensively each type of
* resource harvesting has affected the region. All values are clamped to [0, 100],
* where 0 means untouched and 100 means completely exhausted.
*
* <ul>
* <li><b>miningDepletion</b> how heavily the region's mineral resources have
* been extracted (very slow natural recovery)
* <li><b>loggingDepletion</b> how heavily trees have been harvested (recovery
* is linked to vegetation succession)
* <li><b>farmingDepletion</b> how heavily the land has been cultivated without
* rest (recovery driven by soil quality)
* </ul>
*
* <p>Depletion events are recorded via {@link #recordMining}, {@link #recordLogging},
* and {@link #recordFarming}, which are intended to be called from platform-layer
* event handlers when players interact with the world.
*/
public final class ResourceRegionData {
private static final double MIN = 0.0;
private static final double MAX = 100.0;
private double miningDepletion;
private double loggingDepletion;
private double farmingDepletion;
public ResourceRegionData(double miningDepletion, double loggingDepletion, double farmingDepletion) {
this.miningDepletion = clamp(miningDepletion);
this.loggingDepletion = clamp(loggingDepletion);
this.farmingDepletion = clamp(farmingDepletion);
}
/** Returns a pristine, unextracted region. */
public static ResourceRegionData defaults() {
return new ResourceRegionData(0.0, 0.0, 0.0);
}
// ------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------
public double getMiningDepletion() { return miningDepletion; }
public double getLoggingDepletion() { return loggingDepletion; }
public double getFarmingDepletion() { return farmingDepletion; }
/** Weighted total depletion score, matching the metric written to {@link com.livingworld.regions.RegionMetrics}. */
public double totalDepletion() {
return clamp(miningDepletion * 0.50 + loggingDepletion * 0.30 + farmingDepletion * 0.20);
}
// ------------------------------------------------------------------
// Depletion events (called by platform event handlers)
// ------------------------------------------------------------------
/**
* Records a mining event of the given intensity.
*
* @param intensity depletion to add (0100)
*/
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 (0100)
*/
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 (0100)
*/
public void recordFarming(double intensity) {
if (intensity < 0) throw new IllegalArgumentException("intensity must be >= 0");
farmingDepletion = clamp(farmingDepletion + intensity);
}
// ------------------------------------------------------------------
// Internal setters (used by the module's regeneration logic)
// ------------------------------------------------------------------
public void setMiningDepletion(double v) { miningDepletion = clamp(v); }
public void setLoggingDepletion(double v) { loggingDepletion = clamp(v); }
public void setFarmingDepletion(double v) { farmingDepletion = clamp(v); }
/** Clamps all fields to [0, 100]. */
public void normalize() {
miningDepletion = clamp(miningDepletion);
loggingDepletion = clamp(loggingDepletion);
farmingDepletion = clamp(farmingDepletion);
}
/** Returns an independent copy. */
public ResourceRegionData copy() {
return new ResourceRegionData(miningDepletion, loggingDepletion, farmingDepletion);
}
// ------------------------------------------------------------------
private static double clamp(double v) {
return Math.min(MAX, Math.max(MIN, v));
}
@Override
public String toString() {
return "ResourceRegionData{"
+ "mining=" + miningDepletion
+ ", logging=" + loggingDepletion
+ ", farming=" + farmingDepletion + "}";
}
}
@@ -0,0 +1,147 @@
package com.livingworld.modules.soil;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleMetadata;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionMetrics;
import java.util.List;
/**
* Simulates soil health dynamics and writes {@link RegionMetrics#getSoilQuality()}.
*
* <p>This module runs after {@link com.livingworld.modules.pollution.PollutionModule}
* so it reads the current-tick pollution score when deciding contamination accumulation.
*
* <h3>Per-tick rules</h3>
* <ol>
* <li>High pollution causes contamination to accumulate in the soil.
* <li>Contamination steadily degrades fertility.
* <li>Good vegetation cover promotes fertility recovery and resists erosion.
* <li>Low fertility with low vegetation accelerates erosion.
* <li>Soil quality is computed as a weighted score of fertility, contamination, and erosion.
* </ol>
*/
public final class SoilModule implements SimulationModule {
public static final String MODULE_ID = "soil";
/** Pollution score above which contamination begins accumulating per tick. */
private static final double POLLUTION_CONTAMINATION_THRESHOLD = 10.0;
/** Fraction of excess pollution score that becomes contamination per tick. */
private static final double POLLUTION_TO_CONTAMINATION_RATE = 0.003;
/** Fertility reduction per unit of contamination per tick. */
private static final double CONTAMINATION_FERTILITY_DRAIN = 0.005;
/** Vegetation pressure threshold for fertility recovery to kick in. */
private static final double VEGETATION_RECOVERY_THRESHOLD = 40.0;
/** Fertility gained per unit of excess vegetation pressure per tick. */
private static final double VEGETATION_RECOVERY_RATE = 0.002;
/** Fertility level below which erosion increases. */
private static final double EROSION_FERTILITY_THRESHOLD = 30.0;
/** Erosion increase per tick when fertility is low. */
private static final double EROSION_INCREASE_RATE = 0.05;
/** Erosion reduction per tick when vegetation is good. */
private static final double EROSION_VEGETATION_RESISTANCE = 0.03;
private static final double CHANGE_THRESHOLD = 0.01;
private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID,
"Soil",
"1.0.0",
"Simulates soil fertility, contamination, and erosion dynamics.",
"1",
List.of("pollution"),
List.of(),
true,
true,
false);
@Override
public String getModuleId() { return MODULE_ID; }
@Override
public ModuleMetadata getMetadata() { return METADATA; }
@Override
public void initialize(ModuleContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
}
@Override
public void onServerStarted(ServerContext context) {}
@Override
public void createDefaultRegionData(Region region) {
if (region == null) throw new IllegalArgumentException("region must not be null");
if (!region.getModuleData().contains(MODULE_ID)) {
region.getModuleData().put(MODULE_ID, SoilRegionData.defaults());
}
}
@Override
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
Region region = context.getRegion();
SoilRegionData data = region.getModuleData()
.get(MODULE_ID, SoilRegionData.class)
.orElseGet(SoilRegionData::defaults);
RegionMetrics metrics = region.getMetrics();
double prevSoilQuality = metrics.getSoilQuality();
// Pollution causes contamination to build up in the soil.
if (metrics.getPollutionScore() > POLLUTION_CONTAMINATION_THRESHOLD) {
double excess = metrics.getPollutionScore() - POLLUTION_CONTAMINATION_THRESHOLD;
data.setContamination(data.getContamination() + excess * POLLUTION_TO_CONTAMINATION_RATE);
}
// Contamination steadily drains fertility.
data.setFertility(data.getFertility()
- data.getContamination() * CONTAMINATION_FERTILITY_DRAIN);
// Good vegetation cover promotes fertility recovery.
if (metrics.getVegetationPressure() > VEGETATION_RECOVERY_THRESHOLD) {
double excess = metrics.getVegetationPressure() - VEGETATION_RECOVERY_THRESHOLD;
data.setFertility(data.getFertility() + excess * VEGETATION_RECOVERY_RATE);
}
// Low fertility accelerates erosion; good vegetation cover slows it.
if (data.getFertility() < EROSION_FERTILITY_THRESHOLD) {
data.setErosion(data.getErosion() + EROSION_INCREASE_RATE);
}
if (metrics.getVegetationPressure() > VEGETATION_RECOVERY_THRESHOLD) {
data.setErosion(data.getErosion() - EROSION_VEGETATION_RESISTANCE);
}
// Soil quality: fertility is the main driver, penalised by contamination and erosion.
double soilQuality = Math.max(0.0, Math.min(100.0,
data.getFertility()
- data.getContamination() * 0.40
- data.getErosion() * 0.30));
metrics.setSoilQuality(soilQuality);
region.getModuleData().put(MODULE_ID, data);
boolean changed = Math.abs(metrics.getSoilQuality() - prevSoilQuality) > CHANGE_THRESHOLD;
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
}
@Override
public void onLivingWorldEvent(LivingWorldEvent event) {}
@Override
public void saveModuleData(PersistenceWriter writer) {}
@Override
public void loadModuleData(PersistenceReader reader) {}
@Override
public void shutdown() {}
}
@@ -0,0 +1,123 @@
package com.livingworld.modules.soil;
/**
* Per-region soil state tracked by {@link SoilModule}.
*
* <p>Five interacting values describe the physical and chemical condition of the
* land in a region. All values are clamped to [0, 100].
*
* <ul>
* <li><b>fertility</b> capacity to support plant growth (higher is better)
* <li><b>moisture</b> water content in soil (too low or too high is harmful)
* <li><b>contamination</b> chemical or biological pollution absorbed by soil
* <li><b>compaction</b> density of soil; high compaction resists root growth
* <li><b>erosion</b> structural loss of topsoil (higher is worse)
* </ul>
*/
public final class SoilRegionData {
private static final double MIN = 0.0;
private static final double MAX = 100.0;
private double fertility;
private double moisture;
private double contamination;
private double compaction;
private double erosion;
public SoilRegionData(
double fertility,
double moisture,
double contamination,
double compaction,
double erosion) {
this.fertility = clamp(fertility);
this.moisture = clamp(moisture);
this.contamination = clamp(contamination);
this.compaction = clamp(compaction);
this.erosion = clamp(erosion);
}
/** Returns a healthy default soil profile. */
public static SoilRegionData defaults() {
return new SoilRegionData(60.0, 50.0, 0.0, 10.0, 0.0);
}
// ------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------
public double getFertility() { return fertility; }
public double getMoisture() { return moisture; }
public double getContamination() { return contamination; }
public double getCompaction() { return compaction; }
public double getErosion() { return erosion; }
// ------------------------------------------------------------------
// Mutation
// ------------------------------------------------------------------
/**
* Degrades soil by the given amount.
*
* <p>Reduces fertility and increases contamination proportionally.
*
* @param amount positive degradation magnitude
*/
public void degrade(double amount) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
fertility = clamp(fertility - amount);
contamination = clamp(contamination + amount * 0.5);
erosion = clamp(erosion + amount * 0.3);
}
/**
* Applies ecological recovery by the given amount.
*
* <p>Increases fertility and reduces contamination and erosion.
*
* @param amount positive recovery magnitude
*/
public void recover(double amount) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
fertility = clamp(fertility + amount);
contamination = clamp(contamination - amount * 0.4);
erosion = clamp(erosion - amount * 0.3);
}
public void setFertility(double v) { fertility = clamp(v); }
public void setMoisture(double v) { moisture = clamp(v); }
public void setContamination(double v) { contamination = clamp(v); }
public void setCompaction(double v) { compaction = clamp(v); }
public void setErosion(double v) { erosion = clamp(v); }
/** Clamps all fields to [0, 100]. */
public void normalize() {
fertility = clamp(fertility);
moisture = clamp(moisture);
contamination = clamp(contamination);
compaction = clamp(compaction);
erosion = clamp(erosion);
}
/** Returns an independent copy. */
public SoilRegionData copy() {
return new SoilRegionData(fertility, moisture, contamination, compaction, erosion);
}
// ------------------------------------------------------------------
private static double clamp(double v) {
return Math.min(MAX, Math.max(MIN, v));
}
@Override
public String toString() {
return "SoilRegionData{"
+ "fertility=" + fertility
+ ", moisture=" + moisture
+ ", contamination=" + contamination
+ ", compaction=" + compaction
+ ", erosion=" + erosion + "}";
}
}
@@ -0,0 +1,168 @@
package com.livingworld.modules.vegetation;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleMetadata;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionMetrics;
import java.util.List;
/**
* Simulates vegetation succession and die-off, writing
* {@link RegionMetrics#getVegetationPressure()}.
*
* <p>This module runs after {@link com.livingworld.modules.soil.SoilModule} and
* {@link com.livingworld.modules.pollution.PollutionModule} so it reads
* current-tick soil quality and pollution.
*
* <h3>Succession model</h3>
* When soil quality is adequate and pollution is low, vegetation follows a
* succession path: grass → flowers → shrubs → trees. Each tier can only grow
* once the tier below it reaches a critical threshold.
*
* <h3>Die-off model</h3>
* High pollution or very low soil quality kills vegetation in the reverse order
* (trees → shrubs → grass) and increases dead organic material.
*
* <h3>Dead vegetation</h3>
* Dead matter decomposes slowly each tick, eventually recycling into soil
* nutrients (modelled implicitly via the soil module's contamination dynamics).
*/
public final class VegetationModule implements SimulationModule {
public static final String MODULE_ID = "vegetation";
// --- growth thresholds ---
private static final double GROWTH_SOIL_THRESHOLD = 35.0;
private static final double GROWTH_POLLUTION_LIMIT = 40.0;
private static final double SHRUB_UNLOCK_GRASS = 50.0;
private static final double TREE_UNLOCK_SHRUB = 40.0;
// --- growth rates per tick ---
private static final double GRASS_GROWTH_RATE = 0.015;
private static final double FLOWER_GROWTH_RATE = 0.008;
private static final double SHRUB_GROWTH_RATE = 0.005;
private static final double TREE_GROWTH_RATE = 0.002;
// --- die-off thresholds ---
private static final double DIEOFF_SOIL_THRESHOLD = 20.0;
private static final double DIEOFF_POLLUTION_THRESHOLD = 30.0;
private static final double GRASS_DIEOFF_RATE = 0.30;
private static final double DEAD_ACCUMULATION_RATE = 0.20;
// --- decomposition ---
private static final double DEAD_DECOMPOSITION_RATE = 0.01;
private static final double CHANGE_THRESHOLD = 0.01;
private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID,
"Vegetation",
"1.0.0",
"Simulates vegetation succession, growth, and die-off.",
"1",
List.of("pollution", "soil"),
List.of(),
true,
true,
false);
@Override
public String getModuleId() { return MODULE_ID; }
@Override
public ModuleMetadata getMetadata() { return METADATA; }
@Override
public void initialize(ModuleContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
}
@Override
public void onServerStarted(ServerContext context) {}
@Override
public void createDefaultRegionData(Region region) {
if (region == null) throw new IllegalArgumentException("region must not be null");
if (!region.getModuleData().contains(MODULE_ID)) {
region.getModuleData().put(MODULE_ID, VegetationRegionData.defaults());
}
}
@Override
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
Region region = context.getRegion();
VegetationRegionData data = region.getModuleData()
.get(MODULE_ID, VegetationRegionData.class)
.orElseGet(VegetationRegionData::defaults);
RegionMetrics metrics = region.getMetrics();
double prevVegetationPressure = metrics.getVegetationPressure();
boolean goodConditions = metrics.getSoilQuality() > GROWTH_SOIL_THRESHOLD
&& metrics.getPollutionScore() < GROWTH_POLLUTION_LIMIT;
boolean badConditions = metrics.getSoilQuality() < DIEOFF_SOIL_THRESHOLD
|| metrics.getPollutionScore() > DIEOFF_POLLUTION_THRESHOLD;
if (goodConditions) {
double soilBonus = metrics.getSoilQuality() - GROWTH_SOIL_THRESHOLD;
data.setGrassPressure(data.getGrassPressure() + soilBonus * GRASS_GROWTH_RATE);
data.setFlowerPressure(data.getFlowerPressure() + soilBonus * FLOWER_GROWTH_RATE);
// Shrubs grow only once grass is established.
if (data.getGrassPressure() > SHRUB_UNLOCK_GRASS) {
data.setShrubPressure(data.getShrubPressure() + soilBonus * SHRUB_GROWTH_RATE);
}
// Trees grow only once shrubs are established.
if (data.getShrubPressure() > TREE_UNLOCK_SHRUB) {
data.setTreePressure(data.getTreePressure() + soilBonus * TREE_GROWTH_RATE);
}
}
if (badConditions) {
data.setGrassPressure(data.getGrassPressure() - GRASS_DIEOFF_RATE);
data.setDeadVegetation(data.getDeadVegetation() + DEAD_ACCUMULATION_RATE);
}
// Dead vegetation decomposes slowly each tick.
data.setDeadVegetation(data.getDeadVegetation() * (1.0 - DEAD_DECOMPOSITION_RATE));
// Vegetation pressure summary: living tiers weighted by ecological significance,
// penalised by dead material burden.
double vegetationPressure = Math.max(0.0, Math.min(100.0,
data.getGrassPressure() * 0.35
+ data.getFlowerPressure() * 0.10
+ data.getShrubPressure() * 0.25
+ data.getTreePressure() * 0.25
- data.getDeadVegetation() * 0.20));
metrics.setVegetationPressure(vegetationPressure);
region.getModuleData().put(MODULE_ID, data);
boolean changed = Math.abs(metrics.getVegetationPressure() - prevVegetationPressure)
> CHANGE_THRESHOLD;
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
}
@Override
public void onLivingWorldEvent(LivingWorldEvent event) {}
@Override
public void saveModuleData(PersistenceWriter writer) {}
@Override
public void loadModuleData(PersistenceReader reader) {}
@Override
public void shutdown() {}
}
@@ -0,0 +1,130 @@
package com.livingworld.modules.vegetation;
/**
* Per-region vegetation state tracked by {@link VegetationModule}.
*
* <p>Five values capture the biomass distribution and health of plant cover in a
* region. All values are clamped to [0, 100].
*
* <ul>
* <li><b>grassPressure</b> density and health of grass-layer plants
* <li><b>flowerPressure</b> density and health of flowering plants
* <li><b>shrubPressure</b> density and health of shrubs / undergrowth
* <li><b>treePressure</b> density and health of tree canopy
* <li><b>deadVegetation</b> accumulated dead organic material (higher = more decay burden)
* </ul>
*
* <p>Vegetation succession naturally flows from grass → flowers → shrubs → trees
* when conditions allow. Damage reverses this sequence and raises deadVegetation.
*/
public final class VegetationRegionData {
private static final double MIN = 0.0;
private static final double MAX = 100.0;
private double grassPressure;
private double flowerPressure;
private double shrubPressure;
private double treePressure;
private double deadVegetation;
public VegetationRegionData(
double grassPressure,
double flowerPressure,
double shrubPressure,
double treePressure,
double deadVegetation) {
this.grassPressure = clamp(grassPressure);
this.flowerPressure = clamp(flowerPressure);
this.shrubPressure = clamp(shrubPressure);
this.treePressure = clamp(treePressure);
this.deadVegetation = clamp(deadVegetation);
}
/** Returns a default mixed-vegetation profile. */
public static VegetationRegionData defaults() {
return new VegetationRegionData(50.0, 30.0, 30.0, 40.0, 5.0);
}
// ------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------
public double getGrassPressure() { return grassPressure; }
public double getFlowerPressure() { return flowerPressure; }
public double getShrubPressure() { return shrubPressure; }
public double getTreePressure() { return treePressure; }
public double getDeadVegetation() { return deadVegetation; }
// ------------------------------------------------------------------
// Mutation
// ------------------------------------------------------------------
/**
* Simulates the impact of logging or clear-cutting by the given amount.
*
* <p>Reduces tree and shrub pressure while increasing dead vegetation
* proportionally.
*
* @param amount positive logging damage magnitude
*/
public void reduceFromLogging(double amount) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
treePressure = clamp(treePressure - amount);
shrubPressure = clamp(shrubPressure - amount * 0.5);
deadVegetation = clamp(deadVegetation + amount * 0.8);
}
/**
* Applies ecological recovery across all vegetation layers by the given amount.
*
* <p>Increases all living pressures modestly and reduces dead vegetation.
*
* @param amount positive recovery magnitude
*/
public void recover(double amount) {
if (amount < 0) throw new IllegalArgumentException("amount must be >= 0");
grassPressure = clamp(grassPressure + amount);
flowerPressure = clamp(flowerPressure + amount * 0.7);
shrubPressure = clamp(shrubPressure + amount * 0.5);
treePressure = clamp(treePressure + amount * 0.3);
deadVegetation = clamp(deadVegetation - amount * 0.6);
}
public void setGrassPressure(double v) { grassPressure = clamp(v); }
public void setFlowerPressure(double v) { flowerPressure = clamp(v); }
public void setShrubPressure(double v) { shrubPressure = clamp(v); }
public void setTreePressure(double v) { treePressure = clamp(v); }
public void setDeadVegetation(double v) { deadVegetation = clamp(v); }
/** Clamps all fields to [0, 100]. */
public void normalize() {
grassPressure = clamp(grassPressure);
flowerPressure = clamp(flowerPressure);
shrubPressure = clamp(shrubPressure);
treePressure = clamp(treePressure);
deadVegetation = clamp(deadVegetation);
}
/** Returns an independent copy. */
public VegetationRegionData copy() {
return new VegetationRegionData(
grassPressure, flowerPressure, shrubPressure, treePressure, deadVegetation);
}
// ------------------------------------------------------------------
private static double clamp(double v) {
return Math.min(MAX, Math.max(MIN, v));
}
@Override
public String toString() {
return "VegetationRegionData{"
+ "grass=" + grassPressure
+ ", flower=" + flowerPressure
+ ", shrub=" + shrubPressure
+ ", tree=" + treePressure
+ ", dead=" + deadVegetation + "}";
}
}
@@ -0,0 +1,128 @@
package com.livingworld.modules.water;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleMetadata;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionMetrics;
import java.util.List;
/**
* Refines {@link RegionMetrics#getWaterQuality()} after the pollution module has
* applied its raw damage, adding vegetation-driven purification and soil-driven
* contamination leaching.
*
* <p>This module runs <em>after</em>
* {@link com.livingworld.modules.pollution.PollutionModule} and
* {@link com.livingworld.modules.soil.SoilModule} in the update pipeline so
* it reads current-tick values for both pollutionScore and soilQuality.
*
* <h3>Per-tick rules</h3>
* <ol>
* <li>Purification capacity is derived from current vegetation pressure
* (plants filter water).
* <li>Low soil quality allows contamination to leach into groundwater,
* reducing water quality.
* <li>Purification capacity partially restores water quality each tick.
* <li>Water availability drifts toward a baseline unless stressed by
* drought or flood conditions (V1 placeholder).
* </ol>
*/
public final class WaterModule implements SimulationModule {
public static final String MODULE_ID = "water";
/** Vegetation pressure drives this fraction of purification capacity. */
private static final double VEG_TO_PURIFICATION_FACTOR = 0.50;
/** Each point of soil quality below 40 leaches this much water quality per tick. */
private static final double SOIL_LEACH_RATE = 0.005;
/** Soil quality threshold below which contamination leaches into water. */
private static final double SOIL_LEACH_THRESHOLD = 40.0;
/** Fraction of purification capacity applied to water quality per tick. */
private static final double PURIFICATION_RATE = 0.01;
private static final double CHANGE_THRESHOLD = 0.01;
private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID,
"Water Quality",
"1.0.0",
"Refines water quality with vegetation purification and soil contamination leaching.",
"1",
List.of("pollution", "soil"),
List.of("vegetation"),
true,
true,
false);
@Override
public String getModuleId() { return MODULE_ID; }
@Override
public ModuleMetadata getMetadata() { return METADATA; }
@Override
public void initialize(ModuleContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
}
@Override
public void onServerStarted(ServerContext context) {}
@Override
public void createDefaultRegionData(Region region) {
if (region == null) throw new IllegalArgumentException("region must not be null");
if (!region.getModuleData().contains(MODULE_ID)) {
region.getModuleData().put(MODULE_ID, WaterRegionData.defaults());
}
}
@Override
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
Region region = context.getRegion();
WaterRegionData data = region.getModuleData()
.get(MODULE_ID, WaterRegionData.class)
.orElseGet(WaterRegionData::defaults);
RegionMetrics metrics = region.getMetrics();
double prevWaterQuality = metrics.getWaterQuality();
// Purification capacity is driven by vegetation cover.
double purification = metrics.getVegetationPressure() * VEG_TO_PURIFICATION_FACTOR;
data.setPurificationCapacity(purification);
// Low soil quality allows contamination to leach into groundwater.
if (metrics.getSoilQuality() < SOIL_LEACH_THRESHOLD) {
double leach = (SOIL_LEACH_THRESHOLD - metrics.getSoilQuality()) * SOIL_LEACH_RATE;
metrics.setWaterQuality(Math.max(0.0, metrics.getWaterQuality() - leach));
}
// Vegetation purification partially restores water quality.
double recovery = purification * PURIFICATION_RATE;
metrics.setWaterQuality(Math.min(100.0, metrics.getWaterQuality() + recovery));
region.getModuleData().put(MODULE_ID, data);
boolean changed = Math.abs(metrics.getWaterQuality() - prevWaterQuality) > CHANGE_THRESHOLD;
return changed ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
}
@Override
public void onLivingWorldEvent(LivingWorldEvent event) {}
@Override
public void saveModuleData(PersistenceWriter writer) {}
@Override
public void loadModuleData(PersistenceReader reader) {}
@Override
public void shutdown() {}
}
@@ -0,0 +1,87 @@
package com.livingworld.modules.water;
/**
* Per-region water state tracked by {@link WaterModule}.
*
* <p>All values are clamped to [0, 100].
*
* <ul>
* <li><b>waterAvailability</b> how much fresh water exists in this region
* <li><b>purificationCapacity</b> ecosystem's ability to filter water;
* driven by vegetation cover
* <li><b>droughtRisk</b> likelihood of water shortage conditions
* <li><b>floodRisk</b> likelihood of waterlogging or runoff damage
* </ul>
*/
public final class WaterRegionData {
private static final double MIN = 0.0;
private static final double MAX = 100.0;
private double waterAvailability;
private double purificationCapacity;
private double droughtRisk;
private double floodRisk;
public WaterRegionData(
double waterAvailability,
double purificationCapacity,
double droughtRisk,
double floodRisk) {
this.waterAvailability = clamp(waterAvailability);
this.purificationCapacity = clamp(purificationCapacity);
this.droughtRisk = clamp(droughtRisk);
this.floodRisk = clamp(floodRisk);
}
/** Returns a healthy default water profile. */
public static WaterRegionData defaults() {
return new WaterRegionData(60.0, 50.0, 10.0, 10.0);
}
// ------------------------------------------------------------------
// Getters
// ------------------------------------------------------------------
public double getWaterAvailability() { return waterAvailability; }
public double getPurificationCapacity() { return purificationCapacity; }
public double getDroughtRisk() { return droughtRisk; }
public double getFloodRisk() { return floodRisk; }
// ------------------------------------------------------------------
// Setters (clamp on write)
// ------------------------------------------------------------------
public void setWaterAvailability(double v) { waterAvailability = clamp(v); }
public void setPurificationCapacity(double v) { purificationCapacity = clamp(v); }
public void setDroughtRisk(double v) { droughtRisk = clamp(v); }
public void setFloodRisk(double v) { floodRisk = clamp(v); }
/** Clamps all fields to [0, 100]. */
public void normalize() {
waterAvailability = clamp(waterAvailability);
purificationCapacity = clamp(purificationCapacity);
droughtRisk = clamp(droughtRisk);
floodRisk = clamp(floodRisk);
}
/** Returns an independent copy. */
public WaterRegionData copy() {
return new WaterRegionData(waterAvailability, purificationCapacity, droughtRisk, floodRisk);
}
// ------------------------------------------------------------------
private static double clamp(double v) {
return Math.min(MAX, Math.max(MIN, v));
}
@Override
public String toString() {
return "WaterRegionData{"
+ "availability=" + waterAvailability
+ ", purification=" + purificationCapacity
+ ", drought=" + droughtRisk
+ ", flood=" + floodRisk + "}";
}
}
@@ -0,0 +1,18 @@
package com.livingworld.modules.worldeffects;
/**
* Receives {@link WorldEffectRequest}s generated by {@link WorldEffectsModule}
* and applies the corresponding changes to the world.
*
* <p>The NeoForge platform adapter registers itself as a consumer during bootstrap.
* Tests register a capturing consumer to verify which effects were requested.
*/
@FunctionalInterface
public interface WorldEffectConsumer {
/** Called when the ecosystem simulation wants a visible world change applied. */
void consume(WorldEffectRequest request);
/** A no-op consumer used when no platform adapter is registered. */
WorldEffectConsumer NO_OP = request -> {};
}
@@ -0,0 +1,30 @@
package com.livingworld.modules.worldeffects;
import com.livingworld.regions.RegionCoordinate;
/**
* An immutable request for a visible world change in a specific region.
*
* <p>Generated by {@link WorldEffectsModule} and delivered to registered
* {@link WorldEffectConsumer}s. The platform layer (NeoForge adapter) is
* responsible for translating the request into actual block operations on
* loaded chunks.
*
* @param type the category of effect to apply
* @param region the region in which the effect should be applied
* @param intensity how strongly to apply the effect (0.0 = minimal, 1.0 = full)
*/
public record WorldEffectRequest(
WorldEffectType type,
RegionCoordinate region,
double intensity) {
public WorldEffectRequest {
if (type == null) throw new IllegalArgumentException("type must not be null");
if (region == null) throw new IllegalArgumentException("region must not be null");
if (intensity < 0.0 || intensity > 1.0) {
throw new IllegalArgumentException(
"intensity must be in [0.0, 1.0], got: " + intensity);
}
}
}
@@ -0,0 +1,42 @@
package com.livingworld.modules.worldeffects;
/**
* Categories of visible world changes that the ecosystem simulation can request.
*
* <p>These are abstract descriptions of what should happen. The platform adapter
* (NeoForge layer) translates each type into concrete block changes or entity
* interactions on loaded chunks.
*/
public enum WorldEffectType {
/**
* High pollution combined with low soil quality causes grass blocks to
* degrade into dirt or coarse dirt.
*/
GRASS_DEGRADES_TO_DIRT,
/**
* Healthy soil and good vegetation pressure allows grass, moss, and flowers
* to spread onto adjacent bare blocks.
*/
VEGETATION_SPREADS,
/**
* Sustained logging depletion slows the chance of saplings spawning from
* leaf decay and reduces natural sapling growth rate.
*/
SAPLING_GROWTH_SLOWED,
/**
* A well-recovered region at an advanced succession stage boosts the
* chance of saplings appearing and growing.
*/
SAPLING_GROWTH_BOOSTED,
/**
* A visual tint or overlay indicator applied to blocks in heavily polluted
* regions (e.g. discoloured water, dark soil). Implementation details are
* left to the platform layer.
*/
POLLUTION_VISUAL_INDICATOR,
}
@@ -0,0 +1,210 @@
package com.livingworld.modules.worldeffects;
import com.livingworld.data.serialization.PersistenceReader;
import com.livingworld.data.serialization.PersistenceWriter;
import com.livingworld.events.LivingWorldEvent;
import com.livingworld.modules.ModuleContext;
import com.livingworld.modules.ModuleMetadata;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.ServerContext;
import com.livingworld.modules.SimulationModule;
import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.recovery.RecoveryModule;
import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.modules.resources.ResourceDepletionModule;
import com.livingworld.modules.resources.ResourceRegionData;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionMetrics;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Translates ecosystem simulation state into visible world change requests.
*
* <p>This module runs <em>last</em> in the pipeline. It reads the fully-updated
* state of all other modules and emits {@link WorldEffectRequest}s to registered
* {@link WorldEffectConsumer}s. The consumers (typically the NeoForge platform
* adapter) apply the requests as actual block changes on loaded chunks.
*
* <p>This module contains no Minecraft imports. The platform boundary lives
* entirely within the consumer implementations.
*
* <h3>Visible effects generated</h3>
* <ol>
* <li>{@link WorldEffectType#GRASS_DEGRADES_TO_DIRT} — when pollutionScore &gt; 60
* and soilQuality &lt; 30.
* <li>{@link WorldEffectType#VEGETATION_SPREADS} — when vegetationPressure &gt; 60
* and soilQuality &gt; 50.
* <li>{@link WorldEffectType#SAPLING_GROWTH_SLOWED} — when logging depletion
* exceeds 50 % (read from {@link ResourceRegionData}).
* <li>{@link WorldEffectType#SAPLING_GROWTH_BOOSTED} — when the succession stage
* is {@link SuccessionStage#YOUNG_WOODLAND} or higher.
* <li>{@link WorldEffectType#POLLUTION_VISUAL_INDICATOR} — when pollutionScore &gt; 70.
* </ol>
*
* <h3>Registering consumers</h3>
* Call {@link #registerConsumer(WorldEffectConsumer)} before the simulation starts.
* Multiple consumers may be registered. If no consumer is registered the module
* operates silently as a no-op.
*/
public final class WorldEffectsModule implements SimulationModule {
public static final String MODULE_ID = "worldeffects";
// Thresholds that trigger effect requests.
// Grass degradation: pollution score > 15 AND soil quality below the gate.
// Soil defaults to 60 so the gate is set at 75 to avoid blocking early effects.
private static final double GRASS_DEGRADE_POLLUTION_MIN = 15.0;
private static final double GRASS_DEGRADE_SOIL_MAX = 75.0;
private static final double VEG_SPREAD_VEG_MIN = 60.0;
private static final double VEG_SPREAD_SOIL_MIN = 50.0;
private static final double SAPLING_SLOW_LOGGING_MIN = 50.0;
// Smoke particles appear as soon as any meaningful pollution exists.
private static final double POLLUTION_INDICATOR_MIN = 10.0;
private static final ModuleMetadata METADATA = new ModuleMetadata(
MODULE_ID,
"World Effects",
"1.0.0",
"Translates ecosystem state into visible block-change requests for the platform layer.",
"1",
List.of("pollution", "soil", "vegetation", "resources", "recovery"),
List.of(),
true,
true,
false);
private final List<WorldEffectConsumer> consumers = new ArrayList<>();
/**
* Registers a consumer that will receive effect requests each simulation tick.
*
* @param consumer the consumer to register (must not be null)
*/
public void registerConsumer(WorldEffectConsumer consumer) {
if (consumer == null) throw new IllegalArgumentException("consumer must not be null");
consumers.add(consumer);
}
/** Returns an unmodifiable view of all registered consumers. */
public List<WorldEffectConsumer> getConsumers() {
return Collections.unmodifiableList(consumers);
}
@Override
public String getModuleId() { return MODULE_ID; }
@Override
public ModuleMetadata getMetadata() { return METADATA; }
@Override
public void initialize(ModuleContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
}
@Override
public void onServerStarted(ServerContext context) {}
@Override
public void createDefaultRegionData(Region region) {
// No per-region data; this module only reads state, it does not persist any.
}
@Override
public ModuleUpdateResult updateRegion(RegionUpdateContext context) {
if (context == null) throw new IllegalArgumentException("context must not be null");
if (consumers.isEmpty()) return ModuleUpdateResult.noChange();
Region region = context.getRegion();
RegionMetrics m = region.getMetrics();
boolean emitted = false;
// --- Effect 1: grass degrades when pollution is high and soil is poor ---
if (m.getPollutionScore() > GRASS_DEGRADE_POLLUTION_MIN
&& m.getSoilQuality() < GRASS_DEGRADE_SOIL_MAX) {
double intensity = computeIntensity(
m.getPollutionScore() - GRASS_DEGRADE_POLLUTION_MIN, 40.0)
* computeIntensity(GRASS_DEGRADE_SOIL_MAX - m.getSoilQuality(), 30.0);
emit(new WorldEffectRequest(
WorldEffectType.GRASS_DEGRADES_TO_DIRT, region.getCoordinate(), intensity));
emitted = true;
}
// --- Effect 2: vegetation spreads when soil and vegetation pressure are healthy ---
if (m.getVegetationPressure() > VEG_SPREAD_VEG_MIN
&& m.getSoilQuality() > VEG_SPREAD_SOIL_MIN) {
double intensity = computeIntensity(
m.getVegetationPressure() - VEG_SPREAD_VEG_MIN, 40.0);
emit(new WorldEffectRequest(
WorldEffectType.VEGETATION_SPREADS, region.getCoordinate(), intensity));
emitted = true;
}
// --- Effect 3: logging depletion slows sapling growth ---
ResourceRegionData resources = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class)
.orElse(null);
if (resources != null && resources.getLoggingDepletion() > SAPLING_SLOW_LOGGING_MIN) {
double intensity = computeIntensity(
resources.getLoggingDepletion() - SAPLING_SLOW_LOGGING_MIN, 50.0);
emit(new WorldEffectRequest(
WorldEffectType.SAPLING_GROWTH_SLOWED, region.getCoordinate(), intensity));
emitted = true;
}
// --- Effect 4: advanced succession boosts sapling growth ---
RecoveryRegionData recovery = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class)
.orElse(null);
if (recovery != null
&& recovery.getSuccessionStage().ordinal()
>= SuccessionStage.YOUNG_WOODLAND.ordinal()) {
double intensity = computeIntensity(
recovery.getSuccessionStage().ordinal()
- SuccessionStage.YOUNG_WOODLAND.ordinal() + 1.0, 2.0);
emit(new WorldEffectRequest(
WorldEffectType.SAPLING_GROWTH_BOOSTED, region.getCoordinate(), intensity));
emitted = true;
}
// --- Effect 5: pollution visual indicator ---
if (m.getPollutionScore() > POLLUTION_INDICATOR_MIN) {
double intensity = computeIntensity(
m.getPollutionScore() - POLLUTION_INDICATOR_MIN, 30.0);
emit(new WorldEffectRequest(
WorldEffectType.POLLUTION_VISUAL_INDICATOR, region.getCoordinate(), intensity));
emitted = true;
}
return emitted ? ModuleUpdateResult.changed() : ModuleUpdateResult.noChange();
}
@Override
public void onLivingWorldEvent(LivingWorldEvent event) {}
@Override
public void saveModuleData(PersistenceWriter writer) {}
@Override
public void loadModuleData(PersistenceReader reader) {}
@Override
public void shutdown() { consumers.clear(); }
// ------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------
/** Computes effect intensity as a fraction of the excess over a range, clamped to [0, 1]. */
private static double computeIntensity(double excess, double range) {
return Math.min(1.0, Math.max(0.0, excess / range));
}
private void emit(WorldEffectRequest request) {
for (WorldEffectConsumer consumer : consumers) {
consumer.consume(request);
}
}
}
@@ -0,0 +1,29 @@
package com.livingworld.platform;
/**
* Platform-neutral description of a block broken by a player.
*
* <p>The NeoForge adapter constructs this from a {@code BlockEvent.BreakEvent}
* and passes it to the bootstrap handler so no Minecraft types cross the
* platform boundary into core simulation code.</p>
*
* @param dimensionId Minecraft dimension key string (e.g. {@code "minecraft:overworld"})
* @param blockX world X coordinate of the broken block
* @param blockZ world Z coordinate of the broken block
* @param blockRegistryName registry name of the broken block (e.g. {@code "minecraft:oak_log"})
*/
public record BlockBreakInfo(
String dimensionId,
int blockX,
int blockZ,
String blockRegistryName) {
public BlockBreakInfo {
if (dimensionId == null || dimensionId.isBlank()) {
throw new IllegalArgumentException("dimensionId must not be null or blank");
}
if (blockRegistryName == null || blockRegistryName.isBlank()) {
throw new IllegalArgumentException("blockRegistryName must not be null or blank");
}
}
}
@@ -1,5 +1,6 @@
package com.livingworld.platform.neoforge; package com.livingworld.platform.neoforge;
import com.livingworld.platform.BlockBreakInfo;
import com.livingworld.platform.PlatformAdapter; import com.livingworld.platform.PlatformAdapter;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import java.nio.file.Path; import java.nio.file.Path;
@@ -8,11 +9,14 @@ import java.util.function.Consumer;
import java.util.function.Supplier; import java.util.function.Supplier;
import net.minecraft.SharedConstants; import net.minecraft.SharedConstants;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.Level;
import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.Dist;
import net.neoforged.fml.ModList; import net.neoforged.fml.ModList;
import net.neoforged.fml.loading.FMLEnvironment; import net.neoforged.fml.loading.FMLEnvironment;
import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.RegisterCommandsEvent; import net.neoforged.neoforge.event.RegisterCommandsEvent;
import net.neoforged.neoforge.event.level.BlockEvent;
import net.neoforged.neoforge.event.tick.ServerTickEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent;
/** /**
@@ -25,17 +29,21 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
private final Supplier<Path> worldSaveDirectory; private final Supplier<Path> worldSaveDirectory;
private final Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar; private final Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar;
private final Runnable serverTickHook; private final Runnable serverTickHook;
private final Consumer<BlockBreakInfo> blockBreakHandler;
private boolean commandsRegistered; private boolean commandsRegistered;
private boolean serverTickRegistered; private boolean serverTickRegistered;
private boolean playerEventsRegistered;
public NeoForgePlatformAdapter( public NeoForgePlatformAdapter(
Supplier<Path> worldSaveDirectory, Supplier<Path> worldSaveDirectory,
Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar, Consumer<CommandDispatcher<CommandSourceStack>> commandRegistrar,
Runnable serverTickHook) { Runnable serverTickHook,
Consumer<BlockBreakInfo> blockBreakHandler) {
this.worldSaveDirectory = this.worldSaveDirectory =
Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory"); Objects.requireNonNull(worldSaveDirectory, "worldSaveDirectory");
this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar"); this.commandRegistrar = Objects.requireNonNull(commandRegistrar, "commandRegistrar");
this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook"); this.serverTickHook = Objects.requireNonNull(serverTickHook, "serverTickHook");
this.blockBreakHandler = Objects.requireNonNull(blockBreakHandler, "blockBreakHandler");
} }
@Override @Override
@@ -92,6 +100,39 @@ public final class NeoForgePlatformAdapter implements PlatformAdapter {
@Override @Override
public void registerPlayerEventHooks() { public void registerPlayerEventHooks() {
// Player activity hooks are intentionally deferred until a module needs them. if (playerEventsRegistered) {
return;
}
NeoForge.EVENT_BUS.addListener(BlockEvent.BreakEvent.class, this::onBlockBroken);
playerEventsRegistered = true;
}
/**
* Translates a NeoForge block-break event into a platform-neutral
* {@link BlockBreakInfo} and forwards it to the core handler.
*
* <p>Only player-caused breaks are forwarded (non-null player). Creative-mode
* breaks are included intentionally so creative players can still trigger
* depletion for testing.</p>
*/
private void onBlockBroken(BlockEvent.BreakEvent event) {
if (event.getPlayer() == null) {
return;
}
if (!(event.getLevel() instanceof Level level)) {
return;
}
String dimensionId = level.dimension().location().toString();
ResourceLocation blockId = event.getState().getBlockHolder()
.unwrapKey()
.map(key -> key.location())
.orElse(null);
if (blockId == null) {
return;
}
int blockX = event.getPos().getX();
int blockZ = event.getPos().getZ();
blockBreakHandler.accept(
new BlockBreakInfo(dimensionId, blockX, blockZ, blockId.toString()));
} }
} }
@@ -0,0 +1,187 @@
package com.livingworld.platform.neoforge;
import com.livingworld.core.LivingWorldConstants;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
import com.livingworld.modules.worldeffects.WorldEffectConsumer;
import com.livingworld.modules.worldeffects.WorldEffectRequest;
import java.util.Random;
import java.util.function.Supplier;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.levelgen.Heightmap;
/**
* Translates {@link WorldEffectRequest}s from the ecosystem simulation into
* concrete Minecraft block operations and particle effects.
*
* <p>Called on the server tick thread. Block writes are guarded by a
* {@link ServerLevel#isLoaded} check to avoid force-loading chunks.</p>
*/
public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
private static final int REGION_BLOCKS =
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16;
private static final int BLOCK_ATTEMPTS = 20;
private static final int MIN_GRASS_LIGHT = 9;
private final Supplier<MinecraftServer> serverSupplier;
private final Random random = new Random();
public NeoForgeWorldEffectExecutor(Supplier<MinecraftServer> serverSupplier) {
this.serverSupplier = serverSupplier;
}
@Override
public void consume(WorldEffectRequest request) {
MinecraftServer server = serverSupplier.get();
if (server == null) {
return;
}
ResourceKey<Level> dimensionKey = ResourceKey.create(
Registries.DIMENSION,
ResourceLocation.parse(request.region().dimensionId()));
ServerLevel level = server.getLevel(dimensionKey);
if (level == null) {
return;
}
int baseX = request.region().x() * REGION_BLOCKS;
int baseZ = request.region().z() * REGION_BLOCKS;
switch (request.type()) {
case GRASS_DEGRADES_TO_DIRT ->
degradeGrass(level, baseX, baseZ, request.intensity());
case VEGETATION_SPREADS ->
spreadVegetation(level, baseX, baseZ, request.intensity());
case POLLUTION_VISUAL_INDICATOR ->
spawnPollutionParticles(level, baseX, baseZ, request.intensity());
case SAPLING_GROWTH_BOOSTED ->
placeSaplings(level, baseX, baseZ, request.intensity());
case SAPLING_GROWTH_SLOWED -> {} // requires mixin; deferred
}
}
private void degradeGrass(ServerLevel level, int baseX, int baseZ, double intensity) {
int attempts = Math.max(1, (int) (intensity * BLOCK_ATTEMPTS));
for (int i = 0; i < attempts; i++) {
BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS),
baseZ + random.nextInt(REGION_BLOCKS));
if (pos == null) continue;
BlockPos above = pos.above();
// Clear plants sitting on top of the block first
if (level.isLoaded(above)) {
var aboveState = level.getBlockState(above);
if (aboveState.is(Blocks.SHORT_GRASS) || aboveState.is(Blocks.TALL_GRASS)
|| aboveState.is(Blocks.DANDELION) || aboveState.is(Blocks.POPPY)
|| aboveState.is(Blocks.OAK_SAPLING)) {
level.setBlock(above, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
}
var state = level.getBlockState(pos);
if (state.is(Blocks.GRASS_BLOCK)) {
level.setBlock(pos, Blocks.DIRT.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"WorldEffect GRASS_DEGRADES_TO_DIRT at " + pos);
} else if (state.is(Blocks.DIRT) && intensity > 0.5) {
level.setBlock(pos, Blocks.COARSE_DIRT.defaultBlockState(), Block.UPDATE_ALL);
}
}
}
private void spreadVegetation(ServerLevel level, int baseX, int baseZ, double intensity) {
int attempts = Math.max(1, (int) (intensity * BLOCK_ATTEMPTS));
for (int i = 0; i < attempts; i++) {
BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS),
baseZ + random.nextInt(REGION_BLOCKS));
if (pos == null) continue;
BlockPos above = pos.above();
boolean aboveAir = level.isLoaded(above) && level.getBlockState(above).isAir();
boolean brightEnough = aboveAir && level.getRawBrightness(above, 0) >= MIN_GRASS_LIGHT;
var state = level.getBlockState(pos);
if (state.is(Blocks.DIRT) && brightEnough) {
level.setBlock(pos, Blocks.GRASS_BLOCK.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"WorldEffect VEGETATION_SPREADS at " + pos);
} else if (state.is(Blocks.GRASS_BLOCK) && brightEnough) {
// Add surface plants — flowers 1-in-5 chance, short grass otherwise
Block plant = random.nextInt(5) == 0
? (random.nextBoolean() ? Blocks.DANDELION : Blocks.POPPY)
: Blocks.SHORT_GRASS;
level.setBlock(above, plant.defaultBlockState(), Block.UPDATE_ALL);
}
}
}
private void placeSaplings(ServerLevel level, int baseX, int baseZ, double intensity) {
int attempts = Math.max(1, (int) (intensity * BLOCK_ATTEMPTS));
for (int i = 0; i < attempts; i++) {
BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS),
baseZ + random.nextInt(REGION_BLOCKS));
if (pos == null) continue;
var state = level.getBlockState(pos);
if (!state.is(Blocks.GRASS_BLOCK) && !state.is(Blocks.DIRT)) continue;
BlockPos above = pos.above();
if (!level.isLoaded(above) || !level.getBlockState(above).isAir()) continue;
Block sapling = pickSapling(intensity);
level.setBlock(above, sapling.defaultBlockState(), Block.UPDATE_ALL);
LivingWorldLogger.info(DiagnosticCategory.SIMULATION,
"WorldEffect SAPLING_GROWTH_BOOSTED (" + sapling + ") at " + pos);
}
}
/**
* Selects a sapling species weighted by succession intensity.
* Low intensity (young woodland, ~0.5): pioneer species — oak and birch dominate.
* High intensity (mature forest, ~1.0): diverse canopy — spruce, dark oak, cherry join in.
*/
private Block pickSapling(double intensity) {
int r = random.nextInt(100);
if (intensity < 0.7) {
// Young woodland: pioneer species only
return r < 55 ? Blocks.OAK_SAPLING : Blocks.BIRCH_SAPLING;
} else if (intensity < 0.9) {
// Developing forest: conifers begin to establish
if (r < 35) return Blocks.OAK_SAPLING;
if (r < 65) return Blocks.BIRCH_SAPLING;
if (r < 85) return Blocks.SPRUCE_SAPLING;
return Blocks.DARK_OAK_SAPLING;
} else {
// Mature forest: full species diversity
if (r < 25) return Blocks.OAK_SAPLING;
if (r < 45) return Blocks.BIRCH_SAPLING;
if (r < 62) return Blocks.SPRUCE_SAPLING;
if (r < 77) return Blocks.DARK_OAK_SAPLING;
if (r < 90) return Blocks.CHERRY_SAPLING;
return Blocks.JUNGLE_SAPLING;
}
}
private void spawnPollutionParticles(
ServerLevel level, int baseX, int baseZ, double intensity) {
int count = Math.max(1, (int) (intensity * 8));
for (int i = 0; i < count; i++) {
double x = baseX + random.nextDouble() * REGION_BLOCKS;
double z = baseZ + random.nextDouble() * REGION_BLOCKS;
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE, (int) x, (int) z);
level.sendParticles(ParticleTypes.SMOKE, x, y + 1.5, z, 3, 0.5, 0.5, 0.5, 0.02);
}
}
private BlockPos surfaceAt(ServerLevel level, int x, int z) {
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1;
if (y < level.getMinBuildHeight()) {
return null;
}
BlockPos pos = new BlockPos(x, y, z);
return level.isLoaded(pos) ? pos : null;
}
}
@@ -41,7 +41,7 @@ class LivingWorldBootstrapTest {
assertTrue(bootstrap.getServices().isRegistered(CoreServices.TIME)); assertTrue(bootstrap.getServices().isRegistered(CoreServices.TIME));
assertTrue(bootstrap.getServices().isRegistered(CoreServices.DEBUG)); assertTrue(bootstrap.getServices().isRegistered(CoreServices.DEBUG));
for (int tick = 0; tick < 100; tick++) { for (int tick = 0; tick < 50; tick++) {
bootstrap.onServerTick(); bootstrap.onServerTick();
} }
TimeService timeService = bootstrap.getServices().get(CoreServices.TIME); TimeService timeService = bootstrap.getServices().get(CoreServices.TIME);
@@ -1,36 +1,79 @@
package com.livingworld.commands; package com.livingworld.commands;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import com.livingworld.modules.pollution.PollutionRegionData;
import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.recovery.SuccessionStage;
import java.util.List; import java.util.List;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import com.livingworld.regions.Region; import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate; import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory; import com.livingworld.regions.RegionFactory;
class RegionInfoFormatterTest { class RegionInfoFormatterTest {
@Test private static Region region() {
void includesIdentityLifecycleMetricsFlagsAndSortedModuleIds() { return new RegionFactory().createNewRegion(
Region region = new RegionFactory().createNewRegion(
new RegionCoordinate("minecraft:overworld", -1, 2), 0); new RegionCoordinate("minecraft:overworld", -1, 2), 0);
region.getMetrics().setPollutionScore(75); }
region.getFlags().setHasHighPollution(true);
region.getModuleData().put("water", "data");
region.getModuleData().put("soil", "data");
List<String> lines = RegionInfoFormatter.format(region); @Test
void headerContainsRegionId() {
List<String> lines = RegionInfoFormatter.format(region());
assertTrue(lines.get(0).contains("minecraft:overworld:-1:2"),
"header should contain stable ID; got: " + lines.get(0));
assertTrue(lines.get(0).contains("ACTIVE"), lines.get(0));
}
assertEquals(5, lines.size()); @Test
assertTrue(lines.get(0).contains("minecraft:overworld:-1:2")); void metricsLineContainsPollutionScore() {
assertTrue(lines.get(1).contains("ACTIVE")); Region r = region();
assertTrue(lines.get(2).contains("pollution=75.0")); r.getMetrics().setPollutionScore(75);
assertTrue(lines.get(3).contains("highPollution=true")); List<String> lines = RegionInfoFormatter.format(r);
assertEquals("Module data: soil, water", lines.get(4)); assertTrue(lines.stream().anyMatch(l -> l.contains("poll=75.0")),
"metrics line should contain poll=75.0");
}
@Test
void flagsLineContainsHighPollution() {
Region r = region();
r.getFlags().setHasHighPollution(true);
List<String> lines = RegionInfoFormatter.format(r);
assertTrue(lines.stream().anyMatch(l -> l.contains("highPollution=true")),
"flags line should contain highPollution=true");
}
@Test
void moduleDataSectionPresentForAllModules() {
List<String> lines = RegionInfoFormatter.format(region());
assertTrue(lines.stream().anyMatch(l -> l.contains("--- Module Data ---")));
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" pollution:")));
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" soil:")));
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" water:")));
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" vegetation:")));
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" resources:")));
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" recovery:")));
assertTrue(lines.stream().anyMatch(l -> l.startsWith(" ecosystem:")));
}
@Test
void moduleDataValuesShownWhenPresent() {
Region r = region();
r.getModuleData().put("pollution", new PollutionRegionData(33.0, 0.0, 0.0, 20.0));
r.getModuleData().put("recovery",
new RecoveryRegionData(SuccessionStage.YOUNG_WOODLAND, 50.0, 0.0));
List<String> lines = RegionInfoFormatter.format(r);
assertTrue(lines.stream().anyMatch(l -> l.contains("air=33.0")));
assertTrue(lines.stream().anyMatch(l -> l.contains("stage=YOUNG_WOODLAND")));
}
@Test
void noDataShownWhenModuleAbsent() {
List<String> lines = RegionInfoFormatter.format(region());
assertTrue(lines.stream().anyMatch(l -> l.contains("(no data)")));
} }
@Test @Test
@@ -31,7 +31,7 @@ class SimulationConfigTest {
@Test @Test
void defaultSimulationIntervalTicks() { void defaultSimulationIntervalTicks() {
final SimulationConfig config = new SimulationConfig(); final SimulationConfig config = new SimulationConfig();
assertEquals(100, config.getSimulationIntervalTicks()); assertEquals(50, config.getSimulationIntervalTicks());
} }
@Test @Test
@@ -276,6 +276,6 @@ class SimulationConfigTest {
final String result = config.toString(); final String result = config.toString();
assertTrue(result.contains("SimulationConfig")); assertTrue(result.contains("SimulationConfig"));
assertTrue(result.contains("regionSizeChunks=8")); assertTrue(result.contains("regionSizeChunks=8"));
assertTrue(result.contains("simulationIntervalTicks=100")); assertTrue(result.contains("simulationIntervalTicks=50"));
} }
} }
@@ -4,9 +4,14 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import com.livingworld.modules.pollution.PollutionRegionData;
import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.modules.soil.SoilRegionData;
import com.livingworld.regions.Region; import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate; import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory; import com.livingworld.regions.RegionFactory;
import com.livingworld.regions.RegionModuleData;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Clock; import java.time.Clock;
@@ -99,6 +104,77 @@ class FileRegionPersistenceServiceTest {
assertEquals(0, restarted.getDirtyRegionCount()); assertEquals(0, restarted.getDirtyRegionCount());
} }
@Test
void roundTripsModuleData() {
FileRegionPersistenceService service = service();
service.registerModuleCodec(
"pollution",
(data, w) -> {
PollutionRegionData d = data.get("pollution", PollutionRegionData.class)
.orElseGet(PollutionRegionData::defaults);
w.writeDouble("airPollution", d.getAirPollution());
w.writeDouble("groundPollution", d.getGroundPollution());
w.writeDouble("waterPollution", d.getWaterPollution());
w.writeDouble("decayResistance", d.getDecayResistance());
},
(r, data) -> data.put("pollution", new PollutionRegionData(
r.readDouble("airPollution", 0.0),
r.readDouble("groundPollution", 0.0),
r.readDouble("waterPollution", 0.0),
r.readDouble("decayResistance", 20.0))));
service.registerModuleCodec(
"soil",
(data, w) -> {
SoilRegionData d = data.get("soil", SoilRegionData.class)
.orElseGet(SoilRegionData::defaults);
w.writeDouble("fertility", d.getFertility());
w.writeDouble("contamination", d.getContamination());
},
(r, data) -> data.put("soil", new SoilRegionData(
r.readDouble("fertility", 60.0),
r.readDouble("moisture", 50.0),
r.readDouble("contamination", 0.0),
r.readDouble("compaction", 10.0),
r.readDouble("erosion", 0.0))));
service.registerModuleCodec(
"recovery",
(data, w) -> {
RecoveryRegionData d = data.get("recovery", RecoveryRegionData.class)
.orElseGet(RecoveryRegionData::defaults);
w.writeString("successionStage", d.getSuccessionStage().name());
w.writeDouble("damageAccumulation", d.getDamageAccumulation());
},
(r, data) -> data.put("recovery", new RecoveryRegionData(
SuccessionStage.valueOf(r.readString("successionStage", SuccessionStage.GRASSLAND.name())),
r.readDouble("recoveryProgress", 0.0),
r.readDouble("damageAccumulation", 0.0))));
Region original = new RegionFactory().createNewRegion(
new RegionCoordinate("minecraft:overworld", 0, 0), 0);
RegionModuleData moduleData = original.getModuleData();
moduleData.put("pollution", new PollutionRegionData(42.0, 15.0, 8.0, 25.0));
moduleData.put("soil", new SoilRegionData(75.0, 60.0, 5.0, 20.0, 3.0));
moduleData.put("recovery", new RecoveryRegionData(SuccessionStage.YOUNG_WOODLAND, 66.0, 12.5));
service.saveRegion(original);
Region restored = service.loadRegion(original.getCoordinate()).orElseThrow();
RegionModuleData restoredData = restored.getModuleData();
PollutionRegionData pollution = restoredData.get("pollution", PollutionRegionData.class).orElseThrow();
assertEquals(42.0, pollution.getAirPollution());
assertEquals(15.0, pollution.getGroundPollution());
assertEquals(8.0, pollution.getWaterPollution());
assertEquals(25.0, pollution.getDecayResistance());
SoilRegionData soil = restoredData.get("soil", SoilRegionData.class).orElseThrow();
assertEquals(75.0, soil.getFertility());
assertEquals(5.0, soil.getContamination());
RecoveryRegionData recovery = restoredData.get("recovery", RecoveryRegionData.class).orElseThrow();
assertEquals(SuccessionStage.YOUNG_WOODLAND, recovery.getSuccessionStage());
assertEquals(12.5, recovery.getDamageAccumulation());
}
private FileRegionPersistenceService service() { private FileRegionPersistenceService service() {
return new FileRegionPersistenceService( return new FileRegionPersistenceService(
temporaryDirectory, temporaryDirectory,
@@ -233,6 +233,11 @@ class SimulationManagerTest {
return coordinates.stream().map(regions::get).filter(java.util.Objects::nonNull).toList(); return coordinates.stream().map(regions::get).filter(java.util.Objects::nonNull).toList();
} }
@Override
public java.util.Collection<Region> getActiveRegions() {
return regions.values();
}
@Override @Override
public void markDirty(Region region) { public void markDirty(Region region) {
markDirtyCount++; markDirtyCount++;
@@ -0,0 +1,258 @@
package com.livingworld.data.migration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.*;
class MigrationManagerTest {
// ------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------
private static Properties propsAtVersion(int version) {
Properties p = new Properties();
p.setProperty("schemaVersion", Integer.toString(version));
p.setProperty("someField", "original");
return p;
}
/** Migration that adds a tag proving it ran. */
private static RegionMigration tagMigration(int from) {
return new RegionMigration() {
@Override public int fromVersion() { return from; }
@Override public int toVersion() { return from + 1; }
@Override public Properties apply(Properties data) {
Properties out = new Properties();
out.putAll(data);
out.setProperty("migrated_" + from + "_to_" + (from + 1), "true");
return out;
}
};
}
// ------------------------------------------------------------------
// construction
// ------------------------------------------------------------------
@Test
void newManagerHasNoMigrations() {
MigrationManager manager = new MigrationManager(null);
assertEquals(0, manager.getRegisteredMigrationCount());
}
// ------------------------------------------------------------------
// registration
// ------------------------------------------------------------------
@Test
void registerNullMigrationThrows() {
MigrationManager manager = new MigrationManager(null);
assertThrows(IllegalArgumentException.class, () -> manager.register(null));
}
@Test
void registerMigrationThatSkipsVersionThrows() {
MigrationManager manager = new MigrationManager(null);
RegionMigration bad = new RegionMigration() {
@Override public int fromVersion() { return 1; }
@Override public int toVersion() { return 3; } // skips v2
@Override public Properties apply(Properties data) { return data; }
};
assertThrows(IllegalArgumentException.class, () -> manager.register(bad));
}
@Test
void registerDuplicateFromVersionThrows() {
MigrationManager manager = new MigrationManager(null);
manager.register(tagMigration(1));
assertThrows(IllegalArgumentException.class, () -> manager.register(tagMigration(1)));
}
@Test
void registerIncreasesCount() {
MigrationManager manager = new MigrationManager(null);
manager.register(tagMigration(1));
manager.register(tagMigration(2));
assertEquals(2, manager.getRegisteredMigrationCount());
}
// ------------------------------------------------------------------
// isUpToDate
// ------------------------------------------------------------------
@Test
void isUpToDateReturnsTrueWhenVersionMatches() {
MigrationManager manager = new MigrationManager(null);
assertTrue(manager.isUpToDate(propsAtVersion(1), 1));
}
@Test
void isUpToDateReturnsFalseWhenBehind() {
MigrationManager manager = new MigrationManager(null);
assertFalse(manager.isUpToDate(propsAtVersion(1), 2));
}
// ------------------------------------------------------------------
// migrateIfNeeded — already at target
// ------------------------------------------------------------------
@Test
void migrateIfNeededReturnsSameObjectWhenAlreadyAtTarget() {
MigrationManager manager = new MigrationManager(null);
Properties data = propsAtVersion(1);
Properties result = manager.migrateIfNeeded(data, 1);
assertSame(data, result);
}
// ------------------------------------------------------------------
// migrateIfNeeded — single step
// ------------------------------------------------------------------
@Test
void singleMigrationIsApplied() {
MigrationManager manager = new MigrationManager(null);
manager.register(tagMigration(1));
Properties data = propsAtVersion(1);
Properties result = manager.migrateIfNeeded(data, 2);
assertEquals("2", result.getProperty("schemaVersion"));
assertEquals("true", result.getProperty("migrated_1_to_2"));
assertEquals("original", result.getProperty("someField")); // original data preserved
}
// ------------------------------------------------------------------
// migrateIfNeeded — multi-step
// ------------------------------------------------------------------
@Test
void multiStepMigrationAppliesAllStepsInOrder() {
MigrationManager manager = new MigrationManager(null);
manager.register(tagMigration(1));
manager.register(tagMigration(2));
Properties result = manager.migrateIfNeeded(propsAtVersion(1), 3);
assertEquals("3", result.getProperty("schemaVersion"));
assertEquals("true", result.getProperty("migrated_1_to_2"));
assertEquals("true", result.getProperty("migrated_2_to_3"));
}
// ------------------------------------------------------------------
// migrateIfNeeded — source data is not mutated
// ------------------------------------------------------------------
@Test
void originalDataIsNotMutatedByMigration() {
MigrationManager manager = new MigrationManager(null);
manager.register(tagMigration(1));
Properties data = propsAtVersion(1);
manager.migrateIfNeeded(data, 2);
assertEquals("1", data.getProperty("schemaVersion"));
assertNull(data.getProperty("migrated_1_to_2"));
}
// ------------------------------------------------------------------
// migrateIfNeeded — error cases
// ------------------------------------------------------------------
@Test
void downgradeAttemptThrows() {
MigrationManager manager = new MigrationManager(null);
assertThrows(IllegalStateException.class,
() -> manager.migrateIfNeeded(propsAtVersion(2), 1));
}
@Test
void missingMigrationInChainThrows() {
MigrationManager manager = new MigrationManager(null);
manager.register(tagMigration(1));
// no migration for version 2 → 3
// data at v1, target v3: step 1→2 exists but 2→3 is missing
assertThrows(IllegalStateException.class,
() -> manager.migrateIfNeeded(propsAtVersion(1), 3));
}
@Test
void missingSchemaVersionKeyThrows() {
MigrationManager manager = new MigrationManager(null);
Properties data = new Properties();
data.setProperty("someField", "value");
assertThrows(IllegalStateException.class,
() -> manager.migrateIfNeeded(data, 2));
}
@Test
void invalidSchemaVersionValueThrows() {
MigrationManager manager = new MigrationManager(null);
Properties data = new Properties();
data.setProperty("schemaVersion", "not-a-number");
assertThrows(IllegalStateException.class,
() -> manager.migrateIfNeeded(data, 2));
}
@Test
void zeroSchemaVersionThrows() {
MigrationManager manager = new MigrationManager(null);
Properties data = new Properties();
data.setProperty("schemaVersion", "0");
assertThrows(IllegalStateException.class,
() -> manager.migrateIfNeeded(data, 1));
}
@Test
void nullDataThrows() {
MigrationManager manager = new MigrationManager(null);
assertThrows(IllegalArgumentException.class,
() -> manager.migrateIfNeeded(null, 1));
}
// ------------------------------------------------------------------
// migrations.log
// ------------------------------------------------------------------
@Test
void migrationIsRecordedInLogFile(@TempDir Path tempDir) throws Exception {
Path logFile = tempDir.resolve("migrations.log");
MigrationManager manager = new MigrationManager(logFile);
manager.register(tagMigration(1));
manager.migrateIfNeeded(propsAtVersion(1), 2);
assertTrue(Files.exists(logFile), "migrations.log should be created");
String content = Files.readString(logFile);
assertTrue(content.contains("1 → 2"), "log should record the version step");
}
@Test
void multiStepMigrationWritesMultipleLogEntries(@TempDir Path tempDir) throws Exception {
Path logFile = tempDir.resolve("sub/migrations.log");
MigrationManager manager = new MigrationManager(logFile);
manager.register(tagMigration(1));
manager.register(tagMigration(2));
manager.migrateIfNeeded(propsAtVersion(1), 3);
String content = Files.readString(logFile);
assertTrue(content.contains("1 → 2"));
assertTrue(content.contains("2 → 3"));
}
@Test
void noLogFileWrittenWhenAlreadyAtTargetVersion(@TempDir Path tempDir) throws Exception {
Path logFile = tempDir.resolve("migrations.log");
MigrationManager manager = new MigrationManager(logFile);
manager.migrateIfNeeded(propsAtVersion(1), 1);
assertFalse(Files.exists(logFile), "log should not be created when no migration runs");
}
}
@@ -0,0 +1,311 @@
package com.livingworld.modules;
import com.livingworld.modules.ecosystem.EcosystemModule;
import com.livingworld.modules.ecosystem.EcosystemRegionData;
import com.livingworld.modules.pollution.PollutionModule;
import com.livingworld.modules.pollution.PollutionRegionData;
import com.livingworld.modules.soil.SoilModule;
import com.livingworld.modules.soil.SoilRegionData;
import com.livingworld.modules.vegetation.VegetationModule;
import com.livingworld.modules.vegetation.VegetationRegionData;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory;
import com.livingworld.regions.RegionLifecycleState;
import com.livingworld.regions.RegionMetrics;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration tests that run all four ecosystem modules in pipeline order against
* real Region objects and verify ecological cause-and-effect across multiple ticks.
*/
class EcosystemModuleIntegrationTest {
private static final List<SimulationModule> MODULES = List.of(
new PollutionModule(),
new SoilModule(),
new VegetationModule(),
new EcosystemModule());
private RegionFactory factory;
@BeforeEach
void setUp() {
factory = new RegionFactory();
}
// ------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------
private Region freshRegion() {
return factory.createNewRegion(
new RegionCoordinate("overworld", 0, 0), 0L);
}
/** Runs all modules once against the region, in pipeline order. */
private void tick(Region region) {
RegionUpdateContext ctx = new RegionUpdateContext(region);
for (SimulationModule module : MODULES) {
module.createDefaultRegionData(region);
module.updateRegion(ctx);
}
}
/** Runs N ticks against the region. */
private void tick(Region region, int ticks) {
for (int i = 0; i < ticks; i++) {
tick(region);
}
}
// ------------------------------------------------------------------
// clean region stays stable
// ------------------------------------------------------------------
@Test
void cleanRegionKeepsHighEcosystemHealthOverManyTicks() {
Region region = freshRegion();
// Start with pristine defaults no pollution.
tick(region, 50);
// Ecosystem health should remain high with no external stressors.
assertTrue(region.getMetrics().getEcosystemHealth() >= 50.0,
"Clean region should maintain reasonable ecosystem health");
assertTrue(region.getMetrics().getPollutionScore() < 5.0,
"No pollution was added; score should stay near zero");
}
// ------------------------------------------------------------------
// heavy pollution degrades soil and vegetation
// ------------------------------------------------------------------
@Test
void heavyPollutionDegradesSoilQualityOverTime() {
Region region = freshRegion();
// Prime the region with severe pollution directly in module data.
PollutionRegionData pollData = new PollutionRegionData(80.0, 80.0, 50.0, 20.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
double initialSoilQuality = region.getMetrics().getSoilQuality();
tick(region, 30);
double laterSoilQuality = region.getMetrics().getSoilQuality();
assertTrue(laterSoilQuality < initialSoilQuality,
"Sustained heavy pollution should degrade soil quality. Before="
+ initialSoilQuality + " After=" + laterSoilQuality);
}
@Test
void heavyPollutionReducesVegetationPressure() {
Region region = freshRegion();
PollutionRegionData pollData = new PollutionRegionData(90.0, 90.0, 70.0, 20.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
double initialVeg = region.getMetrics().getVegetationPressure();
tick(region, 30);
double laterVeg = region.getMetrics().getVegetationPressure();
assertTrue(laterVeg < initialVeg,
"Heavy pollution should reduce vegetation pressure. Before="
+ initialVeg + " After=" + laterVeg);
}
@Test
void heavyPollutionIncreasesEcosystemStress() {
Region region = freshRegion();
PollutionRegionData pollData = new PollutionRegionData(100.0, 100.0, 100.0, 50.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
tick(region, 20);
EcosystemRegionData ecoData = region.getModuleData()
.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class)
.orElseThrow();
assertTrue(ecoData.getStress() > 20.0,
"Severe pollution should elevate ecosystem stress");
}
// ------------------------------------------------------------------
// vegetation succession
// ------------------------------------------------------------------
@Test
void bareGroundWithGoodSoilGrowsGrassOverTime() {
Region region = freshRegion();
// Strip all vegetation.
VegetationRegionData barren = new VegetationRegionData(5.0, 0.0, 0.0, 0.0, 0.0);
region.getModuleData().put(VegetationModule.MODULE_ID, barren);
tick(region, 50);
VegetationRegionData later = region.getModuleData()
.get(VegetationModule.MODULE_ID, VegetationRegionData.class)
.orElseThrow();
assertTrue(later.getGrassPressure() > 5.0,
"Good soil should allow grass to grow back from a barren start");
}
@Test
void loggingReducesTreeAndShrubPressure() {
Region region = freshRegion();
VegetationRegionData preLog = VegetationRegionData.defaults();
double treesBefore = preLog.getTreePressure();
preLog.reduceFromLogging(30.0);
region.getModuleData().put(VegetationModule.MODULE_ID, preLog);
assertTrue(preLog.getTreePressure() < treesBefore,
"Logging should immediately reduce tree pressure");
assertTrue(preLog.getDeadVegetation() > 5.0,
"Logging should produce dead vegetation");
}
// ------------------------------------------------------------------
// water quality
// ------------------------------------------------------------------
@Test
void waterPollutionDegradedWaterQuality() {
Region region = freshRegion();
// Force high water pollution.
PollutionRegionData pollData = new PollutionRegionData(0.0, 0.0, 80.0, 20.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
// Set metrics to a known starting waterQuality.
region.getMetrics().setWaterQuality(80.0);
tick(region, 10);
assertTrue(region.getMetrics().getWaterQuality() < 80.0,
"Water pollution should degrade water quality metric");
}
// ------------------------------------------------------------------
// recovery after pollution clears
// ------------------------------------------------------------------
@Test
void ecosystemHealthImprovesDramaticallyAfterPollutionIsRemoved() {
Region region = freshRegion();
// Heavily pollute for 20 ticks.
PollutionRegionData pollData = new PollutionRegionData(90.0, 90.0, 60.0, 20.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
tick(region, 20);
double healthMidPollution = region.getMetrics().getEcosystemHealth();
// Remove pollution and run for another 50 ticks.
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
tick(region, 50);
double healthAfterRecovery = region.getMetrics().getEcosystemHealth();
assertTrue(healthAfterRecovery > healthMidPollution,
"Ecosystem should recover after pollution is removed. MidPollution="
+ healthMidPollution + " AfterRecovery=" + healthAfterRecovery);
}
// ------------------------------------------------------------------
// module order matters
// ------------------------------------------------------------------
@Test
void pollutionModuleUpdatesMetricsReadBySoilModuleInSameTick() {
Region region = freshRegion();
// groundPollution=90 → pollutionScore=90*0.35=31.5 > POLLUTION_CONTAMINATION_THRESHOLD(30).
PollutionRegionData pollData = new PollutionRegionData(0.0, 90.0, 0.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollData);
// Run exactly one tick.
tick(region);
// Pollution module should have set a non-zero pollution score.
assertTrue(region.getMetrics().getPollutionScore() > 0.0,
"PollutionModule should have written a non-zero pollutionScore to metrics");
// Soil module (running after) should have begun accumulating contamination.
SoilRegionData soilData = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class)
.orElseThrow();
assertTrue(soilData.getContamination() > 0.0,
"SoilModule should have accumulated contamination from this tick's pollutionScore");
}
// ------------------------------------------------------------------
// lifecycle: createDefaultRegionData is idempotent
// ------------------------------------------------------------------
@Test
void createDefaultRegionDataIsIdempotent() {
Region region = freshRegion();
PollutionModule module = new PollutionModule();
module.createDefaultRegionData(region);
module.createDefaultRegionData(region); // second call must not overwrite
// Data exists and is valid defaults.
PollutionRegionData data = region.getModuleData()
.get(PollutionModule.MODULE_ID, PollutionRegionData.class)
.orElseThrow();
assertEquals(0.0, data.getAirPollution(), 1e-9);
}
// ------------------------------------------------------------------
// ModuleUpdateResult signals
// ------------------------------------------------------------------
@Test
void pollutionModuleReturnsNoChangeWhenPollutionIsZero() {
Region region = freshRegion();
// Ensure pollution module data is initialised to all-zero.
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
// Zero water quality impact needs zero starting waterQuality impact too.
region.getMetrics().setWaterQuality(60.0);
ModuleUpdateResult result = new PollutionModule()
.updateRegion(new RegionUpdateContext(region));
// Zero pollution decays to zero; pollutionScore stays 0; no meaningful change.
assertFalse(result.changedRegion(),
"PollutionModule with zero pollution should return noChange");
}
@Test
void pollutionModuleReturnsChangedWhenPollutionIsPresent() {
Region region = freshRegion();
region.getModuleData().put(PollutionModule.MODULE_ID,
new PollutionRegionData(50.0, 50.0, 50.0, 0.0));
ModuleUpdateResult result = new PollutionModule()
.updateRegion(new RegionUpdateContext(region));
assertTrue(result.changedRegion(),
"PollutionModule with non-zero pollution should return changed");
}
// ------------------------------------------------------------------
// long-run stability
// ------------------------------------------------------------------
@Test
void allMetricsRemainInValidRangeAfter1000Ticks() {
Region region = freshRegion();
// Add moderate pollution so the simulation isn't completely quiescent.
region.getModuleData().put(PollutionModule.MODULE_ID,
new PollutionRegionData(30.0, 20.0, 10.0, 25.0));
tick(region, 1000);
RegionMetrics m = region.getMetrics();
assertInRange("ecosystemHealth", m.getEcosystemHealth());
assertInRange("pollutionScore", m.getPollutionScore());
assertInRange("soilQuality", m.getSoilQuality());
assertInRange("waterQuality", m.getWaterQuality());
assertInRange("vegetationPressure",m.getVegetationPressure());
assertInRange("recoveryPressure", m.getRecoveryPressure());
}
private static void assertInRange(String name, double value) {
assertTrue(value >= 0.0 && value <= 100.0,
name + " must be in [0, 100] but was " + value);
}
}
@@ -0,0 +1,432 @@
package com.livingworld.modules;
import com.livingworld.modules.ecosystem.EcosystemModule;
import com.livingworld.modules.ecosystem.EcosystemRegionData;
import com.livingworld.modules.pollution.PollutionModule;
import com.livingworld.modules.pollution.PollutionRegionData;
import com.livingworld.modules.recovery.RecoveryModule;
import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.modules.resources.ResourceDepletionModule;
import com.livingworld.modules.resources.ResourceRegionData;
import com.livingworld.modules.soil.SoilModule;
import com.livingworld.modules.soil.SoilRegionData;
import com.livingworld.modules.vegetation.VegetationModule;
import com.livingworld.modules.vegetation.VegetationRegionData;
import com.livingworld.modules.water.WaterModule;
import com.livingworld.modules.worldeffects.WorldEffectRequest;
import com.livingworld.modules.worldeffects.WorldEffectType;
import com.livingworld.modules.worldeffects.WorldEffectsModule;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory;
import com.livingworld.regions.RegionMetrics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Full Volume 2 integration test: runs all eight ecosystem modules in pipeline
* order (Pollution → Soil → Water → Vegetation → ResourceDepletion → Recovery
* → Ecosystem → WorldEffects) and verifies ecological cause-and-effect across
* multiple simulation ticks.
*/
@DisplayName("Volume 2 Full Pipeline Integration")
class Volume2IntegrationTest {
private RegionFactory factory;
private WorldEffectsModule worldEffects;
private List<WorldEffectRequest> effectsCapture;
private List<SimulationModule> pipeline;
@BeforeEach
void setUp() {
factory = new RegionFactory();
worldEffects = new WorldEffectsModule();
effectsCapture = new ArrayList<>();
worldEffects.registerConsumer(effectsCapture::add);
pipeline = List.of(
new PollutionModule(),
new SoilModule(),
new WaterModule(),
new VegetationModule(),
new ResourceDepletionModule(),
new RecoveryModule(),
new EcosystemModule(),
worldEffects);
}
// ------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------
private Region freshRegion() {
return factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
}
private void tick(Region region) {
effectsCapture.clear();
RegionUpdateContext ctx = new RegionUpdateContext(region);
for (SimulationModule module : pipeline) {
module.createDefaultRegionData(region);
module.updateRegion(ctx);
}
}
private void tick(Region region, int ticks) {
for (int i = 0; i < ticks; i++) {
tick(region);
}
}
// ------------------------------------------------------------------
// Structural tests
// ------------------------------------------------------------------
@Test
@DisplayName("all modules initialize without error")
void allModulesInitialize() {
Region region = freshRegion();
for (SimulationModule module : pipeline) {
assertDoesNotThrow(() -> module.createDefaultRegionData(region));
}
}
@Test
@DisplayName("pipeline runs 100 ticks on a clean region without exception")
void cleanRegionStableOver100Ticks() {
Region region = freshRegion();
assertDoesNotThrow(() -> tick(region, 100));
}
@Test
@DisplayName("all metrics stay in [0, 100] over 1000 ticks — pristine region")
void metricsInRangePristine() {
Region region = freshRegion();
tick(region, 1000);
assertMetricsInRange(region.getMetrics());
}
@Test
@DisplayName("all metrics stay in [0, 100] over 500 ticks — heavily polluted region")
void metricsInRangeHeavilyPolluted() {
Region region = freshRegion();
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
tick(region, 500);
assertMetricsInRange(region.getMetrics());
}
// ------------------------------------------------------------------
// Causal chain tests
// ------------------------------------------------------------------
@Test
@DisplayName("heavy pollution degrades soil quality over 30 ticks")
void heavyPollutionDegradesSoil() {
Region region = freshRegion();
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
double initialSoil = region.getMetrics().getSoilQuality();
tick(region, 30);
assertTrue(region.getMetrics().getSoilQuality() < initialSoil,
"Soil quality should degrade under heavy pollution");
}
@Test
@DisplayName("heavy pollution reduces vegetation pressure over 50 ticks")
void heavyPollutionReducesVegetation() {
Region region = freshRegion();
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
double initialVeg = region.getMetrics().getVegetationPressure();
tick(region, 50);
assertTrue(region.getMetrics().getVegetationPressure() < initialVeg,
"Vegetation pressure should fall under heavy pollution");
}
@Test
@DisplayName("heavy pollution lowers water quality over 20 ticks")
void heavyPollutionLowersWater() {
Region region = freshRegion();
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
double initialWater = region.getMetrics().getWaterQuality();
tick(region, 20);
assertTrue(region.getMetrics().getWaterQuality() < initialWater,
"Water quality should fall under heavy pollution");
}
@Test
@DisplayName("heavy pollution increases ecosystem stress over 30 ticks")
void heavyPollutionIncreasesStress() {
Region region = freshRegion();
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
tick(region, 30);
EcosystemRegionData eco = region.getModuleData()
.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class).orElseThrow();
assertTrue(eco.getStress() > 20.0,
"Ecosystem stress should be elevated under heavy pollution");
}
@Test
@DisplayName("bare ground (low soil + zero pollution) grows grass over 50 ticks")
void bareGroundGrowsGrass() {
Region region = freshRegion();
// Start with bare-ish ground: good soil, no pollution, low veg
SoilRegionData soil = new SoilRegionData(70.0, 50.0, 0.0, 10.0, 0.0);
region.getModuleData().put(SoilModule.MODULE_ID, soil);
VegetationRegionData veg = new VegetationRegionData(5.0, 5.0, 0.0, 0.0, 0.0);
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
tick(region, 50);
VegetationRegionData after = region.getModuleData()
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElseThrow();
assertTrue(after.getGrassPressure() > 5.0,
"Grass should grow on bare ground with good soil, was: " + after.getGrassPressure());
}
@Test
@DisplayName("logging depletion reduces tree and shrub pressure over 20 ticks")
void loggingReducesTreeShrubPressure() {
Region region = freshRegion();
ResourceRegionData resources = ResourceRegionData.defaults();
resources.recordLogging(80.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
VegetationRegionData vegData = new VegetationRegionData(50.0, 30.0, 50.0, 60.0, 5.0);
vegData.reduceFromLogging(80.0);
region.getModuleData().put(VegetationModule.MODULE_ID, vegData);
tick(region, 20);
VegetationRegionData after = region.getModuleData()
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElseThrow();
assertTrue(after.getTreePressure() < 60.0,
"Tree pressure should be reduced after logging");
}
@Test
@DisplayName("high vegetation purifies water quality over 30 ticks (pollution-free region)")
void vegetationPurifiesWater() {
Region region = freshRegion();
// Start with high veg, good soil, slightly low water quality
VegetationRegionData vegData = new VegetationRegionData(80.0, 50.0, 60.0, 40.0, 5.0);
region.getModuleData().put(VegetationModule.MODULE_ID, vegData);
region.getMetrics().setWaterQuality(40.0);
tick(region, 30);
assertTrue(region.getMetrics().getWaterQuality() > 40.0,
"Water quality should improve when vegetation is healthy");
}
@Test
@DisplayName("ecosystem health improves after pollution is removed (over 50 ticks)")
void healthImprovesAfterPollutionRemoved() {
Region region = freshRegion();
// First: run 30 ticks with heavy pollution
PollutionRegionData heavyPollution = new PollutionRegionData(80.0, 80.0, 80.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, heavyPollution);
tick(region, 30);
double healthAfterPollution = region.getMetrics().getEcosystemHealth();
// Now remove pollution and run 50 more ticks
region.getModuleData().put(PollutionModule.MODULE_ID, PollutionRegionData.defaults());
tick(region, 50);
assertTrue(region.getMetrics().getEcosystemHealth() > healthAfterPollution,
"Ecosystem health should improve once pollution is removed");
}
@Test
@DisplayName("succession advances from BARREN toward GRASSLAND under good conditions")
void successionAdvancesUnderGoodConditions() {
Region region = freshRegion();
// Set good conditions exceeding all early stage thresholds
region.getMetrics().setSoilQuality(70.0);
region.getMetrics().setPollutionScore(0.0);
region.getMetrics().setVegetationPressure(40.0);
RecoveryRegionData recovery = new RecoveryRegionData(SuccessionStage.BARREN, 0.0, 0.0);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
tick(region, 300);
RecoveryRegionData result = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
assertTrue(result.getSuccessionStage().ordinal() > SuccessionStage.BARREN.ordinal(),
"Succession should advance beyond BARREN with good conditions");
}
@Test
@DisplayName("succession regresses under severe pollution over 30 ticks")
void successionRegressesUnderSeverePollution() {
Region region = freshRegion();
// Start at SCRUBLAND
RecoveryRegionData recovery = new RecoveryRegionData(SuccessionStage.SCRUBLAND, 0.0, 0.0);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
// Force very bad conditions
PollutionRegionData pollution = new PollutionRegionData(95.0, 95.0, 95.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
SoilRegionData soil = new SoilRegionData(5.0, 0.0, 50.0, 30.0, 20.0);
region.getModuleData().put(SoilModule.MODULE_ID, soil);
tick(region, 30);
RecoveryRegionData result = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
// Either damage accumulated or regression happened
assertTrue(result.getDamageAccumulation() > 0.0
|| result.getSuccessionStage().ordinal() < SuccessionStage.SCRUBLAND.ordinal(),
"Succession should regress or take damage under severe pollution");
}
// ------------------------------------------------------------------
// World effects tests
// ------------------------------------------------------------------
@Test
@DisplayName("GRASS_DEGRADES_TO_DIRT emitted when pollution is high and soil is poor")
void worldEffectGrassDegrades() {
Region region = freshRegion();
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
SoilRegionData soil = new SoilRegionData(10.0, 0.0, 50.0, 30.0, 20.0);
region.getModuleData().put(SoilModule.MODULE_ID, soil);
// Prime the pipeline so metrics are set by pollution/soil before worldeffects runs
tick(region, 5);
effectsCapture.clear();
tick(region);
boolean found = effectsCapture.stream()
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
assertTrue(found,
"GRASS_DEGRADES_TO_DIRT should be emitted when soil is poor and pollution is high");
}
@Test
@DisplayName("VEGETATION_SPREADS emitted when vegetation and soil are healthy")
void worldEffectVegetationSpreads() {
Region region = freshRegion();
// Force high vegetation and good soil
VegetationRegionData veg = new VegetationRegionData(90.0, 50.0, 70.0, 60.0, 0.0);
region.getModuleData().put(VegetationModule.MODULE_ID, veg);
SoilRegionData soil = new SoilRegionData(80.0, 60.0, 0.0, 5.0, 0.0);
region.getModuleData().put(SoilModule.MODULE_ID, soil);
tick(region, 3);
effectsCapture.clear();
tick(region);
boolean found = effectsCapture.stream()
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
assertTrue(found,
"VEGETATION_SPREADS should be emitted with high vegetation and good soil");
}
@Test
@DisplayName("SAPLING_GROWTH_SLOWED emitted when logging depletion is high")
void worldEffectSaplingSlowed() {
Region region = freshRegion();
ResourceRegionData resources = ResourceRegionData.defaults();
resources.recordLogging(80.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
effectsCapture.clear();
tick(region);
boolean found = effectsCapture.stream()
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
assertTrue(found, "SAPLING_GROWTH_SLOWED should be emitted with heavy logging depletion");
}
@Test
@DisplayName("SAPLING_GROWTH_BOOSTED emitted when region reaches YOUNG_WOODLAND")
void worldEffectSaplingBoosted() {
Region region = freshRegion();
RecoveryRegionData recovery = new RecoveryRegionData(
SuccessionStage.YOUNG_WOODLAND, 0.0, 0.0);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
effectsCapture.clear();
tick(region);
boolean found = effectsCapture.stream()
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
assertTrue(found, "SAPLING_GROWTH_BOOSTED should be emitted at YOUNG_WOODLAND stage");
}
@Test
@DisplayName("emitted WorldEffectRequests all have intensity in [0, 1]")
void worldEffectIntensitiesInRange() {
Region region = freshRegion();
PollutionRegionData pollution = new PollutionRegionData(90.0, 90.0, 90.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
SoilRegionData soil = new SoilRegionData(10.0, 0.0, 50.0, 30.0, 20.0);
region.getModuleData().put(SoilModule.MODULE_ID, soil);
tick(region, 5);
effectsCapture.clear();
tick(region);
for (WorldEffectRequest req : effectsCapture) {
assertTrue(req.intensity() >= 0.0 && req.intensity() <= 1.0,
"Intensity out of range: " + req.intensity() + " for " + req.type());
}
}
// ------------------------------------------------------------------
// Pipeline ordering test
// ------------------------------------------------------------------
@Test
@DisplayName("pollution metric written in same tick is read by soil and water modules")
void pipelineOrderVerified() {
Region region = freshRegion();
// High ground pollution → pollutionScore computed by PollutionModule
// → SoilModule reads pollutionScore → contamination raised
// → WaterModule reads soilQuality → leach applied
PollutionRegionData pollution = new PollutionRegionData(0.0, 90.0, 0.0, 0.0);
region.getModuleData().put(PollutionModule.MODULE_ID, pollution);
// Single tick — everything computed in sequence
tick(region);
// pollutionScore = ground * 0.35 = 31.5 > threshold → soil contamination increases
SoilRegionData soilAfter = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class).orElseThrow();
assertTrue(soilAfter.getContamination() > 0.0,
"SoilModule should have read the pollution metric set by PollutionModule in the same tick");
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private void assertMetricsInRange(RegionMetrics m) {
assertTrue(m.getEcosystemHealth() >= 0 && m.getEcosystemHealth() <= 100, "ecosystemHealth out of range: " + m.getEcosystemHealth());
assertTrue(m.getPollutionScore() >= 0 && m.getPollutionScore() <= 100, "pollutionScore out of range: " + m.getPollutionScore());
assertTrue(m.getSoilQuality() >= 0 && m.getSoilQuality() <= 100, "soilQuality out of range: " + m.getSoilQuality());
assertTrue(m.getWaterQuality() >= 0 && m.getWaterQuality() <= 100, "waterQuality out of range: " + m.getWaterQuality());
assertTrue(m.getVegetationPressure() >= 0 && m.getVegetationPressure() <= 100, "vegetationPressure out of range: " + m.getVegetationPressure());
assertTrue(m.getResourceDepletion() >= 0 && m.getResourceDepletion() <= 100, "resourceDepletion out of range: " + m.getResourceDepletion());
assertTrue(m.getRecoveryPressure() >= 0 && m.getRecoveryPressure() <= 100, "recoveryPressure out of range: " + m.getRecoveryPressure());
}
}
@@ -0,0 +1,101 @@
package com.livingworld.modules.ecosystem;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class EcosystemRegionDataTest {
@Test
void defaultsAreModerateHealth() {
EcosystemRegionData d = EcosystemRegionData.defaults();
assertEquals(60.0, d.getEcosystemHealth(), 1e-9);
assertEquals(20.0, d.getStress(), 1e-9);
assertEquals(50.0, d.getResilience(), 1e-9);
assertEquals(5.0, d.getRecoveryRate(), 1e-9);
}
@Test
void applyStressIncreasesStressAndDegradesResilience() {
EcosystemRegionData d = EcosystemRegionData.defaults();
d.applyStress(10.0);
assertEquals(30.0, d.getStress(), 1e-9);
assertTrue(d.getResilience() < 50.0, "resilience should decrease under stress");
}
@Test
void applyStressNegativeThrows() {
assertThrows(IllegalArgumentException.class,
() -> EcosystemRegionData.defaults().applyStress(-1.0));
}
@Test
void applyStressClampsAt100() {
EcosystemRegionData d = new EcosystemRegionData(60.0, 90.0, 50.0, 5.0);
d.applyStress(20.0);
assertEquals(100.0, d.getStress(), 1e-9);
}
@Test
void applyRecoveryDecreasesStressAndIncreasesResilience() {
EcosystemRegionData d = new EcosystemRegionData(60.0, 40.0, 50.0, 5.0);
d.applyRecovery(10.0);
assertEquals(30.0, d.getStress(), 1e-9);
assertTrue(d.getResilience() > 50.0, "resilience should increase during recovery");
}
@Test
void applyRecoveryNegativeThrows() {
assertThrows(IllegalArgumentException.class,
() -> EcosystemRegionData.defaults().applyRecovery(-1.0));
}
@Test
void applyRecoveryClampsBelowZero() {
EcosystemRegionData d = new EcosystemRegionData(60.0, 5.0, 50.0, 5.0);
d.applyRecovery(20.0);
assertEquals(0.0, d.getStress(), 1e-9);
}
@Test
void constructorClampsAbove100() {
EcosystemRegionData d = new EcosystemRegionData(200.0, 200.0, 200.0, 200.0);
assertEquals(100.0, d.getEcosystemHealth(), 1e-9);
assertEquals(100.0, d.getStress(), 1e-9);
assertEquals(100.0, d.getResilience(), 1e-9);
assertEquals(100.0, d.getRecoveryRate(), 1e-9);
}
@Test
void constructorClampsBelowZero() {
EcosystemRegionData d = new EcosystemRegionData(-1.0, -1.0, -1.0, -1.0);
assertEquals(0.0, d.getEcosystemHealth(), 1e-9);
assertEquals(0.0, d.getStress(), 1e-9);
assertEquals(0.0, d.getResilience(), 1e-9);
assertEquals(0.0, d.getRecoveryRate(), 1e-9);
}
@Test
void copyIsIndependent() {
EcosystemRegionData original = EcosystemRegionData.defaults();
EcosystemRegionData copy = original.copy();
copy.applyStress(50.0);
assertEquals(20.0, original.getStress(), 1e-9);
}
@Test
void settersClampValues() {
EcosystemRegionData d = EcosystemRegionData.defaults();
d.setStress(-10.0);
d.setResilience(999.0);
assertEquals(0.0, d.getStress(), 1e-9);
assertEquals(100.0, d.getResilience(), 1e-9);
}
@Test
void normalizeDoesNotChangeLegalValues() {
EcosystemRegionData d = EcosystemRegionData.defaults();
d.normalize();
assertEquals(60.0, d.getEcosystemHealth(), 1e-9);
assertEquals(20.0, d.getStress(), 1e-9);
}
}
@@ -0,0 +1,104 @@
package com.livingworld.modules.pollution;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PollutionRegionDataTest {
@Test
void defaultsHaveZeroPollution() {
PollutionRegionData d = PollutionRegionData.defaults();
assertEquals(0.0, d.getAirPollution(), 1e-9);
assertEquals(0.0, d.getGroundPollution(), 1e-9);
assertEquals(0.0, d.getWaterPollution(), 1e-9);
assertEquals(20.0, d.getDecayResistance(), 1e-9);
}
@Test
void addPollutionAccumulates() {
PollutionRegionData d = PollutionRegionData.defaults();
d.addPollution(10.0, 20.0, 5.0);
assertEquals(10.0, d.getAirPollution(), 1e-9);
assertEquals(20.0, d.getGroundPollution(), 1e-9);
assertEquals(5.0, d.getWaterPollution(), 1e-9);
}
@Test
void addPollutionClampsAt100() {
PollutionRegionData d = new PollutionRegionData(90.0, 90.0, 90.0, 20.0);
d.addPollution(20.0, 20.0, 20.0);
assertEquals(100.0, d.getAirPollution(), 1e-9);
assertEquals(100.0, d.getGroundPollution(), 1e-9);
assertEquals(100.0, d.getWaterPollution(), 1e-9);
}
@Test
void decayReducesAllPollution() {
PollutionRegionData d = new PollutionRegionData(50.0, 50.0, 50.0, 0.0);
d.decay(0.02);
// With zero resistance: effectiveRate = 0.02 * 1.0 = 0.02
// air: 50 * (1 - 0.04) = 48
// ground: 50 * (1 - 0.01) = 49.5
// water: 50 * (1 - 0.006) = 49.7
assertTrue(d.getAirPollution() < 50.0);
assertTrue(d.getGroundPollution() < 50.0);
assertTrue(d.getWaterPollution() < 50.0);
}
@Test
void decayWithHighResistanceIsSlower() {
PollutionRegionData low = new PollutionRegionData(50.0, 50.0, 50.0, 0.0);
PollutionRegionData high = new PollutionRegionData(50.0, 50.0, 50.0, 100.0);
low.decay(0.02);
high.decay(0.02);
assertTrue(high.getAirPollution() > low.getAirPollution(),
"High resistance should leave more air pollution after decay");
}
@Test
void decayOnZeroPollutionStaysZero() {
PollutionRegionData d = PollutionRegionData.defaults();
d.decay(0.10);
assertEquals(0.0, d.getAirPollution(), 1e-9);
assertEquals(0.0, d.getGroundPollution(), 1e-9);
assertEquals(0.0, d.getWaterPollution(), 1e-9);
}
@Test
void constructorClampsNegativeValues() {
PollutionRegionData d = new PollutionRegionData(-10.0, -5.0, -1.0, -50.0);
assertEquals(0.0, d.getAirPollution(), 1e-9);
assertEquals(0.0, d.getGroundPollution(), 1e-9);
assertEquals(0.0, d.getWaterPollution(), 1e-9);
assertEquals(0.0, d.getDecayResistance(), 1e-9);
}
@Test
void constructorClampsAbove100() {
PollutionRegionData d = new PollutionRegionData(200.0, 150.0, 110.0, 999.0);
assertEquals(100.0, d.getAirPollution(), 1e-9);
assertEquals(100.0, d.getGroundPollution(), 1e-9);
assertEquals(100.0, d.getWaterPollution(), 1e-9);
assertEquals(100.0, d.getDecayResistance(), 1e-9);
}
@Test
void copyIsIndependent() {
PollutionRegionData original = new PollutionRegionData(30.0, 40.0, 10.0, 25.0);
PollutionRegionData copy = original.copy();
copy.addPollution(50.0, 50.0, 50.0);
assertEquals(30.0, original.getAirPollution(), 1e-9);
assertEquals(40.0, original.getGroundPollution(), 1e-9);
assertEquals(10.0, original.getWaterPollution(), 1e-9);
}
@Test
void normalizeDoesNotChangeValidValues() {
PollutionRegionData d = new PollutionRegionData(30.0, 40.0, 10.0, 25.0);
d.normalize();
assertEquals(30.0, d.getAirPollution(), 1e-9);
assertEquals(40.0, d.getGroundPollution(), 1e-9);
assertEquals(10.0, d.getWaterPollution(), 1e-9);
assertEquals(25.0, d.getDecayResistance(), 1e-9);
}
}
@@ -0,0 +1,215 @@
package com.livingworld.modules.recovery;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory;
import com.livingworld.regions.RegionMetrics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("RecoveryModule")
class RecoveryModuleTest {
private RecoveryModule module;
private RegionFactory factory;
private Region region;
private RegionMetrics metrics;
@BeforeEach
void setUp() {
module = new RecoveryModule();
factory = new RegionFactory();
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
metrics = region.getMetrics();
module.createDefaultRegionData(region);
}
@Test
@DisplayName("moduleId is 'recovery'")
void moduleId() {
assertEquals("recovery", module.getModuleId());
}
@Test
@DisplayName("metadata is non-null with correct id")
void metadata() {
assertNotNull(module.getMetadata());
assertEquals("recovery", module.getMetadata().moduleId());
}
@Test
@DisplayName("initialize throws on null context")
void initializeNullThrows() {
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
}
@Test
@DisplayName("createDefaultRegionData populates module data at GRASSLAND stage")
void createDefaultPopulates() {
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
module.createDefaultRegionData(fresh);
RecoveryRegionData data = fresh.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
assertEquals(SuccessionStage.GRASSLAND, data.getSuccessionStage());
}
@Test
@DisplayName("createDefaultRegionData is idempotent")
void createDefaultIdempotent() {
SuccessionStage before = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow()
.getSuccessionStage();
module.createDefaultRegionData(region);
SuccessionStage after = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow()
.getSuccessionStage();
assertEquals(before, after);
}
@Test
@DisplayName("updateRegion throws on null context")
void updateNullThrows() {
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
}
@Test
@DisplayName("good conditions advance recovery progress")
void goodConditionsAdvanceProgress() {
// GRASSLAND thresholds: minSoil=25, maxPollution=70, minVeg=20
metrics.setSoilQuality(70.0);
metrics.setPollutionScore(10.0);
metrics.setVegetationPressure(50.0);
metrics.setEcosystemHealth(50.0);
module.updateRegion(new RegionUpdateContext(region));
RecoveryRegionData data = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
assertTrue(data.getRecoveryProgress() > 0.0,
"Recovery progress should advance under good conditions");
}
@Test
@DisplayName("bad conditions accumulate damage over several ticks")
void badConditionsAccumulateDamage() {
// Put region at SCRUBLAND so it CAN regress.
// Force conditions way below SCRUBLAND minimums to trigger regression checks.
RecoveryRegionData startData = new RecoveryRegionData(
SuccessionStage.SCRUBLAND, 0.0, 0.0);
region.getModuleData().put(RecoveryModule.MODULE_ID, startData);
metrics.setSoilQuality(5.0); // far below minSoil=40 (SCRUBLAND)
metrics.setPollutionScore(95.0); // far above maxPollution=60 (SCRUBLAND)
metrics.setVegetationPressure(2.0); // far below minVeg=35 (SCRUBLAND)
for (int i = 0; i < 25; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
RecoveryRegionData result = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
// Either damage accumulated or regression already happened
assertTrue(result.getDamageAccumulation() > 0.0
|| result.getSuccessionStage().ordinal() < SuccessionStage.SCRUBLAND.ordinal(),
"Bad conditions should accumulate damage or cause regression");
}
@Test
@DisplayName("enough good ticks cause succession stage advancement")
void enoughGoodTicksAdvanceStage() {
// Start at BARREN — advancement thresholds: minSoil=10, maxPollution=80, minVeg=10
RecoveryRegionData data = new RecoveryRegionData(SuccessionStage.BARREN, 0.0, 0.0);
region.getModuleData().put(RecoveryModule.MODULE_ID, data);
metrics.setSoilQuality(60.0);
metrics.setPollutionScore(5.0);
metrics.setVegetationPressure(30.0);
metrics.setEcosystemHealth(80.0); // triggers health bonus
for (int i = 0; i < 250; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
RecoveryRegionData result = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElseThrow();
assertTrue(result.getSuccessionStage().ordinal() > SuccessionStage.BARREN.ordinal(),
"Stage should advance with enough good ticks");
}
@Test
@DisplayName("recoveryPressure metric is written and stays in [0, 100]")
void recoveryPressureMetricWritten() {
metrics.setSoilQuality(70.0);
metrics.setPollutionScore(0.0);
metrics.setVegetationPressure(50.0);
module.updateRegion(new RegionUpdateContext(region));
double pressure = region.getMetrics().getRecoveryPressure();
assertTrue(pressure >= 0.0 && pressure <= 100.0,
"Recovery pressure must be in [0,100], was: " + pressure);
}
@Test
@DisplayName("MATURE_FOREST stage has zero base recovery pressure")
void matureForestZeroPressure() {
RecoveryRegionData data = new RecoveryRegionData(
SuccessionStage.MATURE_FOREST, 0.0, 0.0);
region.getModuleData().put(RecoveryModule.MODULE_ID, data);
metrics.setSoilQuality(70.0);
metrics.setPollutionScore(0.0);
metrics.setVegetationPressure(50.0);
module.updateRegion(new RegionUpdateContext(region));
// stagesFromPeak = 0 → base pressure = 0 + damage * 0.3 = 0
assertEquals(0.0, region.getMetrics().getRecoveryPressure(), 1e-9);
}
@Test
@DisplayName("returns changed when progress increases")
void changedWhenProgressIncreases() {
metrics.setSoilQuality(70.0);
metrics.setPollutionScore(5.0);
metrics.setVegetationPressure(50.0);
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
assertTrue(result.changedRegion());
}
@Test
@DisplayName("returns noChange when neither advancement nor regression conditions are met")
void noChangeWhenNeutral() {
// GRASSLAND advancement: minSoil=25, maxPollution=70, minVeg=20
// GRASSLAND regression (50% severity): soil<12.5, pollution>84, veg<10
// Put values between these two sets: soil=20 (below advance, above regress),
// pollution=10 (fine), veg=15 (below advance threshold 20, above regress threshold 10)
metrics.setSoilQuality(20.0);
metrics.setPollutionScore(10.0);
metrics.setVegetationPressure(15.0);
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
assertFalse(result.changedRegion());
}
@Test
@DisplayName("recovery pressure stays in [0, 100] over 1000 ticks with bad conditions")
void recoveryPressureBounded() {
metrics.setSoilQuality(5.0);
metrics.setPollutionScore(90.0);
metrics.setVegetationPressure(5.0);
for (int i = 0; i < 1000; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
double p = region.getMetrics().getRecoveryPressure();
assertTrue(p >= 0.0 && p <= 100.0, "Recovery pressure out of range: " + p);
}
}
@@ -0,0 +1,252 @@
package com.livingworld.modules.resources;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory;
import com.livingworld.regions.RegionMetrics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("ResourceDepletionModule")
class ResourceDepletionModuleTest {
private ResourceDepletionModule module;
private RegionFactory factory;
private Region region;
private RegionMetrics metrics;
@BeforeEach
void setUp() {
module = new ResourceDepletionModule();
factory = new RegionFactory();
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
metrics = region.getMetrics();
module.createDefaultRegionData(region);
}
@Test
@DisplayName("moduleId is 'resources'")
void moduleId() {
assertEquals("resources", module.getModuleId());
}
@Test
@DisplayName("metadata is non-null with correct id")
void metadata() {
assertNotNull(module.getMetadata());
assertEquals("resources", module.getMetadata().moduleId());
}
@Test
@DisplayName("initialize throws on null context")
void initializeNullThrows() {
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
}
@Test
@DisplayName("createDefaultRegionData populates module data")
void createDefaultPopulates() {
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
module.createDefaultRegionData(fresh);
assertTrue(fresh.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).isPresent());
}
@Test
@DisplayName("createDefaultRegionData is idempotent")
void createDefaultIdempotent() {
ResourceRegionData before = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
module.createDefaultRegionData(region);
ResourceRegionData after = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
assertEquals(before.getMiningDepletion(), after.getMiningDepletion(), 1e-9);
}
@Test
@DisplayName("updateRegion throws on null context")
void updateNullThrows() {
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
}
@Test
@DisplayName("mining depletion regenerates very slowly (geological timescale)")
void miningRegeneratesSlowly() {
ResourceRegionData data = ResourceRegionData.defaults();
data.recordMining(50.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
// After 1000 ticks, mining depletion should be very close to 50 (barely moved)
for (int i = 0; i < 1000; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
ResourceRegionData after = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
assertTrue(after.getMiningDepletion() > 40.0,
"Mining depletion should recover very slowly, was: " + after.getMiningDepletion());
}
@Test
@DisplayName("logging depletion regenerates — drops from 80 over 100 ticks")
void loggingRegenerates() {
ResourceRegionData data = ResourceRegionData.defaults();
data.recordLogging(80.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
metrics.setVegetationPressure(30.0); // below bonus threshold
for (int i = 0; i < 100; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
ResourceRegionData after = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
assertTrue(after.getLoggingDepletion() < 80.0,
"Logging depletion should decrease, was: " + after.getLoggingDepletion());
}
@Test
@DisplayName("high vegetation pressure accelerates logging regeneration")
void highVegetationAcceleratesLoggingRegen() {
ResourceRegionData data = ResourceRegionData.defaults();
data.recordLogging(80.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
metrics.setVegetationPressure(80.0); // above threshold
for (int i = 0; i < 50; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
double withHighVeg = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
.getLoggingDepletion();
// Reset to same starting state with low vegetation
Region region2 = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
module.createDefaultRegionData(region2);
ResourceRegionData data2 = ResourceRegionData.defaults();
data2.recordLogging(80.0);
region2.getModuleData().put(ResourceDepletionModule.MODULE_ID, data2);
region2.getMetrics().setVegetationPressure(0.0);
for (int i = 0; i < 50; i++) {
module.updateRegion(new RegionUpdateContext(region2));
}
double withLowVeg = region2.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
.getLoggingDepletion();
assertTrue(withHighVeg < withLowVeg,
"Higher vegetation should accelerate logging regen");
}
@Test
@DisplayName("farming depletion regenerates — drops from 60 over 100 ticks")
void farmingRegenerates() {
ResourceRegionData data = ResourceRegionData.defaults();
data.recordFarming(60.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
metrics.setSoilQuality(30.0);
for (int i = 0; i < 100; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
ResourceRegionData after = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
assertTrue(after.getFarmingDepletion() < 60.0,
"Farming depletion should decrease, was: " + after.getFarmingDepletion());
}
@Test
@DisplayName("high soil quality accelerates farming regeneration")
void highSoilAcceleratesFarmingRegen() {
ResourceRegionData data = ResourceRegionData.defaults();
data.recordFarming(60.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
metrics.setSoilQuality(80.0);
for (int i = 0; i < 50; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
double withHighSoil = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
.getFarmingDepletion();
Region region2 = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
module.createDefaultRegionData(region2);
ResourceRegionData data2 = ResourceRegionData.defaults();
data2.recordFarming(60.0);
region2.getModuleData().put(ResourceDepletionModule.MODULE_ID, data2);
region2.getMetrics().setSoilQuality(20.0);
for (int i = 0; i < 50; i++) {
module.updateRegion(new RegionUpdateContext(region2));
}
double withLowSoil = region2.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow()
.getFarmingDepletion();
assertTrue(withHighSoil < withLowSoil,
"Higher soil quality should accelerate farming regen");
}
@Test
@DisplayName("resourceDepletion metric is written after update")
void metricWritten() {
ResourceRegionData data = ResourceRegionData.defaults();
data.recordMining(50.0);
data.recordLogging(40.0);
data.recordFarming(20.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
module.updateRegion(new RegionUpdateContext(region));
assertTrue(region.getMetrics().getResourceDepletion() > 0.0,
"Resource depletion metric should be set");
assertTrue(region.getMetrics().getResourceDepletion() <= 100.0);
}
@Test
@DisplayName("returns noChange for pristine (zero depletion) region")
void noChangeWhenPristine() {
// defaults are all-zero depletion; regen of zero is zero delta
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
assertFalse(result.changedRegion());
}
@Test
@DisplayName("returns changed when depletion drops by more than threshold")
void changedWhenDepletionDrops() {
// High vegetation triggers bonus logging regen (0.255/tick × 30% weight = 0.077 delta > 0.01)
metrics.setVegetationPressure(100.0);
ResourceRegionData data = ResourceRegionData.defaults();
data.recordLogging(100.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
assertTrue(result.changedRegion());
}
@Test
@DisplayName("all depletion values stay in [0, 100] under extreme conditions")
void depletionsBounded() {
ResourceRegionData data = ResourceRegionData.defaults();
data.recordMining(100.0);
data.recordLogging(100.0);
data.recordFarming(100.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, data);
for (int i = 0; i < 500; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
ResourceRegionData result = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElseThrow();
assertTrue(result.getMiningDepletion() >= 0.0 && result.getMiningDepletion() <= 100.0);
assertTrue(result.getLoggingDepletion() >= 0.0 && result.getLoggingDepletion() <= 100.0);
assertTrue(result.getFarmingDepletion() >= 0.0 && result.getFarmingDepletion() <= 100.0);
}
}
@@ -0,0 +1,94 @@
package com.livingworld.modules.soil;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SoilRegionDataTest {
@Test
void defaultsAreHealthyValues() {
SoilRegionData d = SoilRegionData.defaults();
assertEquals(60.0, d.getFertility(), 1e-9);
assertEquals(50.0, d.getMoisture(), 1e-9);
assertEquals(0.0, d.getContamination(), 1e-9);
assertEquals(10.0, d.getCompaction(), 1e-9);
assertEquals(0.0, d.getErosion(), 1e-9);
}
@Test
void degradeReducesFertilityAndIncreasesContaminationAndErosion() {
SoilRegionData d = SoilRegionData.defaults();
d.degrade(10.0);
assertTrue(d.getFertility() < 60.0, "fertility should decrease");
assertTrue(d.getContamination() > 0.0, "contamination should increase");
assertTrue(d.getErosion() > 0.0, "erosion should increase");
}
@Test
void degradeByZeroChangesNothing() {
SoilRegionData d = SoilRegionData.defaults();
d.degrade(0.0);
assertEquals(60.0, d.getFertility(), 1e-9);
assertEquals(0.0, d.getContamination(), 1e-9);
assertEquals(0.0, d.getErosion(), 1e-9);
}
@Test
void degradeNegativeAmountThrows() {
SoilRegionData d = SoilRegionData.defaults();
assertThrows(IllegalArgumentException.class, () -> d.degrade(-1.0));
}
@Test
void recoverIncreasesFertilityAndReducesContaminationAndErosion() {
SoilRegionData d = new SoilRegionData(40.0, 50.0, 20.0, 10.0, 15.0);
d.recover(10.0);
assertTrue(d.getFertility() > 40.0, "fertility should increase");
assertTrue(d.getContamination() < 20.0, "contamination should decrease");
assertTrue(d.getErosion() < 15.0, "erosion should decrease");
}
@Test
void recoverNegativeAmountThrows() {
SoilRegionData d = SoilRegionData.defaults();
assertThrows(IllegalArgumentException.class, () -> d.recover(-1.0));
}
@Test
void valuesAreClampedAbove100() {
SoilRegionData d = new SoilRegionData(200.0, 200.0, 200.0, 200.0, 200.0);
assertEquals(100.0, d.getFertility(), 1e-9);
assertEquals(100.0, d.getMoisture(), 1e-9);
assertEquals(100.0, d.getContamination(), 1e-9);
assertEquals(100.0, d.getCompaction(), 1e-9);
assertEquals(100.0, d.getErosion(), 1e-9);
}
@Test
void valuesAreClampedBelowZero() {
SoilRegionData d = new SoilRegionData(-10.0, -10.0, -10.0, -10.0, -10.0);
assertEquals(0.0, d.getFertility(), 1e-9);
assertEquals(0.0, d.getMoisture(), 1e-9);
assertEquals(0.0, d.getContamination(), 1e-9);
assertEquals(0.0, d.getCompaction(), 1e-9);
assertEquals(0.0, d.getErosion(), 1e-9);
}
@Test
void copyIsIndependent() {
SoilRegionData original = SoilRegionData.defaults();
SoilRegionData copy = original.copy();
copy.degrade(30.0);
assertEquals(60.0, original.getFertility(), 1e-9);
assertEquals(0.0, original.getContamination(), 1e-9);
}
@Test
void settersClampValues() {
SoilRegionData d = SoilRegionData.defaults();
d.setFertility(-5.0);
d.setErosion(999.0);
assertEquals(0.0, d.getFertility(), 1e-9);
assertEquals(100.0, d.getErosion(), 1e-9);
}
}
@@ -0,0 +1,95 @@
package com.livingworld.modules.vegetation;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class VegetationRegionDataTest {
@Test
void defaultsAreHealthyMixedVegetation() {
VegetationRegionData d = VegetationRegionData.defaults();
assertEquals(50.0, d.getGrassPressure(), 1e-9);
assertEquals(30.0, d.getFlowerPressure(), 1e-9);
assertEquals(30.0, d.getShrubPressure(), 1e-9);
assertEquals(40.0, d.getTreePressure(), 1e-9);
assertEquals(5.0, d.getDeadVegetation(), 1e-9);
}
@Test
void reduceFromLoggingDecreasesTressAndShrubs() {
VegetationRegionData d = VegetationRegionData.defaults();
d.reduceFromLogging(20.0);
assertTrue(d.getTreePressure() < 40.0, "tree pressure should drop");
assertTrue(d.getShrubPressure() < 30.0, "shrub pressure should drop");
assertTrue(d.getDeadVegetation() > 5.0, "dead vegetation should increase");
}
@Test
void reduceFromLoggingByZeroChangesNothing() {
VegetationRegionData d = VegetationRegionData.defaults();
d.reduceFromLogging(0.0);
assertEquals(40.0, d.getTreePressure(), 1e-9);
assertEquals(30.0, d.getShrubPressure(), 1e-9);
assertEquals(5.0, d.getDeadVegetation(), 1e-9);
}
@Test
void reduceFromLoggingNegativeThrows() {
assertThrows(IllegalArgumentException.class,
() -> VegetationRegionData.defaults().reduceFromLogging(-1.0));
}
@Test
void recoverIncreasesAllLivingPressures() {
VegetationRegionData d = new VegetationRegionData(10.0, 5.0, 5.0, 5.0, 30.0);
d.recover(10.0);
assertTrue(d.getGrassPressure() > 10.0, "grass should increase");
assertTrue(d.getFlowerPressure() > 5.0, "flowers should increase");
assertTrue(d.getShrubPressure() > 5.0, "shrubs should increase");
assertTrue(d.getTreePressure() > 5.0, "trees should increase");
assertTrue(d.getDeadVegetation() < 30.0, "dead vegetation should decrease");
}
@Test
void recoverNegativeThrows() {
assertThrows(IllegalArgumentException.class,
() -> VegetationRegionData.defaults().recover(-1.0));
}
@Test
void constructorClampsAbove100() {
VegetationRegionData d = new VegetationRegionData(200.0, 200.0, 200.0, 200.0, 200.0);
assertEquals(100.0, d.getGrassPressure(), 1e-9);
assertEquals(100.0, d.getFlowerPressure(), 1e-9);
assertEquals(100.0, d.getShrubPressure(), 1e-9);
assertEquals(100.0, d.getTreePressure(), 1e-9);
assertEquals(100.0, d.getDeadVegetation(), 1e-9);
}
@Test
void constructorClampsBelowZero() {
VegetationRegionData d = new VegetationRegionData(-1.0, -1.0, -1.0, -1.0, -1.0);
assertEquals(0.0, d.getGrassPressure(), 1e-9);
assertEquals(0.0, d.getFlowerPressure(), 1e-9);
assertEquals(0.0, d.getShrubPressure(), 1e-9);
assertEquals(0.0, d.getTreePressure(), 1e-9);
assertEquals(0.0, d.getDeadVegetation(), 1e-9);
}
@Test
void copyIsIndependent() {
VegetationRegionData original = VegetationRegionData.defaults();
VegetationRegionData copy = original.copy();
copy.reduceFromLogging(40.0);
assertEquals(40.0, original.getTreePressure(), 1e-9);
}
@Test
void settersClampValues() {
VegetationRegionData d = VegetationRegionData.defaults();
d.setGrassPressure(-5.0);
d.setDeadVegetation(999.0);
assertEquals(0.0, d.getGrassPressure(), 1e-9);
assertEquals(100.0, d.getDeadVegetation(), 1e-9);
}
}
@@ -0,0 +1,172 @@
package com.livingworld.modules.water;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory;
import com.livingworld.regions.RegionMetrics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("WaterModule")
class WaterModuleTest {
private WaterModule module;
private RegionFactory factory;
private Region region;
private RegionMetrics metrics;
@BeforeEach
void setUp() {
module = new WaterModule();
factory = new RegionFactory();
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
metrics = region.getMetrics();
module.createDefaultRegionData(region);
}
@Test
@DisplayName("moduleId is 'water'")
void moduleId() {
assertEquals("water", module.getModuleId());
}
@Test
@DisplayName("metadata is non-null and has correct id")
void metadata() {
assertNotNull(module.getMetadata());
assertEquals("water", module.getMetadata().moduleId());
}
@Test
@DisplayName("initialize throws on null context")
void initializeNullContextThrows() {
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
}
@Test
@DisplayName("createDefaultRegionData populates module data")
void createDefaultRegionDataPopulates() {
Region fresh = factory.createNewRegion(new RegionCoordinate("overworld", 1, 0), 0L);
module.createDefaultRegionData(fresh);
assertTrue(fresh.getModuleData().get(WaterModule.MODULE_ID, WaterRegionData.class).isPresent());
}
@Test
@DisplayName("createDefaultRegionData is idempotent")
void createDefaultRegionDataIdempotent() {
WaterRegionData before = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
module.createDefaultRegionData(region);
WaterRegionData after = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
assertEquals(before.getPurificationCapacity(), after.getPurificationCapacity(), 1e-9);
}
@Test
@DisplayName("updateRegion throws on null context")
void updateRegionNullThrows() {
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
}
@Test
@DisplayName("high vegetation pressure sets purification capacity")
void highVegetationIncreasesPurification() {
metrics.setVegetationPressure(80.0);
metrics.setWaterQuality(50.0);
metrics.setSoilQuality(70.0); // above leach threshold
module.updateRegion(new RegionUpdateContext(region));
WaterRegionData data = region.getModuleData()
.get(WaterModule.MODULE_ID, WaterRegionData.class).orElseThrow();
// purification = 80 * 0.50 = 40
assertEquals(40.0, data.getPurificationCapacity(), 0.01);
}
@Test
@DisplayName("vegetation purification raises water quality")
void vegetationPurificationRaisesWaterQuality() {
metrics.setVegetationPressure(80.0);
metrics.setWaterQuality(50.0);
metrics.setSoilQuality(70.0); // above leach threshold — no leaching
module.updateRegion(new RegionUpdateContext(region));
// Recovery = 40 * 0.01 = 0.4 gain
assertTrue(region.getMetrics().getWaterQuality() > 50.0,
"Water quality should rise with vegetation purification");
}
@Test
@DisplayName("low soil quality leaches contamination into water")
void lowSoilQualityLeachesWater() {
metrics.setVegetationPressure(0.0); // no purification
metrics.setWaterQuality(80.0);
metrics.setSoilQuality(10.0); // well below threshold of 40
module.updateRegion(new RegionUpdateContext(region));
// leach = (40 - 10) * 0.005 = 0.15 reduction
assertTrue(region.getMetrics().getWaterQuality() < 80.0,
"Water quality should fall when soil is contaminated");
}
@Test
@DisplayName("soil above threshold causes no leaching")
void soilAboveThresholdNoLeach() {
metrics.setVegetationPressure(0.0);
metrics.setWaterQuality(60.0);
metrics.setSoilQuality(50.0); // above threshold
double prevWQ = metrics.getWaterQuality();
module.updateRegion(new RegionUpdateContext(region));
// No leach; no purification either (veg=0). Water quality unchanged.
assertEquals(prevWQ, region.getMetrics().getWaterQuality(), 1e-9);
}
@Test
@DisplayName("returns noChange when water quality is stable")
void noChangeWhenStable() {
metrics.setVegetationPressure(0.0);
metrics.setSoilQuality(50.0);
metrics.setWaterQuality(60.0);
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
assertFalse(result.changedRegion());
}
@Test
@DisplayName("returns changed when water quality shifts")
void changedWhenWaterQualityShifts() {
metrics.setVegetationPressure(80.0);
metrics.setSoilQuality(70.0);
metrics.setWaterQuality(50.0);
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
assertTrue(result.changedRegion());
}
@Test
@DisplayName("water quality stays in [0, 100] under extreme conditions")
void waterQualityBounded() {
metrics.setVegetationPressure(100.0);
metrics.setSoilQuality(0.0);
metrics.setWaterQuality(0.0);
for (int i = 0; i < 200; i++) {
module.updateRegion(new RegionUpdateContext(region));
}
double wq = region.getMetrics().getWaterQuality();
assertTrue(wq >= 0.0 && wq <= 100.0,
"Water quality must stay in [0, 100], was: " + wq);
}
}
@@ -0,0 +1,284 @@
package com.livingworld.modules.worldeffects;
import com.livingworld.modules.ModuleUpdateResult;
import com.livingworld.modules.RegionUpdateContext;
import com.livingworld.modules.recovery.RecoveryModule;
import com.livingworld.modules.recovery.RecoveryRegionData;
import com.livingworld.modules.recovery.SuccessionStage;
import com.livingworld.modules.resources.ResourceDepletionModule;
import com.livingworld.modules.resources.ResourceRegionData;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFactory;
import com.livingworld.regions.RegionMetrics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("WorldEffectsModule")
class WorldEffectsModuleTest {
private WorldEffectsModule module;
private RegionFactory factory;
private Region region;
private RegionMetrics metrics;
private List<WorldEffectRequest> captured;
@BeforeEach
void setUp() {
module = new WorldEffectsModule();
captured = new ArrayList<>();
module.registerConsumer(captured::add);
factory = new RegionFactory();
region = factory.createNewRegion(new RegionCoordinate("overworld", 0, 0), 0L);
metrics = region.getMetrics();
module.createDefaultRegionData(region);
}
@Test
@DisplayName("moduleId is 'worldeffects'")
void moduleId() {
assertEquals("worldeffects", module.getModuleId());
}
@Test
@DisplayName("metadata is non-null with correct id")
void metadata() {
assertNotNull(module.getMetadata());
assertEquals("worldeffects", module.getMetadata().moduleId());
}
@Test
@DisplayName("initialize throws on null context")
void initializeNullThrows() {
assertThrows(IllegalArgumentException.class, () -> module.initialize(null));
}
@Test
@DisplayName("registerConsumer throws on null")
void registerNullThrows() {
assertThrows(IllegalArgumentException.class, () -> module.registerConsumer(null));
}
@Test
@DisplayName("updateRegion throws on null context")
void updateNullThrows() {
assertThrows(IllegalArgumentException.class, () -> module.updateRegion(null));
}
@Test
@DisplayName("no consumer registered → noChange returned")
void noConsumerNoChange() {
WorldEffectsModule fresh = new WorldEffectsModule();
metrics.setPollutionScore(80.0);
ModuleUpdateResult result = fresh.updateRegion(new RegionUpdateContext(region));
assertFalse(result.changedRegion());
}
@Test
@DisplayName("GRASS_DEGRADES_TO_DIRT emitted when pollution > 60 and soil < 30")
void grassDegrades() {
metrics.setPollutionScore(80.0);
metrics.setSoilQuality(15.0);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
assertTrue(found, "GRASS_DEGRADES_TO_DIRT should have been emitted");
}
@Test
@DisplayName("GRASS_DEGRADES_TO_DIRT not emitted when pollution is low")
void grassDegradeNotEmittedLowPollution() {
metrics.setPollutionScore(10.0);
metrics.setSoilQuality(15.0);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.GRASS_DEGRADES_TO_DIRT);
assertFalse(found, "GRASS_DEGRADES_TO_DIRT should NOT be emitted with low pollution");
}
@Test
@DisplayName("VEGETATION_SPREADS emitted when vegetationPressure > 60 and soilQuality > 50")
void vegetationSpreads() {
metrics.setVegetationPressure(80.0);
metrics.setSoilQuality(70.0);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
assertTrue(found, "VEGETATION_SPREADS should have been emitted");
}
@Test
@DisplayName("VEGETATION_SPREADS not emitted when soil is poor")
void vegetationSpreadsNotEmittedPoorSoil() {
metrics.setVegetationPressure(80.0);
metrics.setSoilQuality(30.0); // below threshold
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.VEGETATION_SPREADS);
assertFalse(found, "VEGETATION_SPREADS should NOT be emitted with poor soil");
}
@Test
@DisplayName("SAPLING_GROWTH_SLOWED emitted when logging depletion > 50")
void saplingGrowthSlowed() {
ResourceRegionData resources = ResourceRegionData.defaults();
resources.recordLogging(70.0);
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
assertTrue(found, "SAPLING_GROWTH_SLOWED should have been emitted");
}
@Test
@DisplayName("SAPLING_GROWTH_SLOWED not emitted when logging depletion is low")
void saplingSlowNotEmittedLowLogging() {
ResourceRegionData resources = ResourceRegionData.defaults();
resources.recordLogging(20.0); // below threshold
region.getModuleData().put(ResourceDepletionModule.MODULE_ID, resources);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_SLOWED);
assertFalse(found, "SAPLING_GROWTH_SLOWED should NOT be emitted with low logging");
}
@Test
@DisplayName("SAPLING_GROWTH_BOOSTED emitted when succession stage >= YOUNG_WOODLAND")
void saplingGrowthBoostedAtYoungWoodland() {
RecoveryRegionData recovery = new RecoveryRegionData(
SuccessionStage.YOUNG_WOODLAND, 0.0, 0.0);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
assertTrue(found, "SAPLING_GROWTH_BOOSTED should have been emitted at YOUNG_WOODLAND");
}
@Test
@DisplayName("SAPLING_GROWTH_BOOSTED not emitted below YOUNG_WOODLAND")
void saplingBoostNotEmittedBelowYoungWoodland() {
RecoveryRegionData recovery = new RecoveryRegionData(
SuccessionStage.SCRUBLAND, 0.0, 0.0);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.SAPLING_GROWTH_BOOSTED);
assertFalse(found, "SAPLING_GROWTH_BOOSTED should NOT be emitted below YOUNG_WOODLAND");
}
@Test
@DisplayName("POLLUTION_VISUAL_INDICATOR emitted when pollutionScore > 70")
void pollutionVisualIndicator() {
metrics.setPollutionScore(85.0);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.POLLUTION_VISUAL_INDICATOR);
assertTrue(found, "POLLUTION_VISUAL_INDICATOR should have been emitted");
}
@Test
@DisplayName("POLLUTION_VISUAL_INDICATOR not emitted below threshold")
void pollutionVisualNotEmitted() {
metrics.setPollutionScore(5.0);
module.updateRegion(new RegionUpdateContext(region));
boolean found = captured.stream()
.anyMatch(r -> r.type() == WorldEffectType.POLLUTION_VISUAL_INDICATOR);
assertFalse(found, "POLLUTION_VISUAL_INDICATOR should NOT be emitted below threshold");
}
@Test
@DisplayName("emitted request has intensity in [0, 1]")
void requestIntensityInRange() {
metrics.setPollutionScore(80.0);
metrics.setSoilQuality(15.0);
module.updateRegion(new RegionUpdateContext(region));
for (WorldEffectRequest request : captured) {
assertTrue(request.intensity() >= 0.0 && request.intensity() <= 1.0,
"Intensity out of range: " + request.intensity() + " for " + request.type());
}
}
@Test
@DisplayName("emitted request carries the correct region coordinate")
void requestHasCorrectCoordinate() {
metrics.setPollutionScore(85.0);
module.updateRegion(new RegionUpdateContext(region));
assertFalse(captured.isEmpty(), "Should have emitted at least one request");
assertEquals(region.getCoordinate(), captured.get(0).region());
}
@Test
@DisplayName("returns noChange when no effects are triggered")
void noChangeWhenNoEffects() {
// Default metrics: pollution=0, veg=50, soil=60 — none of the five conditions met
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
assertFalse(result.changedRegion());
assertTrue(captured.isEmpty());
}
@Test
@DisplayName("returns changed when at least one effect is triggered")
void changedWhenEffectTriggered() {
metrics.setPollutionScore(85.0);
ModuleUpdateResult result = module.updateRegion(new RegionUpdateContext(region));
assertTrue(result.changedRegion());
}
@Test
@DisplayName("multiple consumers all receive the same requests")
void multipleConsumersAllReceive() {
List<WorldEffectRequest> second = new ArrayList<>();
module.registerConsumer(second::add);
metrics.setPollutionScore(85.0);
module.updateRegion(new RegionUpdateContext(region));
assertFalse(captured.isEmpty());
assertEquals(captured.size(), second.size(),
"Both consumers should receive the same number of requests");
}
@Test
@DisplayName("NO_OP consumer can be registered without error")
void noOpConsumer() {
WorldEffectsModule fresh = new WorldEffectsModule();
assertDoesNotThrow(() -> fresh.registerConsumer(WorldEffectConsumer.NO_OP));
}
@Test
@DisplayName("shutdown clears all registered consumers")
void shutdownClearsConsumers() {
module.shutdown();
assertTrue(module.getConsumers().isEmpty(), "Consumers should be cleared after shutdown");
}
}
@@ -158,6 +158,11 @@ class LongRunSimulationTest {
.toList(); .toList();
} }
@Override
public Collection<Region> getActiveRegions() {
return orderedRegions;
}
@Override @Override
public void markDirty(Region region) { public void markDirty(Region region) {
region.markDirty(); region.markDirty();