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

+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));
}
}