diff --git a/src/main/java/com/livingworld/LivingWorldMod.java b/src/main/java/com/livingworld/LivingWorldMod.java index 12fe0c0..daf1e4a 100644 --- a/src/main/java/com/livingworld/LivingWorldMod.java +++ b/src/main/java/com/livingworld/LivingWorldMod.java @@ -51,9 +51,12 @@ public class LivingWorldMod { bootstrap.onServerStarted(); bootstrap.getWorldEffectsModule().registerConsumer( new NeoForgeWorldEffectExecutor(() -> minecraftServer)); + bootstrap.setOverworldRaining( + () -> minecraftServer != null && minecraftServer.overworld().isRaining()); }); NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> { bootstrap.onServerStopping(); + bootstrap.setOverworldRaining(null); this.minecraftServer = null; }); diff --git a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java index 45fc9df..31fb6ee 100644 --- a/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java +++ b/src/main/java/com/livingworld/bootstrap/LivingWorldBootstrap.java @@ -20,6 +20,7 @@ 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; @@ -52,6 +53,10 @@ import net.minecraft.commands.CommandSourceStack; */ public final class LivingWorldBootstrap { + private static final double RAIN_MOISTURE_GAIN = 0.3; + private static final double RAIN_DROUGHT_RELIEF = 0.15; + private static final double DRY_DROUGHT_INCREASE = 0.05; + private PlatformAdapter platformAdapter; private Path worldSaveDirectory; private ServiceRegistry services; @@ -59,6 +64,7 @@ public final class LivingWorldBootstrap { private ModuleRegistry moduleRegistry; private SimulationManager simulationManager; private WorldEffectsModule worldEffectsModule; + private BooleanSupplier overworldRaining = () -> false; private boolean initialized; private boolean serverReady; @@ -185,6 +191,14 @@ public final class LivingWorldBootstrap { "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. + */ + public void setOverworldRaining(BooleanSupplier supplier) { + this.overworldRaining = supplier != null ? supplier : () -> false; + } + public void onServerTick() { if (!serverReady) { return; @@ -192,10 +206,34 @@ public final class LivingWorldBootstrap { long previousSimulationTick = simulationManager.getSimulationTickCounter(); simulationManager.onMinecraftServerTick(); if (simulationManager.getSimulationTickCounter() != previousSimulationTick) { + applyWeatherFeedback(); regionManager.saveDirtyRegions(); } } + 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); + } else { + water.setDroughtRisk(water.getDroughtRisk() + DRY_DROUGHT_INCREASE); + } + region.getModuleData().put(WaterModule.MODULE_ID, water); + regionManager.markDirty(region); + } + } + public void registerCommands(CommandDispatcher dispatcher) { requireInitialized(); LivingWorldCommandRoot.registerDeferred( diff --git a/src/main/java/com/livingworld/commands/ForceUpdateCommand.java b/src/main/java/com/livingworld/commands/ForceUpdateCommand.java new file mode 100644 index 0000000..6a64c21 --- /dev/null +++ b/src/main/java/com/livingworld/commands/ForceUpdateCommand.java @@ -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; + } +} diff --git a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java index 83c3590..87c78b1 100644 --- a/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java +++ b/src/main/java/com/livingworld/commands/LivingWorldCommandRoot.java @@ -64,14 +64,30 @@ public final class LivingWorldCommandRoot { requireService(simulationManager, "simulationManager")))) .then(Commands.literal("region") .then(Commands.literal("info") - .executes(context -> RegionInfoCommand.execute( + .executes(context -> RegionInfoCommand.executeAtSelf( context.getSource(), - requireService(regionManager, "regionManager"))))) + requireService(regionManager, "regionManager"))) + .then(Commands.argument("x", IntegerArgumentType.integer()) + .then(Commands.argument("z", IntegerArgumentType.integer()) + .executes(context -> RegionInfoCommand.executeAt( + context.getSource(), + requireService(regionManager, "regionManager"), + IntegerArgumentType.getInteger(context, "x"), + IntegerArgumentType.getInteger(context, "z")))))) + .then(Commands.literal("force-update") + .executes(context -> ForceUpdateCommand.executeAtSelf( + context.getSource(), + requireService(regionManager, "regionManager"), + requireService(simulationManager, "simulationManager"))))) .then(Commands.literal("modules") .then(Commands.literal("list") .executes(context -> listModules( context.getSource(), requireService(moduleRegistry, "moduleRegistry"))))) + .then(Commands.literal("stats") + .executes(context -> StatsCommand.execute( + context.getSource(), + requireService(simulationManager, "simulationManager")))) .then(Commands.literal("simulate") .then(Commands.argument( "ticks", diff --git a/src/main/java/com/livingworld/commands/RegionInfoCommand.java b/src/main/java/com/livingworld/commands/RegionInfoCommand.java index 0d9b1af..a7252ad 100644 --- a/src/main/java/com/livingworld/commands/RegionInfoCommand.java +++ b/src/main/java/com/livingworld/commands/RegionInfoCommand.java @@ -1,44 +1,55 @@ package com.livingworld.commands; +import com.livingworld.regions.Region; +import com.livingworld.regions.RegionManager; import net.minecraft.commands.CommandSourceStack; import net.minecraft.network.chat.Component; import net.minecraft.world.phys.Vec3; -import com.livingworld.regions.Region; -import com.livingworld.regions.RegionManager; - /** - * Prints the Living World region state at the command source position. + * Prints the Living World region state — either at the caller's position or + * at explicit block coordinates. */ public final class RegionInfoCommand { private RegionInfoCommand() { } - public static int execute( + /** Uses the command-source position (player standing location). */ + public static int executeAtSelf( CommandSourceStack source, RegionManager regionManager) { - if (source == null) { - throw new IllegalArgumentException("source must not be null"); - } - if (regionManager == null) { - throw new IllegalArgumentException("regionManager must not be null"); - } + if (source == null) throw new IllegalArgumentException("source must not be null"); + if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null"); Vec3 position = source.getPosition(); String dimensionId = source.getLevel().dimension().location().toString(); Region region = regionManager.getOrCreateRegionAtBlock( dimensionId, - floorToBlock(position.x), - floorToBlock(position.z)); + (int) Math.floor(position.x), + (int) Math.floor(position.z)); + return sendLines(source, region); + } + + /** Uses explicit block X/Z coordinates in the caller's current dimension. */ + public static int executeAt( + CommandSourceStack source, + RegionManager regionManager, + int blockX, + int blockZ) { + if (source == null) throw new IllegalArgumentException("source must not be null"); + if (regionManager == null) throw new IllegalArgumentException("regionManager must not be null"); + + String dimensionId = source.getLevel().dimension().location().toString(); + Region region = regionManager.getOrCreateRegionAtBlock(dimensionId, blockX, blockZ); + return sendLines(source, region); + } + + private static int sendLines(CommandSourceStack source, Region region) { for (String line : RegionInfoFormatter.format(region)) { source.sendSuccess(() -> Component.literal(line), false); } return 1; } - - private static int floorToBlock(double coordinate) { - return (int) Math.floor(coordinate); - } } diff --git a/src/main/java/com/livingworld/commands/RegionInfoFormatter.java b/src/main/java/com/livingworld/commands/RegionInfoFormatter.java index 3a20844..135e3df 100644 --- a/src/main/java/com/livingworld/commands/RegionInfoFormatter.java +++ b/src/main/java/com/livingworld/commands/RegionInfoFormatter.java @@ -1,12 +1,25 @@ package com.livingworld.commands; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; - +import com.livingworld.modules.ecosystem.EcosystemModule; +import com.livingworld.modules.ecosystem.EcosystemRegionData; +import com.livingworld.modules.pollution.PollutionModule; +import com.livingworld.modules.pollution.PollutionRegionData; +import com.livingworld.modules.recovery.RecoveryModule; +import com.livingworld.modules.recovery.RecoveryRegionData; +import com.livingworld.modules.resources.ResourceDepletionModule; +import com.livingworld.modules.resources.ResourceRegionData; +import com.livingworld.modules.soil.SoilModule; +import com.livingworld.modules.soil.SoilRegionData; +import com.livingworld.modules.vegetation.VegetationModule; +import com.livingworld.modules.vegetation.VegetationRegionData; +import com.livingworld.modules.water.WaterModule; +import com.livingworld.modules.water.WaterRegionData; import com.livingworld.regions.Region; import com.livingworld.regions.RegionFlags; import com.livingworld.regions.RegionMetrics; +import com.livingworld.regions.RegionModuleData; +import java.util.ArrayList; +import java.util.List; /** * Formats region diagnostics without depending on Minecraft classes. @@ -21,26 +34,84 @@ public final class RegionInfoFormatter { throw new IllegalArgumentException("region must not be null"); } - RegionMetrics metrics = region.getMetrics(); - RegionFlags flags = region.getFlags(); - Set moduleIds = new TreeSet<>(region.getModuleData().moduleIds()); + RegionMetrics m = region.getMetrics(); + RegionFlags f = region.getFlags(); + RegionModuleData data = region.getModuleData(); - return List.of( - "Region: " + region.getCoordinate().stableId(), - "Lifecycle: " + region.getLifecycleState() + ", dirty=" + region.isDirty(), - "Metrics: ecosystemHealth=" + metrics.getEcosystemHealth() - + ", pollution=" + metrics.getPollutionScore() - + ", soilQuality=" + metrics.getSoilQuality() - + ", waterQuality=" + metrics.getWaterQuality() - + ", vegetationPressure=" + metrics.getVegetationPressure() - + ", resourceDepletion=" + metrics.getResourceDepletion() - + ", recoveryPressure=" + metrics.getRecoveryPressure(), - "Flags: playerActivity=" + flags.isHasPlayerActivity() - + ", highPollution=" + flags.isHasHighPollution() - + ", lowSoilQuality=" + flags.isHasLowSoilQuality() - + ", activeEcosystemEvent=" + flags.isHasActiveEcosystemEvent() - + ", forceLoaded=" + flags.isForceLoadedBySimulation() - + ", corrupted=" + flags.isCorrupted(), - "Module data: " + (moduleIds.isEmpty() ? "none" : String.join(", ", moduleIds))); + List lines = new ArrayList<>(); + lines.add("Region: " + region.getCoordinate().stableId() + + " lifecycle=" + region.getLifecycleState() + + " dirty=" + region.isDirty() + + " tick=" + region.getLastUpdatedSimulationTick()); + lines.add("Metrics:" + + " health=" + fmt(m.getEcosystemHealth()) + + " poll=" + fmt(m.getPollutionScore()) + + " soil=" + fmt(m.getSoilQuality()) + + " water=" + fmt(m.getWaterQuality()) + + " veg=" + fmt(m.getVegetationPressure()) + + " res=" + fmt(m.getResourceDepletion()) + + " recov=" + fmt(m.getRecoveryPressure())); + lines.add("Flags:" + + " playerActivity=" + f.isHasPlayerActivity() + + " highPollution=" + f.isHasHighPollution() + + " lowSoil=" + f.isHasLowSoilQuality() + + " ecoEvent=" + f.isHasActiveEcosystemEvent() + + " forceLoaded=" + f.isForceLoadedBySimulation()); + + lines.add("--- Module Data ---"); + data.get(PollutionModule.MODULE_ID, PollutionRegionData.class).ifPresentOrElse( + d -> lines.add(" pollution: air=" + fmt(d.getAirPollution()) + + " ground=" + fmt(d.getGroundPollution()) + + " water=" + fmt(d.getWaterPollution()) + + " decay=" + fmt(d.getDecayResistance())), + () -> lines.add(" pollution: (no data)")); + + data.get(SoilModule.MODULE_ID, SoilRegionData.class).ifPresentOrElse( + d -> lines.add(" soil: fertility=" + fmt(d.getFertility()) + + " moisture=" + fmt(d.getMoisture()) + + " contam=" + fmt(d.getContamination()) + + " compact=" + fmt(d.getCompaction()) + + " erosion=" + fmt(d.getErosion())), + () -> lines.add(" soil: (no data)")); + + data.get(WaterModule.MODULE_ID, WaterRegionData.class).ifPresentOrElse( + d -> lines.add(" water: avail=" + fmt(d.getWaterAvailability()) + + " purif=" + fmt(d.getPurificationCapacity()) + + " drought=" + fmt(d.getDroughtRisk()) + + " flood=" + fmt(d.getFloodRisk())), + () -> lines.add(" water: (no data)")); + + data.get(VegetationModule.MODULE_ID, VegetationRegionData.class).ifPresentOrElse( + d -> lines.add(" vegetation: grass=" + fmt(d.getGrassPressure()) + + " flower=" + fmt(d.getFlowerPressure()) + + " shrub=" + fmt(d.getShrubPressure()) + + " tree=" + fmt(d.getTreePressure()) + + " dead=" + fmt(d.getDeadVegetation())), + () -> lines.add(" vegetation: (no data)")); + + data.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).ifPresentOrElse( + d -> lines.add(" resources: mining=" + fmt(d.getMiningDepletion()) + + " logging=" + fmt(d.getLoggingDepletion()) + + " farming=" + fmt(d.getFarmingDepletion())), + () -> lines.add(" resources: (no data)")); + + data.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).ifPresentOrElse( + d -> lines.add(" recovery: stage=" + d.getSuccessionStage() + + " progress=" + fmt(d.getRecoveryProgress()) + + " damage=" + fmt(d.getDamageAccumulation())), + () -> lines.add(" recovery: (no data)")); + + data.get(EcosystemModule.MODULE_ID, EcosystemRegionData.class).ifPresentOrElse( + d -> lines.add(" ecosystem: health=" + fmt(d.getEcosystemHealth()) + + " stress=" + fmt(d.getStress()) + + " resilience=" + fmt(d.getResilience()) + + " rate=" + fmt(d.getRecoveryRate())), + () -> lines.add(" ecosystem: (no data)")); + + return lines; + } + + private static String fmt(double v) { + return String.format("%.1f", v); } } diff --git a/src/main/java/com/livingworld/commands/StatsCommand.java b/src/main/java/com/livingworld/commands/StatsCommand.java new file mode 100644 index 0000000..868bdff --- /dev/null +++ b/src/main/java/com/livingworld/commands/StatsCommand.java @@ -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; + } +} diff --git a/src/main/java/com/livingworld/core/simulation/SimulationManager.java b/src/main/java/com/livingworld/core/simulation/SimulationManager.java index 19a4534..24ad402 100644 --- a/src/main/java/com/livingworld/core/simulation/SimulationManager.java +++ b/src/main/java/com/livingworld/core/simulation/SimulationManager.java @@ -8,6 +8,8 @@ import com.livingworld.core.services.PersistenceService; import com.livingworld.core.services.TimeService; import com.livingworld.debug.DiagnosticCategory; import com.livingworld.debug.LivingWorldLogger; +import com.livingworld.debug.SimulationProfileSnapshot; +import com.livingworld.regions.RegionCoordinate; import com.livingworld.events.LivingWorldEventBus; import com.livingworld.modules.ModuleUpdateResult; import com.livingworld.modules.ModuleRegistry; @@ -192,6 +194,32 @@ 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 = regionManager.resolve(coordinate); + if (region.isEmpty()) { + return; + } + long tick = getSimulationTickCounter(); + RegionUpdateJob job = new RegionUpdateJob( + coordinate, 100, tick, null, UpdateReason.FORCED_DEBUG_COMMAND); + runModulesForRegion(region.get(), job, tick); + } + + /** Returns a profile snapshot of the last completed simulation cycle. */ + public SimulationProfileSnapshot createProfileSnapshot() { + if (profiler instanceof com.livingworld.debug.SimulationProfiler concrete) { + return concrete.createSnapshot(); + } + return new SimulationProfileSnapshot(0L, java.util.Map.of(), 0, 0, 0, false); + } + public long getMinecraftTickCounter() { return this.scheduler.getMinecraftTickCounter(); } diff --git a/src/test/java/com/livingworld/commands/RegionInfoFormatterTest.java b/src/test/java/com/livingworld/commands/RegionInfoFormatterTest.java index 6b99721..8fce3d0 100644 --- a/src/test/java/com/livingworld/commands/RegionInfoFormatterTest.java +++ b/src/test/java/com/livingworld/commands/RegionInfoFormatterTest.java @@ -1,36 +1,79 @@ package com.livingworld.commands; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.livingworld.modules.pollution.PollutionRegionData; +import com.livingworld.modules.recovery.RecoveryRegionData; +import com.livingworld.modules.recovery.SuccessionStage; import java.util.List; - import org.junit.jupiter.api.Test; - import com.livingworld.regions.Region; import com.livingworld.regions.RegionCoordinate; import com.livingworld.regions.RegionFactory; class RegionInfoFormatterTest { - @Test - void includesIdentityLifecycleMetricsFlagsAndSortedModuleIds() { - Region region = new RegionFactory().createNewRegion( + private static Region region() { + return new RegionFactory().createNewRegion( new RegionCoordinate("minecraft:overworld", -1, 2), 0); - region.getMetrics().setPollutionScore(75); - region.getFlags().setHasHighPollution(true); - region.getModuleData().put("water", "data"); - region.getModuleData().put("soil", "data"); + } - List lines = RegionInfoFormatter.format(region); + @Test + void headerContainsRegionId() { + List lines = RegionInfoFormatter.format(region()); + assertTrue(lines.get(0).contains("minecraft:overworld:-1:2"), + "header should contain stable ID; got: " + lines.get(0)); + assertTrue(lines.get(0).contains("ACTIVE"), lines.get(0)); + } - assertEquals(5, lines.size()); - assertTrue(lines.get(0).contains("minecraft:overworld:-1:2")); - assertTrue(lines.get(1).contains("ACTIVE")); - assertTrue(lines.get(2).contains("pollution=75.0")); - assertTrue(lines.get(3).contains("highPollution=true")); - assertEquals("Module data: soil, water", lines.get(4)); + @Test + void metricsLineContainsPollutionScore() { + Region r = region(); + r.getMetrics().setPollutionScore(75); + List lines = RegionInfoFormatter.format(r); + assertTrue(lines.stream().anyMatch(l -> l.contains("poll=75.0")), + "metrics line should contain poll=75.0"); + } + + @Test + void flagsLineContainsHighPollution() { + Region r = region(); + r.getFlags().setHasHighPollution(true); + List lines = RegionInfoFormatter.format(r); + assertTrue(lines.stream().anyMatch(l -> l.contains("highPollution=true")), + "flags line should contain highPollution=true"); + } + + @Test + void moduleDataSectionPresentForAllModules() { + List 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 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 lines = RegionInfoFormatter.format(region()); + assertTrue(lines.stream().anyMatch(l -> l.contains("(no data)"))); } @Test