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:
@@ -51,9 +51,12 @@ public class LivingWorldMod {
|
|||||||
bootstrap.onServerStarted();
|
bootstrap.onServerStarted();
|
||||||
bootstrap.getWorldEffectsModule().registerConsumer(
|
bootstrap.getWorldEffectsModule().registerConsumer(
|
||||||
new NeoForgeWorldEffectExecutor(() -> minecraftServer));
|
new NeoForgeWorldEffectExecutor(() -> minecraftServer));
|
||||||
|
bootstrap.setOverworldRaining(
|
||||||
|
() -> minecraftServer != null && minecraftServer.overworld().isRaining());
|
||||||
});
|
});
|
||||||
NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> {
|
NeoForge.EVENT_BUS.addListener(ServerStoppingEvent.class, event -> {
|
||||||
bootstrap.onServerStopping();
|
bootstrap.onServerStopping();
|
||||||
|
bootstrap.setOverworldRaining(null);
|
||||||
this.minecraftServer = null;
|
this.minecraftServer = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.livingworld.modules.ServerContext;
|
|||||||
import com.livingworld.modules.ecosystem.EcosystemModule;
|
import com.livingworld.modules.ecosystem.EcosystemModule;
|
||||||
import com.livingworld.modules.pollution.PollutionModule;
|
import com.livingworld.modules.pollution.PollutionModule;
|
||||||
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
import com.livingworld.modules.ecosystem.EcosystemRegionData;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
import com.livingworld.modules.pollution.PollutionRegionData;
|
import com.livingworld.modules.pollution.PollutionRegionData;
|
||||||
import com.livingworld.modules.recovery.RecoveryModule;
|
import com.livingworld.modules.recovery.RecoveryModule;
|
||||||
import com.livingworld.modules.recovery.RecoveryRegionData;
|
import com.livingworld.modules.recovery.RecoveryRegionData;
|
||||||
@@ -52,6 +53,10 @@ 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 PlatformAdapter platformAdapter;
|
private PlatformAdapter platformAdapter;
|
||||||
private Path worldSaveDirectory;
|
private Path worldSaveDirectory;
|
||||||
private ServiceRegistry services;
|
private ServiceRegistry services;
|
||||||
@@ -59,6 +64,7 @@ public final class LivingWorldBootstrap {
|
|||||||
private ModuleRegistry moduleRegistry;
|
private ModuleRegistry moduleRegistry;
|
||||||
private SimulationManager simulationManager;
|
private SimulationManager simulationManager;
|
||||||
private WorldEffectsModule worldEffectsModule;
|
private WorldEffectsModule worldEffectsModule;
|
||||||
|
private BooleanSupplier overworldRaining = () -> false;
|
||||||
private boolean initialized;
|
private boolean initialized;
|
||||||
private boolean serverReady;
|
private boolean serverReady;
|
||||||
|
|
||||||
@@ -185,6 +191,14 @@ public final class LivingWorldBootstrap {
|
|||||||
"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.
|
||||||
|
*/
|
||||||
|
public void setOverworldRaining(BooleanSupplier supplier) {
|
||||||
|
this.overworldRaining = supplier != null ? supplier : () -> false;
|
||||||
|
}
|
||||||
|
|
||||||
public void onServerTick() {
|
public void onServerTick() {
|
||||||
if (!serverReady) {
|
if (!serverReady) {
|
||||||
return;
|
return;
|
||||||
@@ -192,10 +206,34 @@ 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();
|
||||||
regionManager.saveDirtyRegions();
|
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) {
|
public void registerCommands(CommandDispatcher<CommandSourceStack> dispatcher) {
|
||||||
requireInitialized();
|
requireInitialized();
|
||||||
LivingWorldCommandRoot.registerDeferred(
|
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"))))
|
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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,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;
|
||||||
@@ -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() {
|
public long getMinecraftTickCounter() {
|
||||||
return this.scheduler.getMinecraftTickCounter();
|
return this.scheduler.getMinecraftTickCounter();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user