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>
This commit is contained in:
George
2026-06-07 16:20:48 +01:00
parent 4fd9bb97aa
commit 6e6de00f0d
9 changed files with 351 additions and 60 deletions
@@ -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;
});
@@ -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<CommandSourceStack> dispatcher) {
requireInitialized();
LivingWorldCommandRoot.registerDeferred(
@@ -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;
}
}
@@ -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",
@@ -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);
}
}
@@ -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<String> 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<String> 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);
}
}
@@ -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;
}
}
@@ -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> 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();
}
@@ -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<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());
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<String> 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<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