Standing surface pools

- hydrology.surfacePools defines bowls filled with their own fluid; riverPolicy.surfacePools opts areas in
- Pools are independent SURFACE_POOL courses with one STANDING_POOL segment, shaped by the erosion field, kept clear of rivers and off slopes
- Locators accept the pool selector and pool ids; validator, renderer, probes and docs follow
This commit is contained in:
Brian Neumann-Fopiano
2026-09-02 00:29:32 -04:00
parent 13bce7aa42
commit 479b94d6a2
36 changed files with 841 additions and 80 deletions
@@ -92,7 +92,7 @@ final class PackHydrologyValidator {
biomes,
errors
);
HydrologyValidation hydrology = validateHydrology(dimensionPath, dimension, errors, warnings);
HydrologyValidation hydrology = validateHydrology(dimensionPath, dimension, biomes, errors, warnings);
if (!hydrology.active()) {
continue;
}
@@ -103,6 +103,7 @@ final class PackHydrologyValidator {
dimension,
dimensionPolicy,
hydrology.profileIds(),
hydrology.surfacePoolIds(),
regions,
biomes,
imageMaps,
@@ -116,6 +117,7 @@ final class PackHydrologyValidator {
private static HydrologyValidation validateHydrology(
String dimensionPath,
JSONObject dimension,
Map<String, JSONObject> biomes,
List<String> errors,
List<String> warnings
) {
@@ -156,7 +158,8 @@ final class PackHydrologyValidator {
profileIds,
errors
);
return new HydrologyValidation(riversEnabled, deepFluidsActive, profileIds);
Set<String> surfacePoolIds = validateSurfacePools(dimensionPath + ".hydrology", hydrology, profileIds, biomes, errors);
return new HydrologyValidation(riversEnabled, deepFluidsActive || !surfacePoolIds.isEmpty(), profileIds, surfacePoolIds);
}
private static Set<String> validateRivers(
@@ -596,6 +599,108 @@ final class PackHydrologyValidator {
return Set.copyOf(profileIds);
}
private static Set<String> validateSurfacePools(
String path,
JSONObject hydrology,
Set<String> riverProfileIds,
Map<String, JSONObject> biomes,
List<String> errors
) {
if (!hydrology.has("surfacePools")) {
return Set.of();
}
Object rawPools = hydrology.opt("surfacePools");
if (!(rawPools instanceof JSONArray pools)) {
errors.add(path + ".surfacePools must be an array.");
return Set.of();
}
if (pools.length() > MAX_DEEP_FLUIDS) {
errors.add(path + ".surfacePools must contain at most " + MAX_DEEP_FLUIDS + " entries.");
}
LinkedHashSet<String> ids = new LinkedHashSet<>();
for (int index = 0; index < pools.length(); index++) {
String poolPath = path + ".surfacePools[" + index + "]";
JSONObject pool = pools.optJSONObject(index);
if (pool == null) {
errors.add(poolPath + " must be an object.");
continue;
}
String id = requiredId(poolPath, pool, errors);
if (id != null) {
if (HydrologyFeatureQuery.isReservedKeyword(id)) {
errors.add(poolPath + ".id '" + id + "' is a reserved hydrology feature selector.");
} else if (riverProfileIds.contains(id)) {
errors.add(poolPath + ".id '" + id + "' collides with a river profile.");
} else if (!ids.add(id)) {
errors.add(poolPath + ".id must be unique inside hydrology.surfacePools.");
}
}
validateFluidPalette(poolPath, pool, true, errors);
PackJsonFieldChecks.validateOptionalDoubleRange(poolPath, pool, "density", 0D, 64D, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(poolPath, pool, "spacing", 32, 8192, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(poolPath, pool, "minimumRadius", 2, 16, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(poolPath, pool, "maximumRadius", 2, 16, errors);
PackJsonFieldChecks.validateOptionalIntegerRange(poolPath, pool, "depth", 1, 8, errors);
if (integerValue(pool, "minimumRadius", 4) > integerValue(pool, "maximumRadius", 7)) {
errors.add(poolPath + ".minimumRadius must not exceed maximumRadius.");
}
Object rawBiome = pool.opt("biome");
if (rawBiome instanceof String biome && !biome.isBlank() && !biomes.containsKey(biome)) {
errors.add(poolPath + ".biome references unknown biome '" + biome + "'.");
}
}
return Set.copyOf(ids);
}
private static void validatePolicyPoolsInDimension(
String path,
JSONObject policy,
String dimensionKey,
Set<String> pools,
List<String> errors
) {
if (policy == null) {
return;
}
JSONArray references = policy.optJSONArray("surfacePools");
if (references == null) {
return;
}
for (int index = 0; index < references.length(); index++) {
String pool = references.optString(index, null);
if (pool != null && ID_PATTERN.matcher(pool).matches() && !pools.contains(pool)) {
addDistinct(errors, path + ".surfacePools[" + index + "] references unknown surface pool '"
+ pool + "' in Dimension '" + dimensionKey + "'.");
}
}
}
private static void validatePolicyPools(String path, JSONObject policy, List<String> errors) {
if (!policy.has("surfacePools") || policy.opt("surfacePools") == JSONObject.NULL) {
return;
}
Object rawPools = policy.opt("surfacePools");
if (!(rawPools instanceof JSONArray pools)) {
errors.add(path + ".surfacePools must be an array or null.");
return;
}
if (pools.length() > MAX_POLICY_REFERENCES) {
errors.add(path + ".surfacePools must contain at most " + MAX_POLICY_REFERENCES + " entries.");
}
Set<String> seen = new HashSet<>();
for (int index = 0; index < pools.length(); index++) {
Object raw = pools.opt(index);
String referencePath = path + ".surfacePools[" + index + "]";
if (!(raw instanceof String id) || !ID_PATTERN.matcher(id).matches()) {
errors.add(referencePath + " must name a surface pool id.");
continue;
}
if (!seen.add(id)) {
errors.add(referencePath + " duplicates surface pool '" + id + "'.");
}
}
}
private static boolean validateDeepFluids(
String path,
JSONObject hydrology,
@@ -787,6 +892,7 @@ final class PackHydrologyValidator {
validateNullableEnum(path, policy, "routing", ROUTING_MODES, errors);
validateNullableBoolean(path, policy, "outletAdmission", errors);
validatePolicyProfiles(path, policy, errors);
validatePolicyPools(path, policy, errors);
for (String biomeField : POLICY_BIOME_FIELDS) {
validatePolicyBiomes(path, policy, biomeField, biomes, errors);
}
@@ -803,6 +909,7 @@ final class PackHydrologyValidator {
JSONObject dimension,
JSONObject dimensionPolicy,
Set<String> profiles,
Set<String> pools,
Map<String, JSONObject> regions,
Map<String, JSONObject> biomes,
Map<String, JSONObject> imageMaps,
@@ -816,6 +923,13 @@ final class PackHydrologyValidator {
profiles,
errors
);
validatePolicyPoolsInDimension(
dimensionPath + ".riverPolicy",
dimensionPolicy,
dimensionKey,
pools,
errors
);
Deque<String> pending = new ArrayDeque<>();
addPolicyBiomeReferences(pending, dimensionPolicy);
@@ -835,6 +949,13 @@ final class PackHydrologyValidator {
profiles,
errors
);
validatePolicyPoolsInDimension(
"Region '" + regionKey + "'.riverPolicy",
policy,
dimensionKey,
pools,
errors
);
addPolicyBiomeReferences(pending, policy);
for (String biomeField : REGION_BIOME_FIELDS) {
addStringArray(pending, region.optJSONArray(biomeField));
@@ -867,6 +988,13 @@ final class PackHydrologyValidator {
profiles,
errors
);
validatePolicyPoolsInDimension(
"Biome '" + biomeKey + "'.riverPolicy",
policy,
dimensionKey,
pools,
errors
);
addPolicyBiomeReferences(pending, policy);
addStringArray(pending, biome.optJSONArray("children"));
addBiomeReference(pending, biome.optString("carvingBiome", null));
@@ -1374,10 +1502,11 @@ final class PackHydrologyValidator {
private record HydrologyValidation(
boolean riversEnabled,
boolean deepFluidsActive,
Set<String> profileIds
Set<String> profileIds,
Set<String> surfacePoolIds
) {
private static HydrologyValidation inactive() {
return new HydrologyValidation(false, false, Set.of());
return new HydrologyValidation(false, false, Set.of(), Set.of());
}
private boolean active() {
@@ -39,6 +39,7 @@ import art.arcane.iris.engine.object.IrisInterpolator;
import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisDeepFluidConfig;
import art.arcane.iris.engine.object.IrisSurfacePoolConfig;
import art.arcane.iris.engine.object.IrisHydrology;
import art.arcane.iris.engine.object.IrisRiverHydrology;
import art.arcane.iris.engine.object.IrisRiverProfile;
@@ -553,6 +554,20 @@ public class IrisComplex implements DataProvider {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
for (IrisSurfacePoolConfig pool : hydrology.getSurfacePools()) {
if (pool == null) {
throw new IllegalArgumentException("hydrology.surfacePools cannot contain null entries");
}
String profileKey = requireHydrologyKey(pool.getId(), "hydrology.surfacePools[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
pool.getFluidPalette(),
rng.nextParallelRNG(9900 + streamIndex++),
"hydrology surface pool " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
return Collections.unmodifiableMap(streams);
}
@@ -64,6 +64,7 @@ public final class IrisRenderer {
private static final int RIVER_MOUTH = new Color(54, 164, 205).getRGB();
private static final int RIVER_WATERFALL = new Color(190, 236, 255).getRGB();
private static final int CONFIRMED_DEEP_LAVA = new Color(194, 50, 22).getRGB();
private static final int STANDING_POOL = new Color(232, 120, 40).getRGB();
private static final int PROJECTED_SOURCE = new Color(225, 75, 178).getRGB();
private static final int PROJECTED_OUTLET = new Color(235, 145, 54).getRGB();
private static final int PROJECTED_DEEP_FLUID = new Color(175, 70, 118).getRGB();
@@ -199,6 +200,7 @@ public final class IrisRenderer {
case INLAND_GROTTO -> INLAND_GROTTO;
case MOUTH -> RIVER_MOUTH;
case DEEP_POOL, DEEP_CHANNEL -> CONFIRMED_DEEP_LAVA;
case STANDING_POOL -> STANDING_POOL;
};
}
@@ -215,7 +217,7 @@ public final class IrisRenderer {
return switch (kind) {
case SOURCE -> PROJECTED_SOURCE;
case OUTLET -> PROJECTED_OUTLET;
case DEEP_FLUID -> PROJECTED_DEEP_FLUID;
case DEEP_FLUID, POOL -> PROJECTED_DEEP_FLUID;
};
}
@@ -3,5 +3,6 @@ package art.arcane.iris.engine.hydrology;
public enum HydrologyCandidateKind {
SOURCE,
OUTLET,
DEEP_FLUID
DEEP_FLUID,
POOL
}
@@ -19,5 +19,6 @@ public enum HydrologyCandidateRejection {
SURFACE_TRANSITION_UNSUPPORTED,
SURFACE_SEGMENT_UNSUPPORTED,
SURFACE_SINKHOLE_CLEARANCE,
SURFACE_SHAPE_UNSUPPORTED
SURFACE_SHAPE_UNSUPPORTED,
POLICY_EXCLUDED;
}
@@ -32,6 +32,11 @@ public record HydrologyFeatureQuery(Set<HydrologyFeatureType> types, String prof
HydrologyFeatureType.DEEP_POOL,
HydrologyFeatureType.DEEP_CHANNEL
);
private static final Set<HydrologyFeatureType> PROFILED = Set.of(
HydrologyFeatureType.DEEP_POOL,
HydrologyFeatureType.DEEP_CHANNEL,
HydrologyFeatureType.STANDING_POOL
);
private static final Map<String, Set<HydrologyFeatureType>> BUILT_INS = builtIns();
public HydrologyFeatureQuery {
@@ -53,7 +58,7 @@ public record HydrologyFeatureQuery(Set<HydrologyFeatureType> types, String prof
String key = normalizeKeyword(normalized);
Set<HydrologyFeatureType> builtInTypes = BUILT_INS.get(key);
return builtInTypes == null
? new HydrologyFeatureQuery(DEEP, normalized)
? new HydrologyFeatureQuery(PROFILED, normalized)
: new HydrologyFeatureQuery(builtInTypes, null);
}
@@ -91,6 +96,7 @@ public record HydrologyFeatureQuery(Set<HydrologyFeatureType> types, String prof
builtIns.put("mouth", Set.of(HydrologyFeatureType.MOUTH));
builtIns.put("ridge_tunnel", Set.of(HydrologyFeatureType.RIDGE_BORE));
builtIns.put("deep", DEEP);
builtIns.put("pool", Set.of(HydrologyFeatureType.STANDING_POOL));
return Collections.unmodifiableMap(builtIns);
}
}
@@ -15,11 +15,13 @@ public enum HydrologyFeatureType {
INLAND_GROTTO,
MOUTH,
DEEP_POOL,
DEEP_CHANNEL;
DEEP_CHANNEL,
// A standing surface pool (lava or any fluid) with no course: a bowl cut into the ground with a lip.
STANDING_POOL;
public boolean isSurface() {
return switch (this) {
case SURFACE_POOL, RIFFLE, CASCADE, WATERFALL, MOUTH -> true;
case SURFACE_POOL, RIFFLE, CASCADE, WATERFALL, MOUTH, STANDING_POOL -> true;
default -> false;
};
}
@@ -59,6 +61,7 @@ public enum HydrologyFeatureType {
case DEEP_CHANNEL -> 10;
case DEEP_POOL -> 11;
case SURFACE_POOL -> 12;
case STANDING_POOL -> 13;
};
}
}
@@ -5,6 +5,10 @@ import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCandidate;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveContainmentPlanner;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import art.arcane.iris.engine.hydrology.surface.ChannelProfile;
import art.arcane.iris.engine.hydrology.surface.ValleyProfile;
import art.arcane.iris.engine.hydrology.surface.ValleyProfileSolver;
import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline;
import art.arcane.iris.engine.hydrology.surface.SurfaceCourseBuilder;
import art.arcane.iris.engine.hydrology.surface.SurfaceCourseResult;
import art.arcane.iris.engine.hydrology.surface.SurfaceTerminal;
@@ -45,6 +49,7 @@ public final class HydrologyPlanner {
private static final long COURSE_SALT = 0x434f55525345L;
private static final long SEGMENT_SALT = 0x5345474d454e54L;
private static final long DEEP_FLUID_SALT = 0x44454550464cL;
private static final long SURFACE_POOL_SALT = 0x504f4f4cL;
private static final long DEEP_FLUID_X_OFFSET_SALT = 0x44454550584fL;
private static final long DEEP_FLUID_Z_OFFSET_SALT = 0x444545505a4fL;
private static final long DEEP_CHANNEL_HEADING_SALT = 0x44454550484447L;
@@ -1101,6 +1106,7 @@ public final class HydrologyPlanner {
if (includeDeepFluids) {
compileDeepFluidCourses(key, courses, diagnostics);
}
compileSurfacePools(key, courses, diagnostics);
HydrologyFootprintCompiler.ValidationRaster validation = footprintCompiler.compileValidation(courses);
HydrologyObservedPlannedSurface plannedSurface = new HydrologyObservedPlannedSurface(
validation.plannedSurface()
@@ -6112,6 +6118,183 @@ public final class HydrologyPlanner {
));
}
/**
* Standing surface pools: a jittered lattice of candidate sites per pool profile, admitted where the
* policy lists the pool, the ground is open land clear of every accepted course, and the bowl fits
* the incision cap. Each accepted pool is an independent course with one STANDING_POOL segment.
*/
private void compileSurfacePools(
HydrologyTileKey key,
List<RiverCourse> courses,
List<HydrologyDiagnosticCandidate> diagnostics
) {
int tileSize = settings.routing().tileSize();
for (HydrologyPlannerSettings.SurfacePool pool : settings.surfacePools()) {
if (!pool.enabled() || pool.maximumPerTile() == 0) {
continue;
}
long profileSeed = HydrologyHash.mix(worldSeed, SURFACE_POOL_SALT, HydrologyHash.text(pool.id()));
int target = Math.min(pool.maximumPerTile(), expectedCount(pool.density(), HydrologyHash.mix(
profileSeed, key.tileX(), key.tileZ())));
int accepted = 0;
for (DeepSite site : poolSites(key, pool, profileSeed, tileSize)) {
if (accepted >= target) {
addPoolDiagnostic(site, HydrologyCandidateRejection.SOURCE_QUOTA, diagnostics);
continue;
}
HydrologyCandidateRejection rejection = poolAdmission(pool, site, courses);
if (rejection != null) {
addPoolDiagnostic(site, rejection, diagnostics);
continue;
}
RiverCourse course = buildSurfacePoolCourse(pool, site);
if (course == null) {
addPoolDiagnostic(site, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, diagnostics);
continue;
}
courses.add(course);
accepted++;
}
}
}
private ArrayList<DeepSite> poolSites(
HydrologyTileKey key,
HydrologyPlannerSettings.SurfacePool pool,
long profileSeed,
int tileSize
) {
int minimumX = key.minimumBlockX(tileSize);
int minimumZ = key.minimumBlockZ(tileSize);
int spacing = pool.spacing();
int xOffset = HydrologyHash.between(HydrologyHash.mix(profileSeed, DEEP_FLUID_X_OFFSET_SALT), 0, spacing - 1);
int zOffset = HydrologyHash.between(HydrologyHash.mix(profileSeed, DEEP_FLUID_Z_OFFSET_SALT), 0, spacing - 1);
long firstCellX = ceilDiv((long) minimumX - xOffset, spacing);
long firstCellZ = ceilDiv((long) minimumZ - zOffset, spacing);
long lastCellX = Math.floorDiv((long) minimumX + tileSize - 1L - xOffset, spacing);
long lastCellZ = Math.floorDiv((long) minimumZ + tileSize - 1L - zOffset, spacing);
ArrayList<DeepSite> sites = new ArrayList<>();
for (long cellZ = firstCellZ; cellZ <= lastCellZ; cellZ++) {
for (long cellX = firstCellX; cellX <= lastCellX; cellX++) {
long stable = HydrologyHash.mix(profileSeed, cellX, cellZ);
// Jitter inside the cell keeps pools off a visible grid.
int jitter = Math.max(1, spacing / 3);
int x = Math.toIntExact(xOffset + cellX * spacing + HydrologyHash.between(HydrologyHash.mix(stable, 11), -jitter, jitter));
int z = Math.toIntExact(zOffset + cellZ * spacing + HydrologyHash.between(HydrologyHash.mix(stable, 12), -jitter, jitter));
HydrologyTerrainSample terrain = sampleLandBasis(x, z);
sites.add(new DeepSite(x, z, terrain == null ? 0 : terrain.naturalHeight(), stable));
}
}
sites.sort(Comparator.comparingLong(DeepSite::stableId));
return sites;
}
private HydrologyCandidateRejection poolAdmission(
HydrologyPlannerSettings.SurfacePool pool,
DeepSite site,
List<RiverCourse> courses
) {
HydrologyTerrainSample terrain = sampleLandBasis(site.x(), site.z());
if (terrain == null || terrain.ocean() || terrain.naturalHeight() <= settings.seaLevel() + 1) {
return HydrologyCandidateRejection.SURFACE_EXPOSURE;
}
if (!terrain.transitAllowed() || !terrain.surfacePoolKeys().contains(pool.id())) {
return HydrologyCandidateRejection.POLICY_EXCLUDED;
}
long clearance = pool.maximumRadius() + (long) StrictMath.ceil(settings.surface().shoreWidth())
+ settings.surface().banks().maximumBlendWidth() + settings.surface().maximumWidth();
long clearanceSquared = clearance * clearance;
for (RiverCourse course : courses) {
for (HydraulicSegment segment : course.segments()) {
for (HydrologyPoint point : segment.centerline()) {
long deltaX = (long) point.x() - site.x();
long deltaZ = (long) point.z() - site.z();
if (deltaX * deltaX + deltaZ * deltaZ < clearanceSquared) {
return HydrologyCandidateRejection.SOURCE_SPACING;
}
}
}
}
return null;
}
private RiverCourse buildSurfacePoolCourse(HydrologyPlannerSettings.SurfacePool pool, DeepSite site) {
int radius = HydrologyHash.between(HydrologyHash.mix(site.stableId(), 3), pool.minimumRadius(), pool.maximumRadius());
HydrologyTerrainSample terrain = sampleLandBasis(site.x(), site.z());
List<HydrologyPoint> points = List.of(
new HydrologyPoint(site.x() - 1, site.head(), site.z()),
new HydrologyPoint(site.x(), site.head(), site.z()),
new HydrologyPoint(site.x() + 1, site.head(), site.z())
);
SurfaceCenterline centerline = SurfaceCenterline.densify(points);
int count = centerline.size();
double[] halfWidth = new double[count];
double[] depth = new double[count];
double[] bank = new double[count];
Arrays.fill(halfWidth, radius);
Arrays.fill(depth, pool.depth());
Arrays.fill(bank, terrain == null ? 1D : terrain.bankMultiplier());
ChannelProfile profile = new ChannelProfile(halfWidth, depth, bank);
ValleyProfile valley = new ValleyProfileSolver(settings.surface(), this::sampleBasis, settings.seaLevel(), 0)
.solve(centerline, profile, SurfaceTerminal.SINKHOLE, Integer.MIN_VALUE);
if (!valley.accepted()) {
return null;
}
int center = Math.min(1, valley.exposedStations() - 1);
int head = valley.head()[center];
// A pool belongs in a hollow or on level ground: the whole rim must sit within a few blocks of the
// water, or the bowl becomes a scar dug into a slope.
int rimAllowance = pool.depth() + settings.surface().banks().inset() + 2;
for (int station = 0; station < valley.exposedStations(); station++) {
if (valley.crossMax()[station] - valley.head()[station] > rimAllowance) {
return null;
}
}
long courseId = HydrologyHash.mix(worldSeed, SURFACE_POOL_SALT, HydrologyHash.text(pool.id()), site.stableId());
long segmentId = HydrologyHash.mix(courseId, SEGMENT_SALT, HydrologyFeatureType.STANDING_POOL.ordinal());
ArrayList<HydrologyPoint> centerlinePoints = new ArrayList<>(points.size());
for (HydrologyPoint point : points) {
centerlinePoints.add(new HydrologyPoint(point.x(), head, point.z()));
}
HydraulicSegment segment = new HydraulicSegment(
segmentId,
courseId,
HydrologyFeatureType.STANDING_POOL,
head,
head,
radius * 2,
pool.depth(),
false,
false,
centerlinePoints
);
return new RiverCourse(
courseId,
RiverCourseType.SURFACE_POOL,
OptionalLong.empty(),
OptionalLong.empty(),
pool.id(),
1,
List.of(),
List.of(segment)
);
}
private void addPoolDiagnostic(
DeepSite site,
HydrologyCandidateRejection rejection,
List<HydrologyDiagnosticCandidate> diagnostics
) {
diagnostics.add(new HydrologyDiagnosticCandidate(
HydrologyHash.mix(site.stableId(), DIAGNOSTIC_SALT, rejection.ordinal()),
HydrologyCandidateKind.POOL,
HydrologyFeatureType.STANDING_POOL,
new HydrologyPoint(site.x(), site.head(), site.z()),
rejection,
0
));
}
private void compileDeepFluidCourses(
HydrologyTileKey key,
List<RiverCourse> courses,
@@ -10,7 +10,8 @@ public record HydrologyPlannerSettings(
Underground underground,
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids
List<DeepFluid> deepFluids,
List<SurfacePool> surfacePools
) {
private static final long PLAN_FORMAT_REVISION = 6L;
private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4;
@@ -24,7 +25,8 @@ public record HydrologyPlannerSettings(
throw new IllegalArgumentException("Hydrology planner settings cannot contain null sections.");
}
deepFluids = deepFluids == null ? List.of() : List.copyOf(deepFluids);
int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids);
surfacePools = surfacePools == null ? List.of() : List.copyOf(surfacePools);
int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools);
if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) {
throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period.");
}
@@ -50,6 +52,7 @@ public record HydrologyPlannerSettings(
12
),
Geometry.defaults(),
List.of(),
List.of()
);
}
@@ -68,11 +71,14 @@ public record HydrologyPlannerSettings(
for (DeepFluid deepFluid : deepFluids) {
hash = HydrologyHash.mix(hash, deepFluid.hashCode(), HydrologyHash.text(deepFluid.id()));
}
for (SurfacePool pool : surfacePools) {
hash = HydrologyHash.mix(hash, pool.hashCode(), HydrologyHash.text(pool.id()));
}
return hash;
}
public int publicationRadius() {
return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids);
return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools);
}
int crossTileColorPeriod() {
@@ -85,7 +91,8 @@ public record HydrologyPlannerSettings(
Underground underground,
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids
List<DeepFluid> deepFluids,
List<SurfacePool> surfacePools
) {
int alignedHalo = Math.floorDiv(
Math.min(
@@ -133,6 +140,13 @@ public record HydrologyPlannerSettings(
: 0;
radius = Math.max(radius, Math.max(poolReach, channelReach));
}
for (SurfacePool pool : surfacePools) {
if (!pool.enabled()) {
continue;
}
radius = Math.max(radius, pool.maximumRadius() + (int) StrictMath.ceil(surface.shoreWidth())
+ surface.banks().maximumBlendWidth());
}
return radius;
}
@@ -278,6 +292,31 @@ public record HydrologyPlannerSettings(
}
}
/** A standing surface pool profile: a bowl of the given radius and depth, filled with its own fluid. */
public record SurfacePool(
String id,
boolean enabled,
double density,
int spacing,
int minimumRadius,
int maximumRadius,
int depth,
int maximumPerTile,
String biomeKey
) {
public SurfacePool {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Surface pool id must not be blank.");
}
id = id.trim();
if (!Double.isFinite(density) || density < 0D || spacing < 1
|| minimumRadius < 1 || maximumRadius < minimumRadius || depth < 1 || maximumPerTile < 0) {
throw new IllegalArgumentException("Surface pool settings are invalid.");
}
biomeKey = biomeKey == null || biomeKey.isBlank() ? null : biomeKey.trim();
}
}
// The routing gates read one hydraulic threshold: the drop that makes a reach a waterfall.
public record Hydraulics(int waterfallMinimumDrop) {
public Hydraulics {
@@ -29,7 +29,8 @@ public record HydrologyTerrainSample(
String shoreBiomeKey,
String bankBiomeKey,
String floodedCaveBiomeKey,
List<String> preferredProfileKeys
List<String> preferredProfileKeys,
List<String> surfacePoolKeys
) {
public HydrologyTerrainSample {
requireFiniteNonNegative(slope, "slope");
@@ -51,6 +52,7 @@ public record HydrologyTerrainSample(
bankBiomeKey = normalizeKey(bankBiomeKey, parentBiomeKey);
floodedCaveBiomeKey = normalizeKey(floodedCaveBiomeKey, surfaceBiomeKey);
preferredProfileKeys = normalizeProfiles(preferredProfileKeys);
surfacePoolKeys = surfacePoolKeys == null ? List.of() : List.copyOf(surfacePoolKeys);
}
public static HydrologyTerrainSample openLand(int naturalHeight, double slope, String parentBiomeKey) {
@@ -81,7 +83,8 @@ public record HydrologyTerrainSample(
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
List.of("default")
List.of("default"),
List.of()
);
}
@@ -113,7 +116,41 @@ public record HydrologyTerrainSample(
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
List.of("default")
List.of("default"),
List.of()
);
}
public HydrologyTerrainSample withSurfacePoolKeys(List<String> replacementPoolKeys) {
return new HydrologyTerrainSample(
naturalHeight,
slope,
ocean,
caveAvailable,
caveFloorY,
caveFluidY,
transitAllowed,
outletAllowed,
surfaceSourceAllowed,
surfaceSourceRequired,
undergroundSourceAllowed,
undergroundSourceRequired,
routingCost,
surfaceSourceWeight,
undergroundSourceWeight,
widthMultiplier,
depthMultiplier,
incisionMultiplier,
routingMultiplier,
bankMultiplier,
parentBiomeKey,
surfaceBiomeKey,
mouthBiomeKey,
shoreBiomeKey,
bankBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys,
replacementPoolKeys
);
}
@@ -145,7 +182,8 @@ public record HydrologyTerrainSample(
shoreBiomeKey,
bankBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys
preferredProfileKeys,
surfacePoolKeys
);
}
@@ -38,10 +38,11 @@ public record RiverCourse(
}
}
validateSinkholeContinuation(type, segments);
if (type == RiverCourseType.DEEP_FLUID && (sourceNodeId.isPresent() || outletId.isPresent())) {
throw new IllegalArgumentException("Independent deep-fluid courses cannot reference drainage nodes or river outlets.");
boolean independent = type == RiverCourseType.DEEP_FLUID || type == RiverCourseType.SURFACE_POOL;
if (independent && (sourceNodeId.isPresent() || outletId.isPresent())) {
throw new IllegalArgumentException("Independent deep-fluid and pool courses cannot reference drainage nodes or river outlets.");
}
if (type != RiverCourseType.DEEP_FLUID && (sourceNodeId.isEmpty() || outletId.isEmpty())) {
if (!independent && (sourceNodeId.isEmpty() || outletId.isEmpty())) {
throw new IllegalArgumentException("Surface and underground courses require a source and outlet.");
}
}
@@ -3,5 +3,6 @@ package art.arcane.iris.engine.hydrology;
public enum RiverCourseType {
SURFACE,
UNDERGROUND,
DEEP_FLUID
DEEP_FLUID,
SURFACE_POOL
}
@@ -16,6 +16,7 @@ public record EffectiveRiverPolicy(
List<String> shoreBiomes,
List<String> bankBiomes,
List<String> floodedCaveBiomes,
List<String> surfacePools,
double widthMultiplier,
double depthMultiplier,
double incisionMultiplier,
@@ -31,6 +32,7 @@ public record EffectiveRiverPolicy(
shoreBiomes = List.copyOf(shoreBiomes);
bankBiomes = List.copyOf(bankBiomes);
floodedCaveBiomes = List.copyOf(floodedCaveBiomes);
surfacePools = List.copyOf(surfacePools);
}
public boolean allowsSources() {
@@ -43,6 +43,7 @@ public final class RiverPolicyResolver {
private List<String> shoreBiomes = List.of();
private List<String> bankBiomes = List.of();
private List<String> floodedCaveBiomes = List.of();
private List<String> surfacePools = List.of();
private double widthMultiplier = 1D;
private double depthMultiplier = 1D;
private double incisionMultiplier = 1D;
@@ -80,6 +81,9 @@ public final class RiverPolicyResolver {
if (policy.getFloodedCaveBiomes() != null) {
floodedCaveBiomes = List.copyOf(policy.getFloodedCaveBiomes());
}
if (policy.getSurfacePools() != null) {
surfacePools = List.copyOf(policy.getSurfacePools());
}
if (policy.getWidthMultiplier() != null) {
widthMultiplier = policy.getWidthMultiplier();
}
@@ -108,6 +112,7 @@ public final class RiverPolicyResolver {
shoreBiomes,
bankBiomes,
floodedCaveBiomes,
surfacePools,
widthMultiplier,
depthMultiplier,
incisionMultiplier,
@@ -22,6 +22,7 @@ import art.arcane.iris.engine.hydrology.policy.RiverPolicyResolver;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisCoastalRiverGrottoConfig;
import art.arcane.iris.engine.object.IrisDeepFluidConfig;
import art.arcane.iris.engine.object.IrisSurfacePoolConfig;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisHydrology;
import art.arcane.iris.engine.object.IrisInlandRiverGrottoConfig;
@@ -364,11 +365,14 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
}
public List<String> featureQueryKeys() {
ArrayList<String> deepFluidIds = new ArrayList<>(settings.deepFluids().size());
ArrayList<String> profiledIds = new ArrayList<>(settings.deepFluids().size() + settings.surfacePools().size());
for (HydrologyPlannerSettings.DeepFluid deepFluid : settings.deepFluids()) {
deepFluidIds.add(deepFluid.id());
profiledIds.add(deepFluid.id());
}
return HydrologyFeatureQuery.suggestions(deepFluidIds);
for (HydrologyPlannerSettings.SurfacePool pool : settings.surfacePools()) {
profiledIds.add(pool.id());
}
return HydrologyFeatureQuery.suggestions(profiledIds);
}
@Override
@@ -487,7 +491,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
selectKey(policy.shoreBiomes(), parentBiomeKey, biomePatchNoise, 3),
selectKey(policy.bankBiomes(), parentBiomeKey, biomePatchNoise, 4),
selectKey(policy.floodedCaveBiomes(), parentBiomeKey, biomePatchNoise, 5),
profiles
profiles,
policy.surfacePools()
);
return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(resolvedNaturalHeight, terrain);
}
@@ -764,10 +769,29 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
plannerUnderground,
plannerOutlets,
plannerGeometry,
deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing())
deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()),
surfacePools(hydrology.getSurfacePools())
);
}
private static List<HydrologyPlannerSettings.SurfacePool> surfacePools(List<IrisSurfacePoolConfig> configurations) {
ArrayList<HydrologyPlannerSettings.SurfacePool> pools = new ArrayList<>();
for (IrisSurfacePoolConfig configuration : configurations) {
pools.add(new HydrologyPlannerSettings.SurfacePool(
configuration.getId(),
configuration.getDensity() > 0D,
configuration.getDensity(),
configuration.getSpacing(),
configuration.getMinimumRadius(),
configuration.getMaximumRadius(),
configuration.getDepth(),
Math.min(64, maximumSources(configuration.getDensity(), 0)),
configuration.getBiome()
));
}
return List.copyOf(pools);
}
private static HydrologyPlannerSettings.Geometry geometry(
IrisRiverGeometryConfig geometry,
HydrologyPlannerSettings.ChannelShape surfaceShape
@@ -42,7 +42,8 @@ public final class SurfaceFootprintCompiler {
}
public SurfaceFootprint compile(RiverCourse course) {
if (course.type() != RiverCourseType.SURFACE) {
boolean pool = course.type() == RiverCourseType.SURFACE_POOL;
if (course.type() != RiverCourseType.SURFACE && !pool) {
return SurfaceFootprint.empty();
}
ArrayList<HydraulicSegment> exposed = new ArrayList<>();
@@ -59,10 +60,13 @@ public final class SurfaceFootprintCompiler {
if (stations.count() < 1) {
return SurfaceFootprint.empty();
}
SurfaceTerminal terminal = terminal(course, exposed);
SurfaceTerminal terminal = pool ? SurfaceTerminal.SINKHOLE : terminal(course, exposed);
SurfaceCenterline centerline = SurfaceCenterline.densify(stations.points());
ChannelProfile channel = new ChannelProfileBuilder(settings.surface(), sampler, geometry)
ChannelProfile channel = pool
? poolProfile(exposed.getFirst(), centerline)
: new ChannelProfileBuilder(settings.surface(), sampler, geometry)
.build(centerline, course.profileKey(), terminal == SurfaceTerminal.OCEAN_MOUTH);
String poolBiome = pool ? poolBiome(course.profileKey()) : null;
ValleyProfile valley = ValleyProfile.fromHeads(stations.head(), stations.exposedStations());
ErosionField field = new ErosionFieldCompiler(settings.surface(), sampler, settings.seaLevel()).compile(
HydrologyHash.mix(course.id(), COURSE_SEED_SALT),
@@ -90,7 +94,7 @@ public final class SurfaceFootprintCompiler {
column.x(),
column.z(),
column.terrain(),
layer(feature, column, course.profileKey()),
pool ? poolLayer(feature, column, course.profileKey(), poolBiome) : layer(feature, column, course.profileKey()),
column.role(),
column.apron()
));
@@ -98,6 +102,63 @@ public final class SurfaceFootprintCompiler {
return new SurfaceFootprint(columns, field.uncontainedWetCells());
}
/** A pool's bowl and rim carry the pool biome when one is configured, otherwise the surrounding biome. */
private static HydrologyColumnLayer poolLayer(
HydrologyFeatureRef feature,
SurfaceColumn column,
String profileKey,
String poolBiome
) {
HydrologyTerrainSample terrain = column.terrain();
String biome = poolBiome == null ? terrain.parentBiomeKey() : poolBiome;
boolean channel = column.role() == SurfaceRole.CHANNEL;
boolean shore = column.role() == SurfaceRole.SHORE;
return new HydrologyColumnLayer(
feature,
column.height(),
column.headY(),
column.headY(),
channel,
shore,
!channel,
channel,
false,
false,
true,
channel,
false,
profileKey,
biome,
biome,
biome,
biome,
terrain.floodedCaveBiomeKey()
);
}
private ChannelProfile poolProfile(HydraulicSegment segment, SurfaceCenterline centerline) {
int count = centerline.size();
double[] halfWidth = new double[count];
double[] depth = new double[count];
double[] bank = new double[count];
for (int station = 0; station < count; station++) {
HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]);
halfWidth[station] = Math.max(0.5D, segment.width() / 2D);
depth[station] = Math.max(1D, segment.depth());
bank[station] = terrain == null ? 1D : terrain.bankMultiplier();
}
return new ChannelProfile(halfWidth, depth, bank);
}
private String poolBiome(String poolId) {
for (HydrologyPlannerSettings.SurfacePool pool : settings.surfacePools()) {
if (pool.id().equals(poolId)) {
return pool.biomeKey();
}
}
return null;
}
private static HydrologyColumnLayer layer(HydrologyFeatureRef feature, SurfaceColumn column, String profileKey) {
HydrologyTerrainSample terrain = column.terrain();
if (column.apron()) {
@@ -18,4 +18,8 @@ public class IrisHydrology {
@ArrayType(type = IrisDeepFluidConfig.class)
@Desc("Independent deep-fluid systems that do not consume the surface or underground river source budgets.")
private KList<IrisDeepFluidConfig> deepFluids = new KList<>();
@ArrayType(type = IrisSurfacePoolConfig.class, min = 0)
@Desc("Standing surface pools such as lava pools. Each region or biome opts in through riverPolicy.surfacePools.")
private KList<IrisSurfacePoolConfig> surfacePools = new KList<>();
}
@@ -54,6 +54,10 @@ public class IrisRiverPolicy {
@Desc("Flooded cave and grotto biome selection. An empty list explicitly clears the inherited selection.")
private KList<String> floodedCaveBiomes = null;
@ArrayType(type = String.class)
@Desc("Standing surface pool ids (hydrology.surfacePools) allowed in this area. An empty list explicitly clears the inherited selection.")
private KList<String> surfacePools = null;
@MinNumber(0.0001)
@MaxNumber(16)
@Desc("Multiplier applied to accepted river width.")
@@ -0,0 +1,54 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
import art.arcane.iris.engine.object.annotations.MaxNumber;
import art.arcane.iris.engine.object.annotations.MinNumber;
import art.arcane.iris.engine.object.annotations.RegistryListResource;
import art.arcane.iris.engine.object.annotations.Required;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Accessors(chain = true)
@NoArgsConstructor
@Desc("A standing surface pool: a bowl cut into open ground and filled with a fluid, placed where the river policy allows it.")
@Data
public class IrisSurfacePoolConfig {
@Required
@Desc("Pool identifier. Policies list it under surfacePools and locators accept it as a selector.")
private String id = "lava_pool";
@Required
@Desc("Fluid palette filling the pool.")
private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("lava");
@MinNumber(0)
@MaxNumber(64)
@Desc("Expected pools per hydrology tile where the policy allows them.")
private double density = 0.75D;
@MinNumber(32)
@MaxNumber(8192)
@Desc("Nominal spacing in blocks between candidate pool sites.")
private int spacing = 384;
@MinNumber(2)
@MaxNumber(16)
@Desc("Smallest pool radius in blocks.")
private int minimumRadius = 4;
@MinNumber(2)
@MaxNumber(16)
@Desc("Largest pool radius in blocks.")
private int maximumRadius = 7;
@MinNumber(1)
@MaxNumber(8)
@Desc("Fluid depth at the centre of the pool.")
private int depth = 2;
@RegistryListResource(IrisBiome.class)
@Desc("Biome applied to the pool bed and its rim. Leave empty to keep the surrounding biome.")
private String biome = "";
}
@@ -156,6 +156,70 @@ public class PackHydrologyValidatorTest {
assertTrue(result.errors().toString(), result.errors().stream().noneMatch((String error) -> error.contains(".bed")));
}
@Test
public void surfacePoolRulesRejectBadRangesAndUnknownPolicyReferences() throws Exception {
File pack = pack("""
{
"regions": ["region"],
"dimensionHeight": {"min": -256, "max": 512},
"hydrology": {
"rivers": {"enabled": true, "routing": {"tileSize": 1024, "sampleSpacing": 64, "oceanOutlets": true}},
"surfacePools": [
{
"id": "lava_pool",
"fluidPalette": {"palette": [{"block": "minecraft:lava"}]},
"spacing": 16,
"minimumRadius": 8,
"maximumRadius": 4,
"depth": 9,
"biome": "no/such/biome"
},
{"id": "pool", "fluidPalette": {"palette": [{"block": "minecraft:water"}]}}
]
},
"riverPolicy": {"surfacePools": ["lava_pool", "missing_pool"]}
}
""");
PackHydrologyValidator.Validation result = validate(pack);
assertContains(result.errors(), "spacing must be at least 32");
assertContains(result.errors(), "minimumRadius must not exceed maximumRadius");
assertContains(result.errors(), "depth must be at most 8");
assertContains(result.errors(), "biome references unknown biome 'no/such/biome'");
assertContains(result.errors(), "'pool' is a reserved hydrology feature selector");
assertContains(result.errors(), "references unknown surface pool 'missing_pool'");
}
@Test
public void surfacePoolsAcceptAConfiguredPoolAndItsPolicyReference() throws Exception {
File pack = pack("""
{
"regions": ["region"],
"dimensionHeight": {"min": -256, "max": 512},
"hydrology": {
"rivers": {"enabled": true, "routing": {"tileSize": 1024, "sampleSpacing": 64, "oceanOutlets": true}},
"surfacePools": [
{
"id": "lava_pool",
"fluidPalette": {"palette": [{"block": "minecraft:lava"}]},
"density": 0.75,
"spacing": 384,
"minimumRadius": 4,
"maximumRadius": 7,
"depth": 2
}
]
},
"riverPolicy": {"surfacePools": ["lava_pool"]}
}
""");
PackHydrologyValidator.Validation result = validate(pack);
assertTrue(result.errors().toString(), result.errors().stream().noneMatch((String error) -> error.contains("surfacePool")));
}
@Test
public void rejectsMouthLevelingDistanceBeyondTheRouteLength() throws Exception {
File pack = pack(riversDimension("""
@@ -46,6 +46,7 @@ public class IrisRendererRiverTest {
expected.put(HydrologyFeatureType.MOUTH, new Color(54, 164, 205).getRGB());
expected.put(HydrologyFeatureType.DEEP_POOL, new Color(194, 50, 22).getRGB());
expected.put(HydrologyFeatureType.DEEP_CHANNEL, new Color(194, 50, 22).getRGB());
expected.put(HydrologyFeatureType.STANDING_POOL, new Color(232, 120, 40).getRGB());
assertEquals(HydrologyFeatureType.values().length, expected.size());
for (HydrologyFeatureType type : HydrologyFeatureType.values()) {
@@ -268,7 +268,7 @@ public class HydrologyFootprintCompilerTest {
base.underground(),
base.outlets(),
HydrologyPlannerSettings.Geometry.defaults(),
List.of(deepFluid)
List.of(deepFluid), List.of()
);
HydraulicSegment segment = new HydraulicSegment(
15L,
@@ -510,6 +510,7 @@ public class HydrologyModelTest {
"mouth",
"ridge_tunnel",
"deep",
"pool",
"acid",
"deep_lava"
), suggestions);
@@ -527,7 +528,8 @@ public class HydrologyModelTest {
"inland_grotto",
"mouth",
"ridge_tunnel",
"deep"
"deep",
"pool"
);
assertEquals(builtInKeywords, HydrologyFeatureQuery.suggestions(List.of()));
@@ -288,7 +288,7 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
8
),
stableGeometry(),
List.of(deepFluid)
List.of(deepFluid), List.of()
);
}
@@ -362,7 +362,7 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
base.outlets().maximumPerTile()
),
base.geometry(),
base.deepFluids()
base.deepFluids(), List.of()
);
}
@@ -404,7 +404,7 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
"shore",
"dry",
"flooded",
List.of("water")
List.of("water"), List.of()
);
};
}
@@ -445,7 +445,7 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
sample.shoreBiomeKey(),
sample.bankBiomeKey(),
sample.floodedCaveBiomeKey(),
sample.preferredProfileKeys()
sample.preferredProfileKeys(), List.of()
);
};
}
@@ -478,7 +478,7 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
"shore",
"dry",
"flooded",
List.of("water")
List.of("water"), List.of()
);
}
@@ -704,7 +704,7 @@ public class HydrologyPlannerGlobalInvariantTest {
8
),
HydrologyPlannerSettings.Geometry.defaults(),
deepFluids
deepFluids, List.of()
);
}
@@ -752,7 +752,7 @@ public class HydrologyPlannerGlobalInvariantTest {
),
base.outlets(),
HydrologyPlannerSettings.Geometry.defaults(),
base.deepFluids()
base.deepFluids(), List.of()
);
}
@@ -945,7 +945,7 @@ public class HydrologyPlannerGlobalInvariantTest {
"shore",
"dry",
"flooded",
List.of("default")
List.of("default"), List.of()
);
}
@@ -996,7 +996,7 @@ public class HydrologyPlannerGlobalInvariantTest {
"shore",
"dry",
"flooded",
List.of("default")
List.of("default"), List.of()
);
}
@@ -1045,7 +1045,7 @@ public class HydrologyPlannerGlobalInvariantTest {
"shore",
"dry",
"flooded",
List.of("default")
List.of("default"), List.of()
);
}
@@ -62,7 +62,7 @@ public class HydrologyPlannerSettingsTest {
base.underground(),
base.outlets(),
HydrologyPlannerSettings.Geometry.defaults(),
List.of(overlongChannel)
List.of(overlongChannel), List.of()
));
}
@@ -0,0 +1,119 @@
package art.arcane.iris.engine.hydrology;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class HydrologyPlannerSurfacePoolTest {
private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0);
@Test
public void poolsAreCarvedWhereThePolicyAllowsThemAndNowhereElse() {
HydrologyPlannerSettings settings = settings(2D, 128, 4, 6, 2);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land")
.withSurfacePoolKeys(x < 512 ? List.of("lava_pool") : List.of());
HydrologyTile tile = new HydrologyPlanner(91L, settings, terrain).plan(TILE);
HydrologyTile replay = new HydrologyPlanner(91L, settings, terrain).plan(TILE);
assertEquals(tile, replay);
List<RiverCourse> pools = pools(tile);
assertFalse(tile.diagnosticCandidates().toString(), pools.isEmpty());
for (RiverCourse pool : pools) {
assertEquals("lava_pool", pool.profileKey());
assertEquals(1, pool.segments().size());
HydraulicSegment segment = pool.segments().getFirst();
assertEquals(HydrologyFeatureType.STANDING_POOL, segment.type());
assertTrue(segment.width() >= 8 && segment.width() <= 12);
assertTrue(segment.start().x() < 512);
assertEquals(79, segment.upstreamHeadY());
assertEquals(segment.upstreamHeadY(), segment.downstreamHeadY());
}
}
@Test
public void aPoolIsABowlWithALipInsideTheFootprint() {
HydrologyPlannerSettings settings = settings(2D, 256, 5, 5, 2);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land")
.withSurfacePoolKeys(List.of("lava_pool"));
HydrologyTile tile = new HydrologyPlanner(7L, settings, terrain).plan(TILE);
RiverCourse pool = pools(tile).getFirst();
HydrologyPoint center = pool.segments().getFirst().centerline().get(1);
int wet = 0;
int shore = 0;
for (HydrologyColumnSample column : tile.footprint().columns().values()) {
HydrologyColumnLayer layer = column.primarySurfaceLayer().orElse(null);
if (layer == null || layer.feature().courseId() != pool.id()) {
continue;
}
assertEquals(HydrologyFeatureType.STANDING_POOL, layer.feature().type());
assertTrue(column.terrainHeight() <= column.naturalHeight());
if (layer.channel()) {
wet++;
assertEquals("lava_pool", layer.profileKey());
assertEquals(79, layer.fluidHeadY());
assertTrue(layer.bedY() < layer.fluidHeadY());
assertTrue(center.distanceSquared2D(new HydrologyPoint(column.x(), 0, column.z())) <= 8 * 8);
} else if (layer.shore()) {
shore++;
assertTrue(column.terrainHeight() >= 80);
}
}
assertTrue("wet=" + wet, wet >= 60 && wet <= 140);
assertTrue("shore=" + shore, shore > 0);
}
@Test
public void poolsKeepClearOfEachOther() {
HydrologyPlannerSettings settings = settings(8D, 64, 6, 6, 2);
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land")
.withSurfacePoolKeys(List.of("lava_pool"));
HydrologyTile tile = new HydrologyPlanner(3L, settings, terrain).plan(TILE);
List<RiverCourse> pools = pools(tile);
assertTrue(pools.size() >= 2);
for (int first = 0; first < pools.size(); first++) {
for (int second = first + 1; second < pools.size(); second++) {
HydrologyPoint a = pools.get(first).segments().getFirst().centerline().get(1);
HydrologyPoint b = pools.get(second).segments().getFirst().centerline().get(1);
assertTrue(a.distanceSquared2D(b) >= 40L * 40L);
}
}
}
private static List<RiverCourse> pools(HydrologyTile tile) {
ArrayList<RiverCourse> pools = new ArrayList<>();
for (RiverCourse course : tile.courses()) {
if (course.type() == RiverCourseType.SURFACE_POOL) {
pools.add(course);
}
}
return pools;
}
private static HydrologyPlannerSettings settings(double density, int spacing, int minimumRadius, int maximumRadius, int depth) {
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Routing routing = new HydrologyPlannerSettings.Routing(
1024, 64, base.routing().maximumRouteNodes(), 4096, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D);
return new HydrologyPlannerSettings(
base.seaLevel(),
routing,
base.surface(),
base.hydraulics(),
base.underground(),
base.outlets(),
base.geometry(),
List.of(),
List.of(new HydrologyPlannerSettings.SurfacePool(
"lava_pool", true, density, spacing, minimumRadius, maximumRadius, depth, 64, null))
);
}
}
@@ -514,7 +514,7 @@ public class HydrologyPlannerTest {
"shore",
"dry",
"flooded",
List.of("alpha")
List.of("alpha"), List.of()
);
};
HydrologyTile tile = new HydrologyPlanner(812L, settings, terrain, solidCaveView()).plan(TILE);
@@ -588,7 +588,7 @@ public class HydrologyPlannerTest {
base.underground(),
base.outlets(),
base.geometry(),
base.deepFluids()
base.deepFluids(), List.of()
);
HydrologyTerrainSampler repeatedRidges = (int x, int z) -> {
if (x >= 112) {
@@ -1122,7 +1122,7 @@ public class HydrologyPlannerTest {
base.underground(),
base.outlets(),
HydrologyPlannerSettings.Geometry.defaults(),
base.deepFluids()
base.deepFluids(), List.of()
);
HydrologyPlannerSettings settings = withOutlets(shaped, new HydrologyPlannerSettings.Outlets(
true,
@@ -1408,7 +1408,7 @@ public class HydrologyPlannerTest {
base.underground(),
base.outlets(),
base.geometry(),
base.deepFluids()
base.deepFluids(), List.of()
);
HydrologyTerrainSampler terrain = (int x, int z) -> {
if (x >= 112) {
@@ -1769,7 +1769,7 @@ public class HydrologyPlannerTest {
4
),
stableGeometry(),
deepFluids
deepFluids, List.of()
);
}
@@ -1824,7 +1824,7 @@ public class HydrologyPlannerTest {
1
),
HydrologyPlannerSettings.Geometry.defaults(),
List.of()
List.of(), List.of()
);
}
@@ -1893,7 +1893,7 @@ public class HydrologyPlannerTest {
"shore",
"dry",
"flooded",
List.of("alpha")
List.of("alpha"), List.of()
);
};
}
@@ -1958,7 +1958,7 @@ public class HydrologyPlannerTest {
"shore",
"dry",
"flooded",
List.of("alpha", "beta")
List.of("alpha", "beta"), List.of()
);
}
@@ -1990,7 +1990,7 @@ public class HydrologyPlannerTest {
"shore",
"dry",
"flooded",
List.of("alpha")
List.of("alpha"), List.of()
);
}
@@ -2031,7 +2031,7 @@ public class HydrologyPlannerTest {
"shore",
"dry",
"flooded",
List.of("beta", "alpha")
List.of("beta", "alpha"), List.of()
);
}
@@ -2063,7 +2063,7 @@ public class HydrologyPlannerTest {
"shore",
"dry",
"flooded",
List.of("alpha")
List.of("alpha"), List.of()
);
}
@@ -2095,7 +2095,7 @@ public class HydrologyPlannerTest {
"shore",
"dry",
"flooded",
List.of("alpha", "beta")
List.of("alpha", "beta"), List.of()
);
}
@@ -2121,7 +2121,7 @@ public class HydrologyPlannerTest {
settings.underground(),
settings.outlets(),
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids()
settings.deepFluids(), List.of()
);
}
@@ -2150,7 +2150,7 @@ public class HydrologyPlannerTest {
),
settings.outlets(),
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids()
settings.deepFluids(), List.of()
);
}
@@ -2180,7 +2180,7 @@ public class HydrologyPlannerTest {
),
settings.outlets(),
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids()
settings.deepFluids(), List.of()
);
}
@@ -2209,7 +2209,7 @@ public class HydrologyPlannerTest {
),
settings.outlets(),
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids()
settings.deepFluids(), List.of()
);
}
@@ -2269,7 +2269,7 @@ public class HydrologyPlannerTest {
settings.underground(),
outlets,
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids()
settings.deepFluids(), List.of()
);
}
@@ -231,7 +231,7 @@ public class HydrologyStyledGeometryTest {
4
),
HydrologyPlannerSettings.Geometry.defaults(),
deepFluids
deepFluids, List.of()
);
}
@@ -264,7 +264,7 @@ public class HydrologyStyledGeometryTest {
"shore",
"dry",
"flooded",
List.of("water")
List.of("water"), List.of()
);
}
int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D);
@@ -296,7 +296,7 @@ public class HydrologyStyledGeometryTest {
"shore",
"dry",
"flooded",
List.of("water")
List.of("water"), List.of()
);
}
@@ -77,7 +77,7 @@ public final class HydrologyTerrainSampleTest {
"shore",
"dry",
"flooded",
profiles
profiles, List.of()
);
}
}
@@ -259,7 +259,7 @@ public class HydrologyTileCacheTest {
2
),
HydrologyPlannerSettings.Geometry.defaults(),
List.of()
List.of(), List.of()
);
}
@@ -297,7 +297,7 @@ public class HydrologyTileCacheTest {
2
),
HydrologyPlannerSettings.Geometry.defaults(),
List.of()
List.of(), List.of()
);
}
@@ -338,7 +338,7 @@ public class HydrologyTileCacheTest {
"shore",
"dry",
"flooded",
List.of("default")
List.of("default"), List.of()
);
}
}
@@ -104,7 +104,7 @@ public class ChannelProfileBuilderTest {
"parent",
"parent",
"parent",
List.of("water")
List.of("water"), List.of()
);
return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY);
}
@@ -1566,7 +1566,7 @@ public class MantleHydrologyComponentTest {
8
),
HydrologyPlannerSettings.Geometry.defaults(),
List.of()
List.of(), List.of()
);
}
@@ -1603,7 +1603,7 @@ public class MantleHydrologyComponentTest {
"shore",
"dry",
"flooded",
List.of("default")
List.of("default"), List.of()
);
}
@@ -215,7 +215,8 @@ public final class HydrologyPackProbe {
enum CoverageFamily {
SURFACE,
UNDERGROUND,
DEEP
DEEP,
POOL
}
enum VerificationFamily {
@@ -4080,6 +4081,7 @@ public final class HydrologyPackProbe {
case SURFACE -> CoverageFamily.SURFACE;
case UNDERGROUND -> CoverageFamily.UNDERGROUND;
case DEEP_FLUID -> CoverageFamily.DEEP;
case SURFACE_POOL -> CoverageFamily.POOL;
};
return new ConfiguredCoverage(family, course.profileKey());
}
@@ -265,7 +265,7 @@ public final class HydrologyPlannerProbe {
4
),
HydrologyPlannerSettings.Geometry.defaults(),
List.of()
List.of(), List.of()
);
}
@@ -304,7 +304,7 @@ public final class HydrologyPlannerProbe {
"shore",
"dry",
"flooded",
List.of("water")
List.of("water"), List.of()
);
};
}
@@ -355,7 +355,7 @@ public final class HydrologyPlannerProbe {
"shore",
"bank",
"flooded",
List.of("water")
List.of("water"), List.of()
);
};
}
@@ -476,7 +476,7 @@ public final class HydrologyPlannerProbe {
8
),
HydrologyPlannerSettings.Geometry.defaults(),
List.of(deepFluid)
List.of(deepFluid), List.of()
);
}
@@ -521,7 +521,7 @@ public final class HydrologyPlannerProbe {
"shore",
"dry",
"flooded",
List.of("water")
List.of("water"), List.of()
);
};
}
@@ -554,7 +554,7 @@ public final class HydrologyPlannerProbe {
"shore",
"dry",
"flooded",
List.of("water")
List.of("water"), List.of()
);
}
@@ -186,7 +186,7 @@ public final class RiverTransectProbe {
writeTileDiagnostics(new File(configuration.output(), "tile-diagnostics.png"), tile, complex, runtime, seaLevel);
List<CourseSummary> summaries = new ArrayList<>();
for (RiverCourse course : tile.courses()) {
if (course.type() != RiverCourseType.SURFACE) {
if (course.type() != RiverCourseType.SURFACE && course.type() != RiverCourseType.SURFACE_POOL) {
continue;
}
List<HydrologyPoint> exposedPath = exposedPath(course);