Basin hydrology

- The route-and-reject hydrology system and its probes are removed
- Hydrology is configured under dimension hydrology and per biome, region and dimension hydrologyPolicy
- riverPolicy and the old hydrology config types are gone
- Grid kernels cover priority flood, D8 routing, flow accumulation, basin extraction, breaching and stream burning
- A basin spills at its pour point and an interior bump can never be the spill
- Region plans cover 4096-block regions on a 64-block grid for major rivers and lakes
- Window plans cover 512-block windows on an 8-block grid for reaches and ponds
- Plans are cached in memory, persisted per world under iris/hydrology and keyed by pack, seed, world height and settings
- Only the main runtime of an engine persists plans and stores swap on hotload and default runtime change
- Demand rasters outrank prefetch and stopping pregen withdraws its queued prefetch
- Ponds come from natural basins, seeded cups and chained cups with strictly falling levels
- A pond level never exceeds its rim plus maxFill or the end grade of any inflow
- Pond outlet loops are opened at their lowest pond
- Reaches split at junctions, pond outlets and 256-block grid lines
- Reaches meander, fit the valley, grade to a running minimum and smooth with Chaikin passes
- Drops of two blocks or more get a lip and one-block corner steps become dry rim ledges
- Cascades and waterfalls write falling fluid from the mantle at level eight
- Inflows into region lakes end at the lake level
- River mouths and water beside the sea sit at the sea surface
- The per-chunk raster gives terrain height, water head, role, tier, fluid, cave ceiling and shore distance
- WaterRole codes are stable and exposed to packs through the WATER_ROLE engine stream
- WATER_HEAD replaces the old river engine streams and an unknown stream name fails the pack load
- Banks take SMOOTH, LINEAR, CONCAVE, TERRACED or CLIFF profiles
- Containment berms grow as tall as the water needs
- Columns within two blocks of water stay solid and keep no Terrain3D overhang
- Seam dams hold water where new chunks meet chunks generated earlier
- The transition blend skips hydrology-owned columns
- Caves stay at least two blocks below river and pond beds
- The painter is the only writer of still water
- Objects cannot open water, falls or bank ground and solid objects may stand in water
- On-water objects need fluid at their anchor column
- Floating child islands stay out of channels
- Sea inlets run inland from cliff coasts at sea level and end in a chamber pool
- Inlet passages are sealed against carvers, subterrain features and the post pass
- Cave pools settle into carved lows, keep one fluid and stay out of inlet space
- Pool and inlet cells use their own cave actions in mantle data
- Water vegetation lists sit on tiers, ponds and hydrologyPolicy with SEA_FLOOR, SEA_SURFACE and SHORE_LINE parts
- Pack validation compiles hydrology settings and rejects empty or water-holding palettes
- Chunk generation fails closed when its 3x3 water rasters are not planned
- /iris find covers stream, river, major river, cascade, waterfall, mouth, pond, lake, dam, sea inlet and cave pool
- Generation history records the new feature types and drops old hydrology entries
- The studio map draws water from rasters up close and from plans when zoomed out
- Pregen prepares the current region and one region ahead
- hydrologyProbe, riverTransectProbe, hydrologyBlockProbe, seaInletProbe and cavePoolProbe report containment, rises, seams, tick conversion and missing surface
- Test forks run with a 2 GB heap, a 60-minute timeout and the Mockito agent loaded explicitly
- Generation with hydrology off is unchanged
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-25 15:28:26 -04:00
1 parent 2d211cf106
commit db0f98d103
710 files changed
+41791 -91883

No files matched your search

