Complete phase 7 player feedback loops

This commit is contained in:
George
2026-06-11 18:25:39 +01:00
parent 98f97bfbc4
commit eaf5db723b
5 changed files with 178 additions and 4 deletions
+3
View File
@@ -45,4 +45,7 @@ dependencies {
test { test {
useJUnitPlatform() useJUnitPlatform()
scanForTestClasses = false
include '**/*Test.class'
include '**/*Test$*.class'
} }
@@ -12,6 +12,7 @@ import net.neoforged.neoforge.event.entity.EntityLeaveLevelEvent;
import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent;
import net.neoforged.neoforge.event.level.BlockEvent; import net.neoforged.neoforge.event.level.BlockEvent;
import net.neoforged.neoforge.event.level.ChunkEvent; import net.neoforged.neoforge.event.level.ChunkEvent;
import net.neoforged.neoforge.event.level.block.CropGrowEvent;
import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.ChunkPos;
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;
@@ -405,6 +406,16 @@ public class LivingWorldMod {
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
bootstrap.handleSaplingPlaced(coord, level.getGameTime() / 24000L); bootstrap.handleSaplingPlaced(coord, level.getGameTime() / 24000L);
}); });
NeoForge.EVENT_BUS.addListener(CropGrowEvent.Pre.class, event -> {
if (!bootstrap.isServerReady() || !(event.getLevel() instanceof ServerLevel level)) return;
RegionCoordinate coord = RegionCoordinate.fromBlock(
level.dimension().location().toString(),
event.getPos().getX(), event.getPos().getZ(),
LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
if (bootstrap.isCropGrowthSuppressed(coord)) {
event.setResult(CropGrowEvent.Pre.Result.DO_NOT_GROW);
}
});
LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully."); LivingWorldLogger.info(DiagnosticCategory.BOOTSTRAP, "Living World Bootstrap initialized successfully.");
} }
@@ -486,6 +497,7 @@ public class LivingWorldMod {
if (hasWaterBody(serverLevel, coord)) { if (hasWaterBody(serverLevel, coord)) {
bootstrap.applyWaterBodyBoost(coord); bootstrap.applyWaterBodyBoost(coord);
} }
bootstrap.markFarmlandRegion(coord, hasFarmland(serverLevel, coord));
waterBodyLastScan.put(coord, playerCheckTick); waterBodyLastScan.put(coord, playerCheckTick);
} }
} }
@@ -711,10 +723,24 @@ public class LivingWorldMod {
} }
float pitch = 0.75f + random.nextFloat() * 0.5f; float pitch = 0.75f + random.nextFloat() * 0.5f;
if (stage != null && stage.ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal()
&& pollution > 50.0) {
return false;
}
if (stage != null && stage.ordinal() >= SuccessionStage.YOUNG_WOODLAND.ordinal() if (stage != null && stage.ordinal() >= SuccessionStage.YOUNG_WOODLAND.ordinal()
&& health > 50.0 && random.nextInt(10) == 0) { && health > 50.0 && random.nextInt(Math.max(3, 14 - (int) (health / 10))) == 0) {
// Rustling leaves — healthy forest ambience. int blend = random.nextInt(100);
if (stage == SuccessionStage.MATURE_FOREST && health > 70.0 && blend < 45) {
playAmbientSound(player, Holder.direct(SoundEvents.PARROT_AMBIENT), 0.12f, pitch);
} else if (stage == SuccessionStage.MATURE_FOREST && blend < 75) {
playAmbientSound(player, Holder.direct(SoundEvents.BEE_LOOP), 0.10f, pitch);
} else {
playAmbientSound(player, Holder.direct(SoundEvents.AZALEA_LEAVES_STEP), 0.18f, pitch); playAmbientSound(player, Holder.direct(SoundEvents.AZALEA_LEAVES_STEP), 0.18f, pitch);
}
return true;
}
if (stage == SuccessionStage.GRASSLAND && health > 35.0 && random.nextInt(12) == 0) {
playAmbientSound(player, Holder.direct(SoundEvents.GRASS_STEP), 0.12f, pitch);
return true; return true;
} }
if (stage != null && stage.ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal() if (stage != null && stage.ordinal() <= SuccessionStage.SPARSE_GRASS.ordinal()
@@ -904,6 +930,22 @@ public class LivingWorldMod {
return waterCount >= 5; return waterCount >= 5;
} }
private boolean hasFarmland(ServerLevel level, RegionCoordinate coord) {
int baseX = coord.x() * REGION_BLOCKS;
int baseZ = coord.z() * REGION_BLOCKS;
for (int i = 0; i < 30; i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1;
BlockPos pos = new BlockPos(x, y, z);
if (level.isLoaded(pos)
&& level.getBlockState(pos).is(net.minecraft.world.level.block.Blocks.FARMLAND)) {
return true;
}
}
return false;
}
/** Step 6: Samples the dominant biome at the region centre and returns a succession ceiling. */ /** Step 6: Samples the dominant biome at the region centre and returns a succession ceiling. */
private SuccessionStage deriveBiomeCap(ServerLevel level, RegionCoordinate coord) { private SuccessionStage deriveBiomeCap(ServerLevel level, RegionCoordinate coord) {
int cx = coord.x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 + 64; int cx = coord.x() * LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS * 16 + 64;
@@ -159,6 +159,11 @@ public final class LivingWorldBootstrap {
private final Map<RegionCoordinate, Integer> bareDryCycles = new HashMap<>(); private final Map<RegionCoordinate, Integer> bareDryCycles = new HashMap<>();
private final Map<RegionCoordinate, Integer> permafrostWarmCycles = new HashMap<>(); private final Map<RegionCoordinate, Integer> permafrostWarmCycles = new HashMap<>();
private final Set<RegionCoordinate> thawedPermafrostRegions = new HashSet<>(); private final Set<RegionCoordinate> thawedPermafrostRegions = new HashSet<>();
private final Map<RegionCoordinate, Integer> lowTreeCycles = new HashMap<>();
private final Set<RegionCoordinate> deforestedRegions = new HashSet<>();
private final Set<RegionCoordinate> farmlandRegions = new HashSet<>();
private final Set<RegionCoordinate> exhaustedFarmlandRegions = new HashSet<>();
private final Map<RegionCoordinate, Integer> farmingInactiveCycles = new HashMap<>();
private PlatformAdapter platformAdapter; private PlatformAdapter platformAdapter;
private Path worldSaveDirectory; private Path worldSaveDirectory;
@@ -350,6 +355,11 @@ public final class LivingWorldBootstrap {
bareDryCycles.clear(); bareDryCycles.clear();
permafrostWarmCycles.clear(); permafrostWarmCycles.clear();
thawedPermafrostRegions.clear(); thawedPermafrostRegions.clear();
lowTreeCycles.clear();
deforestedRegions.clear();
farmlandRegions.clear();
exhaustedFarmlandRegions.clear();
farmingInactiveCycles.clear();
simSpeedMultiplier = 1; simSpeedMultiplier = 1;
serverReady = false; serverReady = false;
LivingWorldLogger.info( LivingWorldLogger.info(
@@ -417,6 +427,8 @@ public final class LivingWorldBootstrap {
if (soil == null) return; if (soil == null) return;
double gain = isPollinatorCollapse(coord) ? 1.0 : 2.0; double gain = isPollinatorCollapse(coord) ? 1.0 : 2.0;
soil.setFertility(Math.min(100, soil.getFertility() + gain)); soil.setFertility(Math.min(100, soil.getFertility() + gain));
exhaustedFarmlandRegions.remove(coord);
farmingInactiveCycles.put(coord, 0);
region.getModuleData().put(SoilModule.MODULE_ID, soil); region.getModuleData().put(SoilModule.MODULE_ID, soil);
regionManager.markDirty(region); regionManager.markDirty(region);
}); });
@@ -427,6 +439,7 @@ public final class LivingWorldBootstrap {
if (!serverReady) return; if (!serverReady) return;
RegionCoordinate coord = RegionCoordinate.fromBlock( RegionCoordinate coord = RegionCoordinate.fromBlock(
dimensionId, x, z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS); dimensionId, x, z, LivingWorldConstants.DEFAULT_REGION_SIZE_CHUNKS);
farmingInactiveCycles.put(coord, 0);
regionManager.resolve(coord).ifPresent(region -> { regionManager.resolve(coord).ifPresent(region -> {
SoilRegionData soil = region.getModuleData() SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class) .get(SoilModule.MODULE_ID, SoilRegionData.class)
@@ -519,6 +532,7 @@ public final class LivingWorldBootstrap {
applyHydrologyExpansion(); applyHydrologyExpansion();
applyEcologyExpansion(); applyEcologyExpansion();
applyLongCycleEffects(); applyLongCycleEffects();
applyPlayerFeedbackLoops();
} }
private void applyLongCycleEffects() { private void applyLongCycleEffects() {
@@ -735,6 +749,79 @@ public final class LivingWorldBootstrap {
} }
} }
public void markFarmlandRegion(RegionCoordinate coord, boolean present) {
if (coord == null) return;
if (present) farmlandRegions.add(coord);
else farmlandRegions.remove(coord);
}
public boolean isCropGrowthSuppressed(RegionCoordinate coord) {
return coord != null && exhaustedFarmlandRegions.contains(coord);
}
public boolean isDeforestedRegion(RegionCoordinate coord) {
return coord != null && deforestedRegions.contains(coord);
}
private void applyPlayerFeedbackLoops() {
if (worldEffectsModule == null) return;
for (Region region : regionManager.getActiveRegions()) {
RegionCoordinate coord = region.getCoordinate();
VegetationRegionData vegetation = region.getModuleData()
.get(VegetationModule.MODULE_ID, VegetationRegionData.class).orElse(null);
SoilRegionData soil = region.getModuleData()
.get(SoilModule.MODULE_ID, SoilRegionData.class).orElse(null);
RecoveryRegionData recovery = region.getModuleData()
.get(RecoveryModule.MODULE_ID, RecoveryRegionData.class).orElse(null);
ResourceRegionData resources = region.getModuleData()
.get(ResourceDepletionModule.MODULE_ID, ResourceRegionData.class).orElse(null);
if (vegetation == null || soil == null || recovery == null || resources == null) continue;
if (vegetation.getTreePressure() < 5.0 && resources.getLoggingDepletion() > 20.0) {
int cycles = lowTreeCycles.merge(coord, 1, Integer::sum);
if (cycles >= 10 && deforestedRegions.add(coord)) {
pendingEventMessages.add("[LW] Deforestation detected at ("
+ coord.x() + "," + coord.z() + ") - soil erosion accelerating.");
}
} else if (vegetation.getTreePressure() >= 15.0) {
lowTreeCycles.remove(coord);
deforestedRegions.remove(coord);
}
if (deforestedRegions.contains(coord)) {
recovery.setMaxSuccessionStage(SuccessionStage.SPARSE_GRASS);
soil.setContamination(Math.min(100, soil.getContamination() + 0.10));
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.RIVER_SILTS, coord, 0.6));
}
if (farmlandRegions.contains(coord)) {
int inactive = farmingInactiveCycles.merge(coord, 1, Integer::sum);
boolean exhausted = soil.getFertility() < 20.0 && soil.getContamination() > 40.0;
if (exhausted) {
if (exhaustedFarmlandRegions.add(coord)) {
pendingEventMessages.add("[LW] Crop exhaustion near region ("
+ coord.x() + "," + coord.z()
+ ") - leave fields fallow or use bone meal.");
}
worldEffectsModule.queueEffect(new WorldEffectRequest(
WorldEffectType.CROPLAND_EXHAUSTS, coord, 0.7));
}
if (inactive >= 50) {
soil.setFertility(Math.min(100, soil.getFertility() + 0.15));
soil.setContamination(Math.max(0, soil.getContamination() - 0.08));
if (soil.getFertility() >= 25.0 || soil.getContamination() <= 30.0) {
exhaustedFarmlandRegions.remove(coord);
}
}
}
region.getModuleData().put(SoilModule.MODULE_ID, soil);
region.getModuleData().put(RecoveryModule.MODULE_ID, recovery);
regionManager.markDirty(region);
}
}
private void applyHydrologyExpansion() { private void applyHydrologyExpansion() {
Collection<Region> active = regionManager.getActiveRegions(); Collection<Region> active = regionManager.getActiveRegions();
if (active.isEmpty() || worldEffectsModule == null) return; if (active.isEmpty() || worldEffectsModule == null) return;
@@ -805,7 +892,10 @@ public final class LivingWorldBootstrap {
lowestNeighbour = neighbour; lowestNeighbour = neighbour;
} }
} }
if (atmosphere.getRainLevel() > 0.80 && lowSuccession && maxSlope > 10.0 double floodRainThreshold = deforestedRegions.contains(coord) ? 0.60 : 0.80;
double floodSlopeThreshold = deforestedRegions.contains(coord) ? 5.0 : 10.0;
if (atmosphere.getRainLevel() > floodRainThreshold
&& lowSuccession && maxSlope > floodSlopeThreshold
&& windRandom.nextDouble() < 0.02 && windRandom.nextDouble() < 0.02
&& !isClimateEventActive(coord, ClimateEventType.FLASH_FLOOD)) { && !isClimateEventActive(coord, ClimateEventType.FLASH_FLOOD)) {
ClimateEvent flood = new ClimateEvent( ClimateEvent flood = new ClimateEvent(
@@ -205,4 +205,10 @@ public enum WorldEffectType {
/** Warming frozen subsoil collapses into wet mud and meltwater. */ /** Warming frozen subsoil collapses into wet mud and meltwater. */
PERMAFROST_THAWS, PERMAFROST_THAWS,
/** Deforested runoff replaces river sand with coarse gravel silt. */
RIVER_SILTS,
/** Exhausted farmland fails into coarse dirt and loses its crop. */
CROPLAND_EXHAUSTS,
} }
@@ -172,6 +172,10 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
breakSoilCrust(level, baseX, baseZ, request.intensity()); breakSoilCrust(level, baseX, baseZ, request.intensity());
case PERMAFROST_THAWS -> case PERMAFROST_THAWS ->
thawPermafrost(level, baseX, baseZ, request.intensity()); thawPermafrost(level, baseX, baseZ, request.intensity());
case RIVER_SILTS ->
siltRiver(level, baseX, baseZ, request.intensity());
case CROPLAND_EXHAUSTS ->
exhaustCropland(level, baseX, baseZ, request.intensity());
} }
} }
@@ -1274,6 +1278,35 @@ public final class NeoForgeWorldEffectExecutor implements WorldEffectConsumer {
} }
} }
private void siltRiver(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.20) return;
for (int i = 0; i < Math.max(2, (int) (intensity * 8)); i++) {
int x = baseX + random.nextInt(REGION_BLOCKS);
int z = baseZ + random.nextInt(REGION_BLOCKS);
int y = level.getHeight(Heightmap.Types.WORLD_SURFACE, x, z) - 1;
BlockPos water = new BlockPos(x, y, z);
BlockPos bed = water.below();
if (level.isLoaded(water) && level.getFluidState(water).is(FluidTags.WATER)
&& level.getBlockState(bed).is(Blocks.SAND)) {
level.setBlock(bed, Blocks.GRAVEL.defaultBlockState(), Block.UPDATE_ALL);
}
}
}
private void exhaustCropland(ServerLevel level, int baseX, int baseZ, double intensity) {
if (random.nextDouble() > 0.25) return;
for (int i = 0; i < Math.max(2, (int) (intensity * 8)); i++) {
BlockPos pos = surfaceAt(level, baseX + random.nextInt(REGION_BLOCKS),
baseZ + random.nextInt(REGION_BLOCKS));
if (pos == null || !level.getBlockState(pos).is(Blocks.FARMLAND)) continue;
BlockPos crop = pos.above();
if (level.isLoaded(crop) && level.getBlockState(crop).is(BlockTags.CROPS)) {
level.setBlock(crop, Blocks.AIR.defaultBlockState(), Block.UPDATE_ALL);
}
level.setBlock(pos, Blocks.COARSE_DIRT.defaultBlockState(), Block.UPDATE_ALL);
}
}
private BlockPos surfaceAt(ServerLevel level, int x, int z) { private BlockPos surfaceAt(ServerLevel level, int x, int z) {
int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1; int y = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z) - 1;
if (y < level.getMinBuildHeight()) { if (y < level.getMinBuildHeight()) {