@@ -2,8 +2,9 @@ package art.arcane.iris.probe;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.hydrology.plan.WindowKey;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.world.pregen.PregenPerformanceProfile;
import net.minecraft.core.registries.Registries;
@@ -31,11 +32,16 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public final class OfflineRegionGenerator {
private static final int CHUNKS_PER_HYDROLOGY_REGION = WindowKey.SIZE >> 4;
private OfflineRegionGenerator() {
}
@@ -97,14 +103,13 @@ public final class OfflineRegionGenerator {
PregenPerformanceProfile.apply(session.engine());
System.out.println("generationProfile=pregen noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize()
+ " burstParallelism=" + MultiBurst.burst.parallelism() + " multicore=true");
IrisHydrologyRuntime hydrology = session.engine().getComplex().getHydrologyRuntime();
try (HydrologyTileCache.PregenerationScope hydrologyScope = hydrology == null ? null
: hydrology.preparePregeneration(configuration.hydrologyArea());
try (HydrologyPregeneration pregeneration = PregenHydrology.open(session.engine());
HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options(
session.engine(), runtime, staged.resolve("templates"), configuration.levelKey(),
configuration.noiseSettings(), spigot));
RegionGenerationWindow workers = new RegionGenerationWindow(configuration.parallelism(),
RegionGenerationWindow.Policy.STANDALONE)) {
PregenHydrology.prepareAll(pregeneration, configuration.hydrologyRegions());
HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers);
Path regionDirectory = session.engine().getTarget().getWorld().worldFolder().toPath().resolve("region");
Files.createDirectory(regionDirectory);
@@ -239,14 +244,27 @@ public final class OfflineRegionGenerator {
}
}
HydrologyTileCache.PregenerationArea hydrologyArea() {
List<long[]> hydrologyRegions() {
int halo = NativeRegionPlan.terrainHalo();
long minimumX = ((long) chunkX - halo) * 16L;
long minimumZ = ((long) chunkZ - halo) * 16L;
long maximumX = ((long) chunkX + (long) width * regionsX + halo) * 16L - 1L;
long maximumZ = ((long) chunkZ + (long) width * regionsZ + halo) * 16L - 1L;
return new HydrologyTileCache.PregenerationArea(Math.toIntExact((minimumX + maximumX) / 2L),
Math.toIntExact((minimumZ + maximumZ) / 2L), minimumX, minimumZ, maximumX, maximumZ);
Set<Long> seen = new HashSet<>();
List<long[]> regions = new ArrayList<>();
for (int row = 0; row < regionsZ; row++) {
for (int column = 0; column < regionsX; column++) {
int regionX = row % 2 == 0 ? column : regionsX - column - 1;
int minimumX = Math.floorDiv(chunkX + regionX * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
int maximumX = Math.floorDiv(chunkX + (regionX + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
int minimumZ = Math.floorDiv(chunkZ + row * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
int maximumZ = Math.floorDiv(chunkZ + (row + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
for (int hydrologyZ = minimumZ; hydrologyZ <= maximumZ; hydrologyZ++) {
for (int hydrologyX = minimumX; hydrologyX <= maximumX; hydrologyX++) {
if (seen.add(((long) hydrologyX << 32) | (hydrologyZ & 0xFFFFFFFFL))) {
regions.add(new long[]{hydrologyX, hydrologyZ});
}
}
}
}
}
return regions;
}
static Configuration parse(String[] arguments) throws IOException {
@@ -137,7 +137,7 @@ public class HeadlessChunkGeneratorTest {
}
Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Native columns","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
"landChance":1,"carvingEnabled":false}
""");
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
@@ -1,7 +1,6 @@
package art.arcane.iris.probe;
import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.util.collection.KList;
import net.minecraft.nbt.CompoundTag;
@@ -68,11 +67,10 @@ public final class HeadlessRegionTerrainTest {
OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration(
workspace.pack(), "main", 69420L, -3, 2, 1, 2, 3, 4, configuration,
"probe", Level.OVERWORLD, NoiseGeneratorSettings.OVERWORLD, root.resolve("export"));
HydrologyTileCache.PregenerationArea bounds = export.hydrologyArea();
assertEquals(-80L, bounds.minimumBlockX());
assertEquals(0L, bounds.minimumBlockZ());
assertEquals(15L, bounds.maximumBlockX());
assertEquals(111L, bounds.maximumBlockZ());
List<long[]> regions = export.hydrologyRegions();
assertEquals(2, regions.size());
assertArrayEquals(new long[]{-1L, 0L}, regions.get(0));
assertArrayEquals(new long[]{0L, 0L}, regions.get(1));
assertEquals(1, result.targetChunks());
assertEquals(529, result.chunks().size());
long terrain = result.chunks().stream().filter(chunk -> chunk.getPersistedStatus() == ChunkStatus.TERRAIN).count();
@@ -189,7 +187,7 @@ public final class HeadlessRegionTerrainTest {
}
Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Native region","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
"landChance":1,"carvingEnabled":false}
""");
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
+1 -1
View File
@@ -14,7 +14,7 @@ dependencies {
// log4j-core Filter interface, so its constructor reference needs the type resolvable.
compileOnly(libs.log4j.core)
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(libs.paper.api)
testImplementation(libs.bstats)
testImplementation(project(':core'))
@@ -31,8 +31,7 @@ import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -196,9 +195,9 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoPOI(e, type, player(), teleport);
}
@Director(description = "Find an accepted hydrology feature", sync = true)
@Director(description = "Find a hydrology water feature", sync = true)
public void river(
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class)
@Param(description = "Feature type: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet or cave_pool", customHandler = HydrologyTypeHandler.class)
String type,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
@@ -212,42 +211,32 @@ public class CommandFind implements DirectorExecutor {
}
return;
}
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
HydrologyFeatureQuery query;
HydrologyFeatureType featureType;
try {
query = HydrologyFeatureQuery.parse(type);
featureType = HydrologyFeatureType.parse(type);
} catch (IllegalArgumentException error) {
commandSender.sendMessage(C.RED + error.getMessage());
return;
}
String label = featureType.key().replace('_', ' ');
Location origin = target.getLocation();
World originWorld = origin.getWorld();
int requestedDistance = runtime == null
? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime == null
? requestedDistance
: runtime.maximumFeatureSearchDistance(
origin.getBlockX(), origin.getBlockZ(), requestedDistance);
commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "...");
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
commandSender.sendMessage(C.GRAY + GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
J.a(() -> {
try {
GenerationSemanticQueries.RiverResult feature = GenerationSemanticQueries.nearestRiver(
activeEngine,
query,
featureType,
origin.getBlockX(),
origin.getBlockZ(),
maximumDistance,
(int visited) -> sendStructureMessage(target, commandSender,
C.GRAY + "Searched " + visited + " hydrology tiles for " + type + "...")
maximumDistance
).orElse(null);
if (feature == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + "No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
C.YELLOW + GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
return;
}
String label = type + " hydrology feature";
if (!teleport) {
sendStructureMessage(target, commandSender,
C.GREEN + "Found " + label + " at " + feature.x() + ", " + feature.y() + ", " + feature.z() + ".");
@@ -259,8 +248,9 @@ public class CommandFind implements DirectorExecutor {
target, originWorld, commandSender, label, destination, true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error);
C.RED + "Could not search for " + label + ": " + error.getClass().getSimpleName()
+ (error.getMessage() == null ? "" : " - " + error.getMessage()));
Iris.reportError("Could not locate hydrology feature '" + featureType.key() + "'.", error);
}
});
}
@@ -17,8 +17,7 @@ import art.arcane.iris.platform.bukkit.terrain.IrisSampleLimits;
import art.arcane.iris.platform.bukkit.terrain.IrisSurfaceClassifier;
import art.arcane.iris.platform.bukkit.terrain.IrisWorldInfoFactory;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -108,15 +107,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try {
int surface = engine.getHeight(blockX, blockZ);
HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ);
WaterRole role = engine.getComplex().getWaterRoleStream().get(blockX, blockZ);
int fluid = (int) Math.round(
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
engine.getComplex().getWaterHeadStream().get(blockX, blockZ));
InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType();
}
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology);
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, role);
} catch (Throwable error) {
reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN;
@@ -266,12 +265,12 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
return false;
}
HydrologyColumnSample hydrology = wantRiver
? hydrologySample(engine, blockX, blockZ)
: null;
WaterRole role = wantRiver
? engine.getComplex().getWaterRoleStream().get(blockX, blockZ)
: WaterRole.NONE;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = wantRiver
? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ))
? (int) Math.round(engine.getComplex().getWaterHeadStream().get(blockX, blockZ))
: engine.getDimension().getFluidHeight();
boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
@@ -281,29 +280,23 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
surface,
fluid,
biome == null ? null : biome.getInferredType(),
hydrology
role
)
: IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
HydrologyColumnLayer surfaceLayer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
boolean riverPresent = surfaceLayer != null;
IrisRiverState riverState = wantRiverState
? riverState(hydrology)
? riverState(role)
: IrisRiverState.NONE;
double riverDistance = wantRiverDistance && riverPresent
? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ)
double riverDistance = wantRiverDistance
? riverDistance(role)
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow && riverPresent
? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ))
int riverFlow = wantRiverFlow
? riverFlow(role)
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface
&& surfaceLayer != null
&& surfaceLayer.connectedFluid()
int riverWaterSurfaceY = wantRiverWaterSurface && role.wet()
? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample(
@@ -327,21 +320,25 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
}
}
static IrisRiverState riverState(HydrologyColumnSample hydrology) {
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer == null || !layer.channel()) {
return IrisRiverState.NONE;
}
return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
static IrisRiverState riverState(WaterRole role) {
return role.wet() ? IrisRiverState.WET : IrisRiverState.NONE;
}
private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) {
if (engine.getComplex().getHydrologyRuntime() == null) {
return null;
}
return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null);
static double riverDistance(WaterRole role) {
return switch (role) {
case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
case SHORE, POND_SHORE -> 1D;
case BANK, DAM -> 2D;
};
}
static int riverFlow(WaterRole role) {
return switch (role) {
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
case POND, INLET -> 0;
};
}
private static Optional<String> key(IrisBiome biome) {
@@ -1,8 +1,7 @@
package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
public final class IrisSurfaceClassifier {
@@ -29,25 +28,16 @@ public final class IrisSurfaceClassifier {
int engineSurfaceHeight,
int engineFluidHeight,
InferredType inferredType,
HydrologyColumnSample hydrology
WaterRole role
) {
if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID;
}
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer != null) {
if (layer.shore()) {
return IrisSurfaceKind.RIVER_SHORE;
}
if (layer.channel()) {
return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL;
}
if (layer.grading()) {
return IrisSurfaceKind.LAND;
}
}
return classify(engineSurfaceHeight, engineFluidHeight, inferredType);
return switch (role) {
case NONE -> classify(engineSurfaceHeight, engineFluidHeight, inferredType);
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
case BANK, DAM -> IrisSurfaceKind.LAND;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
};
}
}
@@ -1,7 +1,7 @@
package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.localization.IrisLanguage;
@@ -22,6 +22,7 @@ import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -45,7 +46,7 @@ public class CommandFindHydrologyTest {
@Test
public void worldChangeDuringSearchDoesNotLoadOrTeleportIntoTheNewWorld() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.search.get().run();
@@ -58,7 +59,7 @@ public class CommandFindHydrologyTest {
@Test
public void disconnectDuringSearchDoesNotLoadTheDestination() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
when(fixture.player.isOnline()).thenReturn(false);
fixture.search.get().run();
@@ -69,7 +70,7 @@ public class CommandFindHydrologyTest {
@Test
public void worldChangeWhileLoadingTheDestinationCancelsTeleport() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.chunk.complete(mock(Chunk.class));
@@ -82,7 +83,7 @@ public class CommandFindHydrologyTest {
@Test
public void successIsReportedOnlyAfterTeleportCompletes() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
@@ -95,7 +96,7 @@ public class CommandFindHydrologyTest {
@Test
public void rejectedTeleportReportsFailureWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.complete(false);
@@ -109,7 +110,7 @@ public class CommandFindHydrologyTest {
public void failedTeleportReportsTheFullCauseWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("destination unloaded");
fixture.command.river("surface", true);
fixture.command.river("river", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.completeExceptionally(failure);
@@ -120,6 +121,72 @@ public class CommandFindHydrologyTest {
}
}
@Test
public void everyFeatureTypeIsSearchedOffTheCommandThreadAtTheRuntimeRadius() {
for (HydrologyFeatureType type : HydrologyFeatureType.values()) {
try (Fixture fixture = new Fixture()) {
fixture.command.river(type.key(), false);
fixture.queries.verify(() -> GenerationSemanticQueries.nearestRiver(any(), any(), anyInt(), anyInt(), anyInt()), never());
fixture.search.get().run();
fixture.queries.verify(() -> GenerationSemanticQueries.nearestRiver(fixture.engine, type, 0, 0,
GenerationSemanticQueries.RIVER_SEARCH_DISTANCE));
verify(fixture.sender).sendMessage(contains("Found " + type.key().replace('_', ' ') + " at 32, 63, 32."));
}
}
}
@Test
public void unknownTypeIsRejectedWithTheAcceptedTypesBeforeAnySearch() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
verify(fixture.sender).sendMessage(contains("Use one of: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet, cave_pool."));
assertNull(fixture.search.get());
}
}
@Test
public void missingCavePoolIsReportedAsNotFoundInGeneratedChunks() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.empty());
fixture.command.river("cave_pool", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("Searching generated chunks for the nearest cave pool within 7168 blocks"));
verify(fixture.sender).sendMessage(contains("No cave pool found in generated chunks within 7168 blocks."));
}
}
@Test
public void missingFeatureIsReportedAsNotFound() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.empty());
fixture.command.river("sea_inlet", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("No sea inlet found within 7168 blocks."));
fixture.platform.verify(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true)), never());
}
}
@Test
public void plannerFailureIsReportedAsAnErrorNotAsNotFound() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("window planner exploded");
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenThrow(failure);
fixture.command.river("lake", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("Could not search for lake: IllegalStateException - window planner exploded"));
verify(fixture.sender, never()).sendMessage(contains("No lake found"));
fixture.logging.verify(() -> Iris.reportError(any(String.class), eq(failure)));
}
}
private static final class Fixture implements AutoCloseable {
private final CommandFind command = mock(CommandFind.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.class);
@@ -132,7 +199,7 @@ public class CommandFindHydrologyTest {
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
private final MockedStatic<J> scheduler = mockStatic(J.class);
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class, CALLS_REAL_METHODS);
private final MockedStatic<IrisLanguage> language = mockStatic(IrisLanguage.class, invocation -> "teleported");
private final MockedStatic<Iris> logging = mockStatic(Iris.class);
@@ -160,10 +227,10 @@ public class CommandFindHydrologyTest {
});
platform.when(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true))).thenReturn(chunk);
platform.when(() -> BukkitPlatform.teleportAsync(eq(player), any(Location.class))).thenReturn(teleport);
queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt(), any()))
queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.of(new GenerationSemanticQueries.RiverResult(
GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION,
HydrologyFeatureType.RIFFLE, Optional.empty(), 1L, 32, 63, 32, OptionalLong.empty())));
HydrologyFeatureType.RIVER, 1L, 32, 63, 32, OptionalLong.empty())));
}
@Override
@@ -20,26 +20,26 @@ import static org.mockito.Mockito.when;
public class CommandObjectFluidHeightTest {
@Test
public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() {
public void absoluteObjectPlacementShiftsTheColumnWaterHeadFromNegativeMinY() {
World world = mock(World.class);
Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class);
IrisComplex complex = mock(IrisComplex.class);
@SuppressWarnings("unchecked")
ProceduralStream<Double> riverHead = mock(ProceduralStream.class);
ProceduralStream<Double> waterHead = mock(ProceduralStream.class);
Map<Block, BlockData> future = new HashMap<>();
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex);
when(dimension.getFluidHeight()).thenReturn(127);
when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead);
when(riverHead.get(12, -7)).thenReturn(131D);
when(complex.getWaterHeadStream()).thenReturn(waterHead);
when(waterHead.get(12, -7)).thenReturn(131D);
IObjectPlacer placer = CommandObject.createPlacer(world, future, engine);
assertEquals(63, placer.getFluidHeight());
assertEquals(67, placer.getFluidHeight(12, -7));
verify(riverHead).get(12, -7);
verify(waterHead).get(12, -7);
}
}
@@ -9,16 +9,18 @@ import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import art.arcane.iris.api.terrain.IrisColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import org.bukkit.World;
import org.bukkit.generator.ChunkGenerator;
@@ -26,6 +28,7 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
@@ -112,10 +115,32 @@ public class IrisTerrainSVCTest {
}
@Test
public void acceptedHydrologyMapsToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null));
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true)));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false)));
public void waterRolesMapToThePublicRiverFields() {
for (WaterRole role : WaterRole.values()) {
assertEquals(role.name(), role.wet() ? IrisRiverState.WET : IrisRiverState.NONE, IrisTerrainSVC.riverState(role));
double distance = switch (role) {
case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
case SHORE, POND_SHORE -> 1D;
case BANK, DAM -> 2D;
};
assertEquals(role.name(), distance, IrisTerrainSVC.riverDistance(role), 0D);
int flow = switch (role) {
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
case POND, INLET -> 0;
};
assertEquals(role.name(), flow, IrisTerrainSVC.riverFlow(role));
}
}
@Test
public void columnSamplesReportTheWaterRoleOfEachColumn() throws Exception {
assertEquals(List.of("RIVER", "WET", "0.0", "1", "6"), sampled(WaterRole.CHANNEL, 70));
assertEquals(List.of("RIVER", "WET", "0.0", "0", "6"), sampled(WaterRole.POND, 70));
assertEquals(List.of("RIVER_SHORE", "NONE", "1.0", "-1", "unavailable"), sampled(WaterRole.SHORE, 50));
assertEquals(List.of("LAND", "NONE", "2.0", "-1", "unavailable"), sampled(WaterRole.BANK, 50));
assertEquals(List.of("LAND", "NONE", "NaN", "-1", "unavailable"), sampled(WaterRole.NONE, 50));
}
@Test
@@ -219,6 +244,33 @@ public class IrisTerrainSVCTest {
}
}
private static List<String> sampled(WaterRole role, int head) throws Exception {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisBiome biome = mock(IrisBiome.class);
when(biome.getInferredType()).thenReturn(InferredType.LAND);
when(engine.getComplex()).thenReturn(complex);
when(engine.getDimension()).thenReturn(mock(IrisDimension.class));
when(engine.getHeight(0, 0)).thenReturn(60);
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getSurfaceBiome(0, 0)).thenReturn(biome);
when(complex.getWaterRoleStream()).thenReturn(ProceduralStream.of((x, z) -> role, Interpolated.of(value -> 0D, value -> role)));
when(complex.getWaterHeadStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> (double) head));
IrisColumnQuery query = IrisColumnQuery.rect(0, 0, 0, 0, 1, EnumSet.of(IrisColumnField.SURFACE_KIND,
IrisColumnField.RIVER_STATE, IrisColumnField.RIVER_DISTANCE, IrisColumnField.RIVER_FLOW,
IrisColumnField.RIVER_WATER_SURFACE_Y));
List<IrisColumnSample> samples = new ArrayList<>();
try (MockedStatic<IrisSettings> settings = mockStatic(IrisSettings.class)) {
settings.when(IrisSettings::get).thenReturn(new IrisSettings());
assertTrue(enabledService().sampleColumns(world(engine), query, samples::add));
}
assertEquals(1, samples.size());
IrisColumnSample sample = samples.get(0);
return List.of(sample.surfaceKind().name(), sample.riverState().name(), String.valueOf(sample.riverDistance()),
String.valueOf(sample.riverFlow()),
sample.riverWaterSurfaceY() == IrisColumnSample.UNAVAILABLE_HEIGHT ? "unavailable" : String.valueOf(sample.riverWaterSurfaceY()));
}
private static World world(Engine engine) {
World world = mock(World.class);
ChunkGenerator generator = mock(ChunkGenerator.class, withSettings().extraInterfaces(PlatformChunkGenerator.class));
@@ -235,41 +287,4 @@ public class IrisTerrainSVCTest {
((AtomicBoolean) enabled.get(service)).set(true);
return service;
}
private static HydrologyColumnSample river(boolean connectedFluid) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
HydrologyFeatureType.SURFACE_POOL,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
true,
false,
false,
connectedFluid,
false,
false,
true,
connectedFluid,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
}
}
@@ -1,15 +1,10 @@
package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.terrain.InferredType;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -69,98 +64,28 @@ public class IrisSurfaceClassifierTest {
}
@Test
public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.MOUTH, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify(
64,
63,
InferredType.SHORE,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false)
));
assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false)
));
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false)
));
public void waterRolesOverrideGenericOceanAndLandKinds() {
for (WaterRole role : WaterRole.values()) {
IrisSurfaceKind expected = switch (role) {
case NONE -> IrisSurfaceKind.OCEAN;
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
case BANK, DAM -> IrisSurfaceKind.LAND;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
};
assertEquals(role.name(), expected, IrisSurfaceClassifier.classify(60, 63, InferredType.SEA, role));
}
}
@Test
public void voidClassificationWinsOverAcceptedRiverGeometry() {
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(
0,
63,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
public void voidClassificationWinsOverWaterRoles() {
for (WaterRole role : WaterRole.values()) {
assertEquals(role.name(), IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(0, 63, InferredType.LAND, role));
}
}
@Test
public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
64,
63,
InferredType.LAND,
null
));
}
private static HydrologyColumnSample hydrology(
HydrologyFeatureType type,
boolean channel,
boolean shore,
boolean connectedFluid
) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
type,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
channel,
shore,
!channel && !shore,
connectedFluid,
false,
false,
true,
connectedFluid && channel,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
public void columnsWithoutAWaterRoleKeepTheGenericKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(64, 63, InferredType.LAND, WaterRole.NONE));
assertEquals(IrisSurfaceKind.SHORE, IrisSurfaceClassifier.classify(64, 63, InferredType.SHORE, WaterRole.NONE));
}
}
+3 -1
View File
@@ -153,7 +153,7 @@ dependencies {
String modConfiguration = irisModTarget.fabricRemap ? 'modImplementation' : 'implementation'
add(modConfiguration, "net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -236,6 +236,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
}
+3 -1
View File
@@ -178,7 +178,7 @@ dependencies {
? minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
: minecraft.dependency("iris-${buildLane}", "net.minecraftforge:forge:${forgeVersion}"))
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -272,6 +272,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
}
@@ -25,7 +25,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
@@ -61,7 +61,7 @@ final class ModdedCommandSuggestions {
static final SuggestionProvider<NativeCommandSource> OBJECT_KEYS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder);
static final SuggestionProvider<NativeCommandSource> STRUCTURE_KEYS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder);
static final SuggestionProvider<NativeCommandSource> POI_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(List.of("buried_treasure"), builder);
static final SuggestionProvider<NativeCommandSource> HYDROLOGY_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestHydrologyTypes(context, builder);
static final SuggestionProvider<NativeCommandSource> HYDROLOGY_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(HydrologyFeatureType.keys(), builder);
static final SuggestionProvider<NativeCommandSource> PACK_NAMES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestPackNames(context, builder);
static final SuggestionProvider<NativeCommandSource> DIMENSION_NAMES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder);
@@ -87,24 +87,6 @@ final class ModdedCommandSuggestions {
return builder.buildFuture();
}
private static CompletableFuture<Suggestions> suggestHydrologyTypes(
CommandContext<NativeCommandSource> context,
SuggestionsBuilder builder
) {
ModdedCommandFeedback.tab(context.getSource());
try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().world());
if (engine != null && engine.getComplex().getHydrologyRuntime() != null) {
return NativeCommandRegistration.suggest(
engine.getComplex().getHydrologyRuntime().featureQueryKeys(), builder);
}
} catch (Throwable error) {
warnTabFailure("hydrology types", context.getSource(), error);
}
return NativeCommandRegistration.suggest(
HydrologyFeatureQuery.suggestions(List.of()), builder);
}
static Set<String> reachableBiomeKeys(Engine engine) {
return reachableBiomeKeys(GenerationFindCatalog.biomes(engine));
}
@@ -39,8 +39,7 @@ import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.runtime.WrongEngineBroException;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -143,39 +142,31 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, "This dimension is not generated by Iris.");
return 0;
}
IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
HydrologyFeatureQuery query;
HydrologyFeatureType featureType;
try {
query = HydrologyFeatureQuery.parse(type);
featureType = HydrologyFeatureType.parse(type);
} catch (IllegalArgumentException error) {
IrisModdedCommands.fail(source, error.getMessage());
return 0;
}
String label = featureType.key().replace('_', ' ');
int originX = player.blockX();
int originZ = player.blockZ();
int requestedDistance = runtime == null
? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime == null
? requestedDistance
: runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance);
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
NativeModdedServer server = source.server();
IrisModdedCommands.ok(source, "Searching accepted hydrology plans for " + type + "...");
IrisModdedCommands.ok(source, GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
MultiBurst.burst.completeValueAsync(() -> GenerationSemanticQueries.nearestRiver(
engine, query, originX, originZ, maximumDistance,
(int visited) -> server.execute(() -> IrisModdedCommands.ok(source,
"Searched " + visited + " hydrology tiles for " + type + "..."))).orElse(null))
engine, featureType, originX, originZ, maximumDistance).orElse(null))
.whenComplete((GenerationSemanticQueries.RiverResult feature, Throwable error) -> server.execute(() -> {
if (error != null) {
ModdedIrisLog.error("Hydrology locate failed for {}", type, error);
IrisModdedCommands.fail(source,
"Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
ModdedIrisLog.error("Hydrology locate failed for {}", featureType.key(), cause);
IrisModdedCommands.fail(source, "Could not search for " + label + ": " + cause.getClass().getSimpleName()
+ (cause.getMessage() == null ? "" : " - " + cause.getMessage()));
return;
}
if (feature == null) {
IrisModdedCommands.fail(source,
"No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
IrisModdedCommands.fail(source, GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
return;
}
teleportToStructure(
@@ -185,7 +176,7 @@ final class ModdedLocateCommands {
feature.x(),
feature.y() + 2,
feature.z(),
type + " hydrology feature"
label
);
}));
return 1;
@@ -23,6 +23,7 @@ import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.world.pregen.MantleHeapPressure;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.world.pregen.PregenListener;
import art.arcane.iris.world.pregen.PregenMantleBackpressure;
import art.arcane.iris.world.pregen.PregeneratorMethod;
@@ -30,6 +31,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -71,6 +73,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private final PregenMantleBackpressure backpressure;
private final PauseWhenEmptyGuard pauseGuard;
private final LongSupplier clock;
private final AtomicBoolean closing = new AtomicBoolean();
private final PregenHydrology hydrology;
public ModdedPregenMethod(Configuration configuration) {
this(configuration, System::nanoTime);
@@ -80,6 +84,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
this.clock = Objects.requireNonNull(clock);
runtime = configuration.runtime();
engine = configuration.engine();
hydrology = new PregenHydrology(() -> engine);
sync = configuration.sync();
pauseGuard = new PauseWhenEmptyGuard(runtime);
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
@@ -121,6 +126,8 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
@Override
public void close() {
closing.set(true);
hydrology.close();
try {
awaitPendingChunks();
ModdedIrisLog.info("Iris modded pregen closing: dim={} completed={} pending={} peakInFlight={} finalLimit={}",
@@ -341,6 +348,13 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
return false;
}
@Override
public void onRegionsAhead(List<long[]> regions) {
if (!closing.get()) {
hydrology.prepare(regions);
}
}
@Override
public String getMethod(int x, int z) {
return "Modded";
@@ -0,0 +1,149 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStructureQueries;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedLocateRiverTest {
private static final int DISTANCE = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
@Test
public void unknownTypeFailsBeforeSearching() {
Fixture fixture = new Fixture();
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(0, ModdedLocateCommands.gotoRiver(fixture.source, "ocean_trench"));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()));
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), never());
verify(fixture.server, never()).execute(any());
verify(fixture.runtime, never()).nearestFeature(anyInt(), anyInt(), any(), anyInt(), any());
}
}
@Test
public void missingFeatureReportsNotFound() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any())).thenReturn(Optional.empty());
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "river"));
fixture.finishOnServerThread();
commands.verify(() -> IrisModdedCommands.fail(fixture.source, "No river found within " + DISTANCE + " blocks."));
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
}
}
@Test
public void plannerFailureIsReportedAsAnErrorNotAsNotFound() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any()))
.thenThrow(new IllegalStateException("window planner exploded"));
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "major_river"));
fixture.finishOnServerThread();
commands.verify(() -> IrisModdedCommands.fail(fixture.source,
"Could not search for major river: IllegalStateException - window planner exploded"));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), eq("No major river found within " + DISTANCE + " blocks.")),
never());
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
}
}
@Test
public void foundFeatureTeleportsAboveItFromThePlayersPosition() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any()))
.thenReturn(Optional.of(new HydrologyFeatureRef(9L, HydrologyFeatureType.WATERFALL, 400, 130, -250)));
when(fixture.player.teleport(fixture.world, 400.5D, 68D, -249.5D)).thenReturn(true);
try (MockedStatic<IrisModdedCommands> commands = fixture.commands();
MockedConstruction<NativeStructureQueries> queries = mockConstruction(NativeStructureQueries.class)) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "waterfall"));
fixture.finishOnServerThread();
ArgumentCaptor<HydrologyFeatureQuery> query = ArgumentCaptor.forClass(HydrologyFeatureQuery.class);
verify(fixture.runtime).nearestFeature(eq(120), eq(-75), query.capture(), eq(DISTANCE), any());
assertEquals(HydrologyFeatureQuery.of(HydrologyFeatureType.WATERFALL), query.getValue());
verify(queries.constructed().getFirst()).loadChunk(400 >> 4, -250 >> 4);
verify(fixture.player).teleport(fixture.world, 400.5D, 68D, -249.5D);
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), times(2));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()), never());
}
}
private static final class Fixture {
private final NativeCommandSource source = mock(NativeCommandSource.class);
private final NativeWorld world = mock(NativeWorld.class);
private final NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
private final NativeModdedServer server = mock(NativeModdedServer.class);
private final Engine engine = mock(Engine.class);
private final HydrologyRuntime runtime = mock(HydrologyRuntime.class);
private final CompletableFuture<Runnable> completion = new CompletableFuture<>();
private Fixture() {
IrisComplex complex = mock(IrisComplex.class);
IrisDimension dimension = mock(IrisDimension.class);
when(source.player()).thenReturn(player);
when(source.world()).thenReturn(world);
when(source.server()).thenReturn(server);
when(player.blockX()).thenReturn(120);
when(player.blockZ()).thenReturn(-75);
when(player.connected()).thenReturn(true);
when(player.inWorld(world)).thenReturn(true);
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
when(engine.getComplex()).thenReturn(complex);
when(engine.getDimension()).thenReturn(dimension);
when(dimension.getMinHeight()).thenReturn(-64);
when(complex.getHydrologyRuntime()).thenReturn(runtime);
when(runtime.enabled()).thenReturn(true);
doAnswer(invocation -> {
completion.complete(invocation.getArgument(0));
return null;
}).when(server).execute(any());
}
private MockedStatic<IrisModdedCommands> commands() {
MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class);
commands.when(() -> IrisModdedCommands.engineFor(world)).thenReturn(engine);
return commands;
}
private void finishOnServerThread() throws Exception {
completion.get(10L, TimeUnit.SECONDS).run();
}
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.iris.world.pregen.MantleHeapPressure;
@@ -11,8 +12,10 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
@@ -31,9 +34,11 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
@@ -89,6 +94,25 @@ public class ModdedPregenMethodLifecycleTest {
verify(runtime, never()).execute(any());
}
@Test
public void closeWithdrawsQueuedHydrologyPrefetchAndRefusesLaterAnnouncements() {
HydrologyPregeneration epoch = mock(HydrologyPregeneration.class);
when(engine.getComplex().getHydrologyRuntime().openPregeneration()).thenReturn(epoch);
when(runtime.isServerThread()).thenReturn(true);
assertTrue(method.deferFinalSaveToServerThread());
List<long[]> ahead = List.of(new long[]{0L, 0L}, new long[]{1L, 0L});
method.onRegionsAhead(ahead);
method.close();
method.onRegionsAhead(ahead);
InOrder order = inOrder(epoch);
order.verify(epoch).prepare(ahead);
order.verify(epoch).close();
verify(epoch, times(1)).prepare(anyList());
method.completeDeferredFinalSave();
}
@Test
public void asynchronousCallerCannotClaimTheShutdownSave() {
assertFalse(method.deferFinalSaveToServerThread());
+3 -1
View File
@@ -141,7 +141,7 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies {
testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.mockito.core)
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -261,6 +261,8 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
useJUnitPlatform()
classpath = classpath.minus(developmentModLibraries).plus(sourceSets.modLibraries.output)