From a4fe527d65ec55b258ae42ec417a7b203e47aa50 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 3 Sep 2026 05:03:00 -0400 Subject: [PATCH] Polish surface rivers: estuaries, ponds, erosion, sea caves Surface rivers gain a drowned inlet at the mouth, a spring pond and an inland terminal pond, a configurable erosion profile (thalweg fraction, blend curve, bed noise, smoothing) and a per-area shore biome width; banks lose freeboard and inset for a single sink. Underground courses accept tributaries, coastal grottos can be admitted as standalone sea caves, and a region or biome can confine the rivers born in it. Tile planning forks its ring work through HydrologyForkJoin. Pack validation, the schema, the probes and the tests follow. --- .../core/pack/PackHydrologyValidator.java | 71 +- .../engine/framework/render/IrisRenderer.java | 2 +- .../hydrology/HydrologyCandidateKind.java | 4 +- .../HydrologyCandidateRejection.java | 1 + .../hydrology/HydrologyFootprintCompiler.java | 102 +- .../engine/hydrology/HydrologyForkJoin.java | 91 ++ .../engine/hydrology/HydrologyPlanner.java | 1279 ++++++++++++++--- .../hydrology/HydrologyPlannerSettings.java | 194 ++- .../hydrology/HydrologyTerrainSample.java | 40 +- .../engine/hydrology/HydrologyTileCache.java | 193 ++- .../iris/engine/hydrology/RiverCourse.java | 6 +- .../engine/hydrology/RiverCourseType.java | 4 +- .../policy/EffectiveRiverPolicy.java | 8 +- .../hydrology/policy/RiverConfinement.java | 8 + .../hydrology/policy/RiverPolicyResolver.java | 21 +- .../IrisHydrologyRoutingTerrainSampler.java | 39 +- .../runtime/IrisHydrologyRuntime.java | 143 +- .../surface/ChannelProfileBuilder.java | 20 +- .../surface/ErosionFieldCompiler.java | 237 ++- .../surface/SurfaceFootprintCompiler.java | 3 +- .../hydrology/surface/SurfaceTerminal.java | 4 +- .../surface/ValleyProfileSolver.java | 117 +- .../object/IrisCoastalRiverGrottoConfig.java | 5 +- .../engine/object/IrisRiverMouthConfig.java | 15 + .../iris/engine/object/IrisRiverPolicy.java | 8 + .../engine/object/IrisRiverRoutingConfig.java | 7 +- .../iris/engine/object/IrisSeaCaveConfig.java | 37 + .../object/IrisSurfaceRiverBankConfig.java | 7 +- .../object/IrisSurfaceRiverChannelConfig.java | 4 +- .../engine/object/IrisSurfaceRiverConfig.java | 8 +- .../object/IrisSurfaceRiverErosionConfig.java | 37 + .../object/IrisSurfaceRiverPondConfig.java | 38 + .../object/IrisSurfaceRiverPondsConfig.java | 18 + .../object/IrisUndergroundRiverConfig.java | 5 + .../core/pack/PackHydrologyValidatorTest.java | 177 ++- .../core/project/IrisRiverSchemaTest.java | 23 +- .../HydrologyCaveCourseFilterTest.java | 153 ++ .../HydrologyFootprintCompilerTest.java | 284 +++- .../hydrology/HydrologyForkJoinTest.java | 109 ++ .../engine/hydrology/HydrologyModelTest.java | 62 + .../HydrologyPlannerConfinementTest.java | 132 ++ ...CrossTileCaveAdmissionIntegrationTest.java | 66 +- .../HydrologyPlannerGlobalInvariantTest.java | 33 +- .../HydrologyPlannerSeaCaveTest.java | 331 +++++ .../HydrologyPlannerSettingsTest.java | 191 ++- .../HydrologyPlannerSurfacePoolTest.java | 8 +- .../hydrology/HydrologyPlannerTest.java | 307 +++- .../HydrologyPlannerTributaryTest.java | 274 +++- .../HydrologyStyledGeometryTest.java | 18 +- .../hydrology/HydrologyTerrainSampleTest.java | 40 +- .../hydrology/HydrologyTileCacheTest.java | 252 +++- .../policy/RiverPolicyResolverTest.java | 26 + ...risHydrologyRoutingTerrainSamplerTest.java | 5 +- .../IrisHydrologyRuntimeSettingsTest.java | 84 +- .../surface/ChannelProfileBuilderTest.java | 52 +- .../surface/ErosionFieldCompilerTest.java | 352 ++++- .../surface/SurfaceCourseBuilderTest.java | 37 +- .../surface/SurfaceFootprintCompilerTest.java | 52 + .../surface/ValleyProfileSolverTest.java | 177 ++- .../MantleHydrologyComponentTest.java | 12 +- .../object/IrisRiverConfigurationTest.java | 9 +- .../arcane/iris/probe/HydrologyPackProbe.java | 120 +- .../iris/probe/HydrologyPlannerProbe.java | 44 +- .../arcane/iris/probe/RiverTransectProbe.java | 35 +- 64 files changed, 5619 insertions(+), 622 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java create mode 100644 core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverConfinement.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisSeaCaveConfig.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverErosionConfig.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondConfig.java create mode 100644 core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondsConfig.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyForkJoinTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerConfinementTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSeaCaveTest.java diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java index 0ae3e4c51..3967a19d4 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java @@ -183,9 +183,10 @@ final class PackHydrologyValidator { if (geometry != null) { validateGeometry(path + ".geometry", geometry, errors); } + int minimumSurfaceCourseLength = routing == null ? 384 : integerValue(routing, "minimumSurfaceCourseLength", 384); SourceBudget surfaceBudget = surface == null ? SourceBudget.defaults() - : validateSurface(path + ".surface", surface, dimension, errors, warnings); + : validateSurface(path + ".surface", surface, dimension, minimumSurfaceCourseLength, errors, warnings); SourceBudget undergroundBudget = underground == null ? SourceBudget.defaults() : validateUnderground(path + ".underground", underground, dimension, errors, warnings); @@ -276,6 +277,7 @@ final class PackHydrologyValidator { errors ); PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "maximumOutletsPerTile", 1, 256, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "maximumCoastalOutletsPerTile", 0, 64, errors); PackJsonFieldChecks.validateOptionalBoolean(path, routing, "oceanOutlets", errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "valleyPreference", 0D, 8D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "uphillPenalty", 0D, 128D, errors); @@ -320,6 +322,7 @@ final class PackHydrologyValidator { String path, JSONObject surface, JSONObject dimension, + int minimumSurfaceCourseLength, List errors, List warnings ) { @@ -330,6 +333,8 @@ final class PackHydrologyValidator { JSONObject flow = nestedObject(surface, "flow", path, errors); JSONObject mouths = nestedObject(surface, "mouths", path, errors); JSONObject bed = nestedObject(surface, "bed", path, errors); + JSONObject erosion = nestedObject(surface, "erosion", path, errors); + JSONObject ponds = nestedObject(surface, "ponds", path, errors); SourceBudget budget = sources == null ? SourceBudget.defaults() @@ -344,11 +349,17 @@ final class PackHydrologyValidator { validateSurfaceFlow(path + ".flow", flow, errors); } if (mouths != null) { - validateMouths(path + ".mouths", mouths, errors); + validateMouths(path + ".mouths", mouths, minimumSurfaceCourseLength, errors); } if (bed != null) { validateSurfaceBed(path + ".bed", bed, errors); } + if (erosion != null) { + validateSurfaceErosion(path + ".erosion", erosion, errors); + } + if (ponds != null) { + validateSurfacePonds(path + ".ponds", ponds, errors); + } boolean surfaceEnabled = booleanValue(surface, "enabled", true); if (surfaceEnabled && budget.maximumExpectedSources() == 0) { @@ -368,7 +379,7 @@ final class PackHydrologyValidator { private static void validateSurfaceChannel(String path, JSONObject channel, List errors) { validateRange(path, channel, "width", 1D, 128D, 4D, 8D, errors); validateRange(path, channel, "depth", 1D, 64D, 2D, 4D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "inset", 0, 3, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "sink", 0, 3, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "maximumIncision", 1, 32, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, channel, "springWidthRatio", 1D, 4D, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "springLength", 4, 96, errors); @@ -376,8 +387,33 @@ final class PackHydrologyValidator { PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "roughnessWavelength", 4, 64, errors); } + private static void validateSurfaceErosion(String path, JSONObject erosion, List errors) { + PackJsonFieldChecks.validateOptionalBoolean(path, erosion, "enabled", errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, erosion, "smoothingRadius", 0, 64, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, erosion, "thalwegFraction", 0D, 0.95D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, erosion, "blendCurve", 0.25D, 4D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, erosion, "bedNoise", 0D, 2D, errors); + } + + private static void validateSurfacePonds(String path, JSONObject ponds, List errors) { + for (String end : new String[] {"source", "terminal"}) { + JSONObject pond = nestedObject(ponds, end, path, errors); + if (pond == null) { + continue; + } + String pondPath = path + "." + end; + PackJsonFieldChecks.validateOptionalBoolean(pondPath, pond, "enabled", errors); + PackJsonFieldChecks.validateOptionalIntegerRange(pondPath, pond, "minimumRadius", 1, 32, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(pondPath, pond, "maximumRadius", 1, 32, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(pondPath, pond, "depth", 1, 8, errors); + if (pond.has("minimumRadius") && pond.has("maximumRadius") + && pond.optInt("minimumRadius") > pond.optInt("maximumRadius")) { + errors.add(pondPath + ".minimumRadius must not exceed maximumRadius"); + } + } + } + private static void validateSurfaceBanks(String path, JSONObject banks, List errors) { - PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "freeboard", 0, 4, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "shoreWidth", 0.5D, 6D, errors); PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "blendSlope", 0.5D, 12D, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "minimumBlendWidth", 1, 64, errors); @@ -426,9 +462,16 @@ final class PackHydrologyValidator { } } - private static void validateMouths(String path, JSONObject mouths, List errors) { + private static void validateMouths(String path, JSONObject mouths, int minimumSurfaceCourseLength, List errors) { PackJsonFieldChecks.validateOptionalDoubleRange(path, mouths, "flareRatio", 1D, 4D, errors); PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "maximumOceanApron", 0, 32, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "inletLength", 0, 256, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "inletDepth", 0, 16, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "maximumIncision", 0, 128, errors); + // The drowned reach is the end of a river, never the whole of one. + if (mouths.has("inletLength") && integerValue(mouths, "inletLength", 64) > minimumSurfaceCourseLength) { + errors.add(path + ".inletLength must not exceed routing.minimumSurfaceCourseLength."); + } } private static SourceBudget validateUnderground( @@ -554,6 +597,22 @@ final class PackHydrologyValidator { errors.add(path + ".poolLevel must be SEA_LEVEL."); } } + if (coastal) { + JSONObject seaCaves = nestedObject(grotto, "seaCaves", path, errors); + if (seaCaves != null) { + String seaCavePath = path + ".seaCaves"; + PackJsonFieldChecks.validateOptionalBoolean(seaCavePath, seaCaves, "enabled", errors); + PackJsonFieldChecks.validateOptionalIntegerRange(seaCavePath, seaCaves, "maximumPerTile", 0, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(seaCavePath, seaCaves, "minimumSpacing", 16, 8192, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(seaCavePath, seaCaves, "minimumCoastHeight", 1, 128, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(seaCavePath, seaCaves, "depth", 0, 128, errors); + int horizontalRadius = integerValue(grotto, "horizontalRadius", DEFAULT_COASTAL_GROTTO.getHorizontalRadius()); + int minimumSpacing = integerValue(seaCaves, "minimumSpacing", DEFAULT_COASTAL_GROTTO.getSeaCaves().getMinimumSpacing()); + if (minimumSpacing < horizontalRadius * 2) { + errors.add(seaCavePath + ".minimumSpacing must be at least twice " + path + ".horizontalRadius."); + } + } + } int verticalRadius = integerValue( grotto, @@ -902,6 +961,8 @@ final class PackHydrologyValidator { validateNullableDouble(path, policy, "incisionMultiplier", 0D, 16D, errors); validateNullableDouble(path, policy, "routingMultiplier", 0D, 64D, errors); validateNullableDouble(path, policy, "bankMultiplier", 0D, 4D, errors); + validateNullableDouble(path, policy, "shoreBiomeWidth", 0D, 32D, errors); + validateNullableBoolean(path, policy, "confined", errors); return policy; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java b/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java index 1ddd68c40..5be049825 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java @@ -215,7 +215,7 @@ public final class IrisRenderer { public static int hydrologyDiagnosticColor(HydrologyCandidateKind kind) { Objects.requireNonNull(kind, "kind"); return switch (kind) { - case SOURCE -> PROJECTED_SOURCE; + case SOURCE, TRIBUTARY -> PROJECTED_SOURCE; case OUTLET -> PROJECTED_OUTLET; case DEEP_FLUID, POOL -> PROJECTED_DEEP_FLUID; }; diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java index e6dd9330f..55c04c011 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java @@ -4,5 +4,7 @@ public enum HydrologyCandidateKind { SOURCE, OUTLET, DEEP_FLUID, - POOL + POOL, + /** A later course to an outlet that could not be joined to the outlet's main course. */ + TRIBUTARY } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java index 2cf36b17a..d6d23ce8e 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java @@ -3,6 +3,7 @@ package art.arcane.iris.engine.hydrology; public enum HydrologyCandidateRejection { NO_LEGAL_OUTLET, NO_DRAINAGE_PATH, + CONFINED_NO_OUTLET, ROUTE_LIMIT, COURSE_TOO_SHORT, SOURCE_QUOTA, diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java index d4534035b..9318794ed 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java @@ -998,6 +998,9 @@ final class HydrologyFootprintCompiler { continue; } addLayer(columns, x, z, terrain, layer); + if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO) { + addAdjacentSeaApron(columns, course, segment, point, x, z, flowX, flowZ, pointFeatures); + } } } @@ -1010,9 +1013,10 @@ final class HydrologyFootprintCompiler { int z, SurfaceRasterIndex plannedSurface ) { - if (!terrainRoofedSurfaceTransition(course, segment)) { + if (!terrainRoofedLayer(course, segment)) { return layer; } + boolean seaCave = course.type() == RiverCourseType.SEA_CAVE; int plannedTerrainHeight = plannedSurface.resolve( x, z, @@ -1031,6 +1035,10 @@ final class HydrologyFootprintCompiler { if (neighborTerrain == null) { continue; } + if (seaCave && neighborTerrain.ocean()) { + // The sea face is the intended opening; the seabed never lowers the chamber roof. + continue; + } if (plannedSurface.ownsSurfaceChannelAt(neighborX, neighborZ, course.id())) { continue; } @@ -1041,6 +1049,10 @@ final class HydrologyFootprintCompiler { ); maximumCeiling = Math.min(maximumCeiling, neighborSurface - 1); } + if (seaCave && Math.min(layer.ceilingY(), maximumCeiling) <= layer.fluidHeadY()) { + // A sea-cave column needs air between the sea and its roof; a roof at or below sea level is not carved. + return null; + } if (maximumCeiling == Integer.MAX_VALUE || layer.ceilingY() <= maximumCeiling) { return layer; } @@ -1158,7 +1170,7 @@ final class HydrologyFootprintCompiler { ); int fluidHead = channel ? shape.fluidHead() : bed; int ceiling = channel ? localCeiling(shape, ellipsoidScale) : fluidHead; - if (channel && terrainRoofedSurfaceTransition(course, segment)) { + if (channel && terrainRoofedLayer(course, segment)) { ceiling = Math.max(fluidHead, Math.min(ceiling, terrain.naturalHeight() - 1)); } boolean terrainOwned = !falling; @@ -1235,13 +1247,24 @@ final class HydrologyFootprintCompiler { HydrologyTerrainSample terrain, HydrologyFeatureRef[] pointFeatures ) { - if (segment.type() != HydrologyFeatureType.MOUTH - && segment.type() != HydrologyFeatureType.COASTAL_GROTTO) { - return; - } - if (distance > settings.outlets().maximumOceanApron() + 0.25D) { + if (!oceanApronEligible(segment, distance)) { return; } + addOceanApronLayer(columns, course, segment, point, x, z, flowX, flowZ, terrain, pointFeatures); + } + + private void addOceanApronLayer( + Long2ObjectLinkedOpenHashMap columns, + RiverCourse course, + HydraulicSegment segment, + HydrologyPoint point, + int x, + int z, + int flowX, + int flowZ, + HydrologyTerrainSample terrain, + HydrologyFeatureRef[] pointFeatures + ) { int fluidHead = Math.min(settings.seaLevel(), point.y()); HydrologyFeatureRef feature = feature( course, @@ -1396,7 +1419,14 @@ final class HydrologyFootprintCompiler { return shape.fluidHead() + localExtent; } - private boolean terrainRoofedSurfaceTransition(RiverCourse course, HydraulicSegment segment) { + /** + * Layers whose ceiling follows the terrain: the underground transition of a surface river, and the + * chamber of a standalone sea cave, which must never break the surface above it. + */ + private boolean terrainRoofedLayer(RiverCourse course, HydraulicSegment segment) { + if (course.type() == RiverCourseType.SEA_CAVE) { + return segment.type() == HydrologyFeatureType.COASTAL_GROTTO; + } return course.type() == RiverCourseType.SURFACE && (segment.type() == HydrologyFeatureType.UNDERGROUND_POOL || segment.type() == HydrologyFeatureType.UNDERGROUND_DROP); @@ -1680,7 +1710,61 @@ final class HydrologyFootprintCompiler { private boolean oceanApronEligible(HydraulicSegment segment, double distance) { return (segment.type() == HydrologyFeatureType.MOUTH || segment.type() == HydrologyFeatureType.COASTAL_GROTTO) - && distance <= settings.outlets().maximumOceanApron() + 0.25D; + && distance <= apronReach(segment) + 0.25D; + } + + /** + * How far from the centerline the ocean apron reaches. A coastal grotto chamber is as wide as its + * horizontal radius, so its apron reaches at least that far: every sea column the chamber touches + * must carry the apron that declares the sea face an intentional opening. + */ + private int apronReach(HydraulicSegment segment) { + int reach = settings.outlets().maximumOceanApron(); + if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO) { + return Math.max(reach, settings.outlets().coastalGrotto().horizontalRadius()); + } + return reach; + } + + /** + * The organic chamber boundary can put a sea column one block past the apron reach while the land + * column beside it is still carved; the apron follows the chamber there so the face stays open. + */ + private void addAdjacentSeaApron( + Long2ObjectLinkedOpenHashMap columns, + RiverCourse course, + HydraulicSegment segment, + HydrologyPoint point, + int x, + int z, + int flowX, + int flowZ, + HydrologyFeatureRef[] pointFeatures + ) { + for (int[] offset : HORIZONTAL_NEIGHBORS) { + int neighborX = x + offset[0]; + int neighborZ = z + offset[1]; + HydrologyTerrainSample neighborTerrain = sampleTerrainBasis(neighborX, neighborZ); + if (neighborTerrain == null) { + continue; + } + if (classifyNatural(neighborX, neighborZ) != HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + && !neighborTerrain.ocean()) { + continue; + } + addOceanApronLayer( + columns, + course, + segment, + point, + neighborX, + neighborZ, + flowX, + flowZ, + neighborTerrain, + pointFeatures + ); + } } private HydrologyFeatureRef feature( diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java new file mode 100644 index 000000000..73abd9359 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyForkJoin.java @@ -0,0 +1,91 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinTask; +import java.util.concurrent.ForkJoinWorkerThread; + +/** + * Runs a batch of independent planning tasks in parallel without ever parking a pool worker on work + * that lives in another pool. On a ForkJoin worker the tasks are forked into that worker's own pool + * and joined with work helping, so a saturated pool still finishes them on the joining thread; on any + * other thread they run on the fallback executor, or inline when there is none. Results keep the + * task order and a task's unchecked exception is rethrown as is. + */ +public final class HydrologyForkJoin { + private HydrologyForkJoin() { + } + + public static List invokeAll(List> tasks, Executor fallback) { + Objects.requireNonNull(tasks, "tasks"); + if (tasks.size() < 2 || fallback == null && !(Thread.currentThread() instanceof ForkJoinWorkerThread)) { + return runInline(tasks); + } + if (Thread.currentThread() instanceof ForkJoinWorkerThread) { + return forkJoin(tasks); + } + return runOn(tasks, fallback); + } + + private static List runInline(List> tasks) { + ArrayList results = new ArrayList<>(tasks.size()); + for (Callable task : tasks) { + results.add(call(task)); + } + return results; + } + + private static List forkJoin(List> tasks) { + ArrayList> forked = new ArrayList<>(tasks.size()); + for (Callable task : tasks) { + forked.add(ForkJoinTask.adapt(task)); + } + ForkJoinTask.invokeAll(forked); + ArrayList results = new ArrayList<>(tasks.size()); + for (ForkJoinTask task : forked) { + results.add(task.join()); + } + return results; + } + + private static List runOn(List> tasks, Executor executor) { + ArrayList> futures = new ArrayList<>(tasks.size()); + for (Callable task : tasks) { + futures.add(CompletableFuture.supplyAsync(() -> call(task), executor)); + } + ArrayList results = new ArrayList<>(tasks.size()); + for (CompletableFuture future : futures) { + try { + results.add(future.join()); + } catch (CompletionException failure) { + throw unwrap(failure.getCause()); + } + } + return results; + } + + private static T call(Callable task) { + try { + return task.call(); + } catch (RuntimeException | Error failure) { + throw failure; + } catch (Exception failure) { + throw new IllegalStateException("Hydrology planning task failed.", failure); + } + } + + private static RuntimeException unwrap(Throwable cause) { + if (cause instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (cause instanceof Error error) { + throw error; + } + return new IllegalStateException("Hydrology planning task failed.", cause); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java index 253bc3bff..37cd727cd 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -12,6 +12,7 @@ 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; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.noise.SimplexNoise; @@ -34,6 +35,7 @@ import java.util.Objects; import java.util.OptionalLong; import java.util.PriorityQueue; import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; @@ -43,13 +45,24 @@ public final class HydrologyPlanner { private static final long NODE_SALT = 0x4e4f4445L; private static final double CROSS_DROP_WEIGHT = 3D; private static final long OUTLET_SALT = 0x4f55544c4554L; + /** Sea outlet types in the order the coastal budget takes them, one of each type per turn. */ + private static final List COASTAL_OUTLET_TYPES = List.of( + HydrologyFeatureType.MOUTH, + HydrologyFeatureType.COASTAL_GROTTO + ); private static final long EDGE_SALT = 0x45444745L; private static final long SURFACE_SOURCE_SALT = 0x53555246414345L; private static final long UNDERGROUND_SOURCE_SALT = 0x554e444552L; private static final long COURSE_SALT = 0x434f55525345L; private static final long SEGMENT_SALT = 0x5345474d454e54L; + private static final int TRIBUTARY_JUNCTION_ATTEMPTS = 4; + private static final int TRIBUTARY_BACKWATER_LIMIT = 3; + private static final int TRIBUTARY_JUNCTION_FLAT_STATIONS = 6; + private static final int TRIBUTARY_JUNCTION_PAIR_INDEX = 0x4a4f494e; private static final long DEEP_FLUID_SALT = 0x44454550464cL; private static final long SURFACE_POOL_SALT = 0x504f4f4cL; + private static final long SEA_CAVE_SALT = 0x534541434156L; + private static final double SEA_CAVE_JITTER_DEGREES = 25D; 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; @@ -116,11 +129,13 @@ public final class HydrologyPlanner { private final Cache> refinedEdgeCache; private final ConcurrentHashMap> resolvingOwners; private final ThreadLocal planningSamples; + private final ThreadLocal draftProfiles; private final SimplexNoise routeAnchorX; private final SimplexNoise routeAnchorZ; private final SimplexNoise routeWormPrimary; private final SimplexNoise routeWormDetail; private final SurfaceCourseBuilder surfaceCourseBuilder; + private final SurfaceCourseBuilder tributaryCourseBuilder; public HydrologyPlanner(long worldSeed, HydrologyPlannerSettings settings, HydrologyTerrainSampler sampler) { this( @@ -231,6 +246,7 @@ public final class HydrologyPlanner { .build(); this.resolvingOwners = new ConcurrentHashMap<>(); this.planningSamples = new ThreadLocal<>(); + this.draftProfiles = new ThreadLocal<>(); this.routeAnchorX = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_ANCHOR_X_SALT)); this.routeAnchorZ = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_ANCHOR_Z_SALT)); this.routeWormPrimary = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_WORM_PRIMARY_SALT)); @@ -242,6 +258,14 @@ public final class HydrologyPlanner { settings.seaLevel(), settings.routing().minimumSurfaceCourseLength() ); + // A tributary only has to be a real reach before it joins its stem: half the course minimum. + this.tributaryCourseBuilder = new SurfaceCourseBuilder( + settings.surface(), + this::sampleBasis, + geometrySampler, + settings.seaLevel(), + settings.routing().minimumSurfaceCourseLength() / 2 + ); } public long worldSeed() { @@ -274,7 +298,23 @@ public final class HydrologyPlanner { } public HydrologyTile plan(HydrologyTileKey key) { - return materializeAcceptedTile(resolveCrossTileOwner(key)); + long started = System.nanoTime(); + CrossTileResolution resolution = resolveCrossTileOwner(key); + long materializeStarted = System.nanoTime(); + HydrologyTile tile = materializeAcceptedTile(resolution); + long finished = System.nanoTime(); + IrisLogging.debug( + "Hydrology tile %d,%d planned in %dms: owners=%d resolve=%dms materialize=%dms courses=%d on %s", + key.tileX(), + key.tileZ(), + (finished - started) / 1_000_000L, + resolution.ownerCount(), + (materializeStarted - started) / 1_000_000L, + (finished - materializeStarted) / 1_000_000L, + tile.courses().size(), + Thread.currentThread().getName() + ); + return tile; } void clearOwnerDrafts() { @@ -289,12 +329,23 @@ public final class HydrologyPlanner { if (previous == null) { planningSamples.set(new PlanningSamples()); } + DraftProfile previousProfile = draftProfiles.get(); + DraftProfile profile = new DraftProfile(); + draftProfiles.set(profile); + long started = System.nanoTime(); try { - return compileScopedOwnerDraft(key, crossTileAdmission); + HydrologyOwnerDraft draft = compileScopedOwnerDraft(key, crossTileAdmission); + profile.log(key, ownerColorRank(key), System.nanoTime() - started); + return draft; } finally { if (previous == null) { planningSamples.remove(); } + if (previousProfile == null) { + draftProfiles.remove(); + } else { + draftProfiles.set(previousProfile); + } } } @@ -309,8 +360,11 @@ public final class HydrologyPlanner { RoutingPlan undergroundRouting = null; SourceSelection surfaceSelection = SourceSelection.empty(true); SourceSelection undergroundSelection = SourceSelection.empty(false); + DraftProfile profile = currentDraftProfile(); + long phaseStarted = System.nanoTime(); if (hasRoutedSourceSearch()) { SourceRoutingContext primaryContext = sourceRoutingContext(key); + phaseStarted = profile.record(DraftPhase.CONTEXT, phaseStarted); grid = primaryContext.grid(); surfaceRouting = primaryContext.surfaceRouting(); undergroundRouting = primaryContext.undergroundRouting(); @@ -335,6 +389,7 @@ public final class HydrologyPlanner { diagnostics, sourceRoutingContexts ); + phaseStarted = profile.record(DraftPhase.SELECT, phaseStarted); } HydrologyFootprintCompiler footprintCompiler = new HydrologyFootprintCompiler( settings, @@ -370,6 +425,7 @@ public final class HydrologyPlanner { sourceCompilations, diagnostics ); + phaseStarted = profile.record(DraftPhase.SETTLE, phaseStarted); PublicationAttempt publication = null; List fallbackOutlets = grid == null || surfaceRouting == null ? List.of() @@ -495,6 +551,7 @@ public final class HydrologyPlanner { break; } } + profile.record(DraftPhase.PUBLISH, phaseStarted); if (publication == null) { throw new IllegalStateException("Hydrology publication did not execute."); } @@ -725,7 +782,9 @@ public final class HydrologyPlanner { } candidateKeys.add(candidateKey); } + long dependenciesStarted = System.nanoTime(); List resolvedCandidates = resolveLowerRankOwners(candidateKeys, context); + currentDraftProfile().recordDependencies(candidateKeys.size(), dependenciesStarted); for (int index = 0; index < candidateKeys.size(); index++) { HydrologyTileKey candidateKey = candidateKeys.get(index); int candidateRank = ownerColorRank(candidateKey); @@ -752,25 +811,23 @@ public final class HydrologyPlanner { List candidateKeys, CrossTileResolutionContext context ) { - if (candidateKeys.size() < 2 - || !IrisPlatforms.isBound() - || MultiBurst.burst.ownsCurrentThread()) { + if (candidateKeys.size() < 2 || !IrisPlatforms.isBound()) { ArrayList resolved = new ArrayList<>(candidateKeys.size()); for (HydrologyTileKey candidateKey : candidateKeys) { resolved.add(resolveCrossTileOwner(candidateKey, context)); } return List.copyOf(resolved); } - ArrayList> futures = new ArrayList<>(candidateKeys.size()); + // Neighbour drafts fork into the pool this thread belongs to (or the burst pool from outside + // any pool) and are joined with work helping, so a saturated pool never deadlocks on them. + ArrayList> tasks = new ArrayList<>(candidateKeys.size()); for (HydrologyTileKey candidateKey : candidateKeys) { - futures.add(CompletableFuture.supplyAsync( - () -> resolveIndependentOwner(candidateKey), - MultiBurst.burst - )); + tasks.add(() -> resolveIndependentOwner(candidateKey)); } + List owners = HydrologyForkJoin.invokeAll(tasks, MultiBurst.burst); ArrayList resolved = new ArrayList<>(candidateKeys.size()); for (int index = 0; index < candidateKeys.size(); index++) { - CrossTileResolvedOwner owner = awaitResolvedOwner(futures.get(index)); + CrossTileResolvedOwner owner = owners.get(index); context.remember(candidateKeys.get(index), owner); resolved.add(owner); } @@ -1128,6 +1185,9 @@ public final class HydrologyPlanner { compileDeepFluidCourses(key, courses, diagnostics); } compileSurfacePools(key, courses, diagnostics); + if (includeDeepFluids && grid != null) { + compileSeaCaves(grid, courses, diagnostics); + } HydrologyFootprintCompiler.ValidationRaster validation = footprintCompiler.compileValidation(courses); HydrologyObservedPlannedSurface plannedSurface = new HydrologyObservedPlannedSurface( validation.plannedSurface() @@ -1339,11 +1399,10 @@ public final class HydrologyPlanner { .centerline() .getLast(); if (course.type() == RiverCourseType.SURFACE) { - RiverCourse owner = edgeOwners.get(new CourseEdgeKey(course.type(), course.drainageEdges().get(ownedEdgeCount).id())); - RiverCourse tributary = surfaceTributary(course, owner, boundary, ownedEdgeCount, diagnostics); - if (tributary != null) { - normalized.add(tributary); - } + // Surface tributaries are cut at their junction before they are built, so a surface + // course still sharing drainage with a longer trunk has no junction to keep it. + addTributaryDiagnostic(course.id(), course.segments().getFirst().start(), + HydrologyCandidateRejection.NO_DRAINAGE_PATH, ownedEdgeCount, diagnostics); continue; } int retainedSegmentCount = retainedSegmentCount(course.segments(), boundary); @@ -1366,131 +1425,376 @@ public final class HydrologyPlanner { } /** - * Turns a surface course that lost its shared drainage to a longer trunk into a tributary: it is cut - * where its own centerline first comes within a channel width of the stem and joined to the nearest - * stem station with a short connector. The tributary must still be a real reach and must arrive at - * or above the stem's water, so it steps down into the river instead of pooling below it. + * Builds a later draft to the stem's outlet as a tributary. The draft's path is cut where it first + * comes within a stem width of the stem or first enters drainage the stem already owns, and it is + * shaped with the stem's water level at that station as its terminal, so it arrives at or above the + * stem and steps down into it. When the tributary would arrive below the stem's water there, the + * junction slides downstream along the stem to the first station whose water is low enough and the + * course is shaped again. The tributary owns only the drainage edges upstream of the junction. */ - private RiverCourse surfaceTributary( - RiverCourse course, - RiverCourse owner, - HydrologyPoint boundary, - int ownedEdgeCount, + private RiverCourse buildSurfaceTributary( + SurfaceCourseDraft draft, + RiverCourse stem, List diagnostics ) { + CoursePath path = draft.path(); + HydrologyPoint sourcePoint = path.points().getFirst(); + HashSet stemEdges = new HashSet<>(); + for (DrainageEdge edge : stem.drainageEdges()) { + stemEdges.add(edge.id()); + } ArrayList stemStations = new ArrayList<>(); ArrayList stemWidths = new ArrayList<>(); - for (HydraulicSegment segment : owner.segments()) { + for (HydraulicSegment segment : stem.segments()) { for (HydrologyPoint point : segment.centerline()) { stemStations.add(point); stemWidths.add(segment.width()); } } - ArrayList retained = new ArrayList<>(); - int stations = 0; - HydrologyPoint joinStation = null; - for (HydraulicSegment segment : course.segments()) { - List centerline = segment.centerline(); - int joinIndex = -1; - for (int index = 0; index < centerline.size() && joinIndex < 0; index++) { - HydrologyPoint point = centerline.get(index); - for (int stemIndex = 0; stemIndex < stemStations.size(); stemIndex++) { - HydrologyPoint station = stemStations.get(stemIndex); - double reach = (segment.width() + stemWidths.get(stemIndex)) / 2D + 1D; - if (point.distanceSquared2D(station) <= reach * reach) { - joinIndex = index; - joinStation = station; - break; - } + int minimumStemIndex = 0; + HydrologyCandidateRejection rejection = HydrologyCandidateRejection.NO_DRAINAGE_PATH; + int rejectionDetail = 0; + for (int attempt = 0; attempt < TRIBUTARY_JUNCTION_ATTEMPTS; attempt++) { + int[] junction = tributaryJunction(path, stemStations, stemWidths, stemEdges, minimumStemIndex); + if (junction == null) { + break; + } + int joinIndex = junction[0]; + int stemIndex = junction[1]; + HydrologyPoint joinStation = stemStations.get(stemIndex); + if (joinIndex < 1) { + rejection = HydrologyCandidateRejection.COURSE_TOO_SHORT; + rejectionDetail = 0; + break; + } + TributaryBuild build = buildSurfaceTributaryAt(draft, joinIndex, stemIndex, stemStations, stemWidths, stemEdges); + if (build.course() != null) { + return build.course(); + } + rejection = build.rejection(); + rejectionDetail = build.rejectionDetail(); + if (rejection != HydrologyCandidateRejection.SURFACE_HEAD_RANGE) { + break; + } + // The tributary arrives rejectionDetail blocks below the stem's water: slide the junction + // downstream to the first stem station whose water is that much lower. + int target = joinStation.y() - rejectionDetail; + int nextStemIndex = -1; + for (int index = stemIndex + 1; index < stemStations.size(); index++) { + if (stemStations.get(index).y() <= target) { + nextStemIndex = index; + break; } } - if (joinIndex < 0) { - retained.add(segment); - stations += centerline.size(); - continue; + if (nextStemIndex < 0) { + break; } - if (joinIndex > 0 || retained.isEmpty()) { - List prefix = centerline.subList(0, joinIndex + 1); - int downstreamHead = prefix.getLast().y(); - int drop = segment.upstreamHeadY() - downstreamHead; - HydrologyFeatureType type = drop == 0 && segment.type().isDrop() - ? HydrologyFeatureType.SURFACE_POOL - : segment.type(); - retained.add(new HydraulicSegment( + minimumStemIndex = nextStemIndex; + } + addTributaryDiagnostic(draft.courseId(), sourcePoint, rejection, rejectionDetail, diagnostics); + return null; + } + + /** + * The first draft point, in path order, that lies within a stem width of a stem station at or past + * {@code minimumStemIndex} or that enters drainage the stem owns, as {draft point index, stem station + * index}; null when the draft never meets the stem there. Among the stations the point can reach the + * lowest water wins, then the flattest stretch, so the tributary's mouth sits at the river's level + * where the two channels touch and never above the stem's shore. + */ + private static int[] tributaryJunction( + CoursePath path, + List stemStations, + List stemWidths, + Set stemEdges, + int minimumStemIndex + ) { + for (int index = 0; index < path.points().size(); index++) { + HydrologyPoint point = path.points().get(index); + boolean sharedEdge = index > 0 && stemEdges.contains(path.pairEdges().get(index - 1).id()); + int best = -1; + int bestFlatness = -1; + double bestDistance = Double.POSITIVE_INFINITY; + for (int stemIndex = minimumStemIndex; stemIndex < stemStations.size(); stemIndex++) { + double distance = point.distanceSquared2D(stemStations.get(stemIndex)); + double reach = stemWidths.get(stemIndex) + 1D; + boolean reachable = distance <= reach * reach; + if (!reachable && !(sharedEdge && best < 0 && distance < bestDistance)) { + continue; + } + int flatness = reachable ? stemFlatness(stemStations, stemIndex) : -1; + int level = stemStations.get(stemIndex).y(); + int bestLevel = best < 0 ? Integer.MAX_VALUE : stemStations.get(best).y(); + if (level < bestLevel + || level == bestLevel && (flatness > bestFlatness || flatness == bestFlatness && distance < bestDistance)) { + best = stemIndex; + bestFlatness = flatness; + bestDistance = distance; + } + } + if (best >= 0) { + return new int[] {index, best}; + } + } + return null; + } + + /** How many of the stem stations around {@code stemIndex} share its water level. */ + private static int stemFlatness(List stemStations, int stemIndex) { + int level = stemStations.get(stemIndex).y(); + int flat = 0; + for (int offset = -TRIBUTARY_JUNCTION_FLAT_STATIONS; offset <= TRIBUTARY_JUNCTION_FLAT_STATIONS; offset++) { + int index = stemIndex + offset; + if (index >= 0 && index < stemStations.size() && stemStations.get(index).y() == level) { + flat++; + } + } + return flat; + } + + /** Raises every trailing station below {@code level} to it, so the tail is a flat pool at the stem's water. */ + private static void backwater(List segments, int level) { + for (int index = segments.size() - 1; index >= 0; index--) { + HydraulicSegment segment = segments.get(index); + if (segment.upstreamHeadY() >= level && segment.downstreamHeadY() >= level) { + break; + } + ArrayList centerline = new ArrayList<>(segment.centerline().size()); + for (HydrologyPoint point : segment.centerline()) { + centerline.add(point.y() < level ? new HydrologyPoint(point.x(), level, point.z()) : point); + } + int upstream = Math.max(segment.upstreamHeadY(), level); + int downstream = Math.max(segment.downstreamHeadY(), level); + segments.set(index, new HydraulicSegment( + segment.id(), + segment.courseId(), + upstream == downstream ? HydrologyFeatureType.SURFACE_POOL : segment.type(), + upstream, + downstream, + segment.width(), + segment.depth(), + segment.fallingFluid(), + segment.receivingPool(), + List.copyOf(centerline) + )); + if (segment.upstreamHeadY() >= level) { + break; + } + } + } + + /** + * Lowers the trailing stations of a tributary towards {@code level}, one block per cascade run of + * centerline distance from the junction and never deeper than the channel may cut, so the reach + * grades down into the stem instead of hanging above it. Where the ground holds a station up, the + * stations upstream of it are held with it, so the graded reach still descends into the stem. + */ + void levelApproach(List segments, int level) { + int run = Math.max(1, settings.surface().banks().cascadeRun()); + int maximumIncision = settings.surface().maximumIncision(); + double distance = 0D; + HydrologyPoint previous = null; + int downstreamHead = Integer.MIN_VALUE; + for (int index = segments.size() - 1; index >= 0; index--) { + HydraulicSegment segment = segments.get(index); + ArrayList centerline = new ArrayList<>(segment.centerline()); + boolean changed = false; + boolean settled = false; + for (int pointIndex = centerline.size() - 1; pointIndex >= 0; pointIndex--) { + HydrologyPoint point = centerline.get(pointIndex); + if (previous != null) { + distance += StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + } + previous = point; + int allowed = level + (int) StrictMath.floor(distance / run); + if (point.y() <= allowed) { + settled = true; + break; + } + int floor = sampleBasis(point.x(), point.z()).naturalHeight() - maximumIncision; + int lowered = Math.min(point.y(), Math.max(Math.max(allowed, floor), downstreamHead)); + if (lowered < point.y()) { + centerline.set(pointIndex, new HydrologyPoint(point.x(), lowered, point.z())); + changed = true; + } + downstreamHead = lowered; + } + if (changed) { + // A level reach may carry a bump above its heads (a pool's centerline is not graded); + // once its tail is cut down the reach becomes a graded transition, which must never rise + // downstream, so every station is held at or below the one before it. + int ceiling = centerline.getFirst().y(); + for (int pointIndex = 1; pointIndex < centerline.size(); pointIndex++) { + HydrologyPoint point = centerline.get(pointIndex); + if (point.y() > ceiling) { + centerline.set(pointIndex, new HydrologyPoint(point.x(), ceiling, point.z())); + } else { + ceiling = point.y(); + } + } + int upstream = centerline.getFirst().y(); + int downstream = centerline.getLast().y(); + HydrologyFeatureType type = segment.type(); + if (upstream > downstream && !type.isDrop()) { + type = upstream - downstream >= settings.hydraulics().waterfallMinimumDrop() + ? HydrologyFeatureType.WATERFALL + : upstream - downstream == 1 ? HydrologyFeatureType.RIFFLE : HydrologyFeatureType.CASCADE; + } else if (upstream == downstream && type.isDrop()) { + type = HydrologyFeatureType.SURFACE_POOL; + } + segments.set(index, new HydraulicSegment( segment.id(), segment.courseId(), type, - segment.upstreamHeadY(), - downstreamHead, + upstream, + downstream, segment.width(), segment.depth(), - false, - false, - prefix + segment.fallingFluid(), + segment.receivingPool(), + List.copyOf(centerline) )); - stations += prefix.size(); } - break; + if (settled) { + break; + } } - if (joinStation == null) { - addTributaryDiagnostic(course, HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0, diagnostics); - return null; + } + + private static int lastWidth(SurfaceCourseResult result) { + return Math.max(1, result.lastWidth()); + } + + private record TributaryBuild(RiverCourse course, HydrologyCandidateRejection rejection, int rejectionDetail) { + private static TributaryBuild rejected(HydrologyCandidateRejection rejection, int detail) { + return new TributaryBuild(null, rejection, detail); } - if (retained.isEmpty() || stations < settings.routing().minimumSurfaceCourseLength() / 2) { - addTributaryDiagnostic(course, HydrologyCandidateRejection.COURSE_TOO_SHORT, stations, diagnostics); - return null; + } + + private TributaryBuild buildSurfaceTributaryAt( + SurfaceCourseDraft draft, + int joinIndex, + int stemIndex, + List stemStations, + List stemWidths, + Set stemEdges + ) { + CoursePath path = draft.path(); + HydrologyPoint joinStation = stemStations.get(stemIndex); + ArrayList points = new ArrayList<>(path.points().subList(0, joinIndex + 1)); + HydrologyPoint last = points.getLast(); + if (last.x() != joinStation.x() || last.z() != joinStation.z()) { + points.add(new HydrologyPoint(joinStation.x(), last.y(), joinStation.z())); + } + LinkedHashSet ownedEdges = new LinkedHashSet<>(); + for (DrainageEdge edge : path.pairEdges().subList(0, joinIndex)) { + if (!stemEdges.contains(edge.id())) { + ownedEdges.add(edge); + } + } + if (ownedEdges.isEmpty()) { + return TributaryBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, joinIndex); } - HydraulicSegment last = retained.getLast(); - HydrologyPoint end = last.end(); int stemHead = joinStation.y(); - if (last.downstreamHeadY() < stemHead) { - addTributaryDiagnostic(course, HydrologyCandidateRejection.SURFACE_HEAD_RANGE, - stemHead - last.downstreamHeadY(), diagnostics); - return null; + SurfaceCourseResult result = tributaryCourseBuilder.build( + worldSeed, + draft.courseId(), + draft.profileKey(), + List.copyOf(points), + SurfaceTerminal.TRIBUTARY, + stemHead + ); + if (!result.accepted()) { + return TributaryBuild.rejected(result.rejection(), result.rejectionDetail()); } - if (end.x() != joinStation.x() || end.z() != joinStation.z()) { - int drop = last.downstreamHeadY() - stemHead; + if (path.organicSurfaceRequired()) { + // The reach is judged without its approach into the stem: the turn a tributary makes to + // enter its river is junction geometry, not a bend in the river itself. + double approachRadius = 3D * lastWidth(result) + 8D; + int judged = joinIndex; + while (judged > 0 && path.points().get(judged).distanceSquared2D(joinStation) <= approachRadius * approachRadius) { + judged--; + } + int shapeRejection = judged >= 2 ? surfaceShapeRejection(path.points().subList(0, judged + 1), true) : 0; + if (shapeRejection != 0) { + return TributaryBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, shapeRejection); + } + } + ArrayList segments = new ArrayList<>(result.segments()); + // Where the two channels touch, the tributary must sit at the lowest stem water it reaches. + double touch = stemWidths.get(stemIndex) + lastWidth(result) + 1D; + for (int index = 0; index < stemStations.size(); index++) { + HydrologyPoint station = stemStations.get(index); + if (station.distanceSquared2D(joinStation) <= touch * touch) { + stemHead = Math.min(stemHead, station.y()); + } + } + if (segments.getLast().downstreamHeadY() < stemHead) { + int shortfall = stemHead - segments.getLast().downstreamHeadY(); + if (shortfall > TRIBUTARY_BACKWATER_LIMIT) { + return TributaryBuild.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE, shortfall); + } + // The stem's water stands a little above the tributary's floor at the junction: the tail of + // the tributary backs up to the stem's level, a short still reach the bank lip contains. + backwater(segments, stemHead); + } else if (segments.getLast().downstreamHeadY() > stemHead) { + // The tributary hangs above its river: its last stretch is cut down to the stem's water, + // a block per cascade run, so the channel meets the river at its level and not above + // the stem's shore. + levelApproach(segments, stemHead); + } + HydraulicSegment lastSegment = segments.getLast(); + HydrologyPoint end = lastSegment.end(); + if (end.x() != joinStation.x() || end.z() != joinStation.z() || lastSegment.downstreamHeadY() != stemHead) { + // The hop onto the stem is graded like any in-course drop, so the water steps down block + // by block into the river instead of standing above the stem's shore. + int drop = lastSegment.downstreamHeadY() - stemHead; HydrologyFeatureType type = drop == 0 ? HydrologyFeatureType.SURFACE_POOL : drop >= settings.hydraulics().waterfallMinimumDrop() ? HydrologyFeatureType.WATERFALL : drop == 1 ? HydrologyFeatureType.RIFFLE : HydrologyFeatureType.CASCADE; - retained.add(new HydraulicSegment( - HydrologyHash.mix(course.id(), SEGMENT_SALT, 0x4a4f494eL), - course.id(), + boolean graded = addHydraulicSegments( + draft.courseId(), + TRIBUTARY_JUNCTION_PAIR_INDEX, type, - last.downstreamHeadY(), + lastSegment.downstreamHeadY(), stemHead, - last.width(), - last.depth(), - false, - false, - List.of(end, new HydrologyPoint(joinStation.x(), stemHead, joinStation.z())) - )); + lastSegment.width(), + lastSegment.depth(), + List.of(end, new HydrologyPoint(joinStation.x(), stemHead, joinStation.z())), + true, + segments + ); + if (!graded) { + return TributaryBuild.rejected(HydrologyCandidateRejection.SURFACE_DROP_UNSUPPORTED, drop); + } } - return new RiverCourse( - course.id(), - course.type(), - course.sourceNodeId(), - course.outletId(), - course.profileKey(), - course.discharge(), - course.drainageEdges().subList(0, ownedEdgeCount), - retained - ); + List edges = List.copyOf(ownedEdges); + return new TributaryBuild(new RiverCourse( + draft.courseId(), + RiverCourseType.SURFACE, + OptionalLong.of(draft.source().id()), + OptionalLong.of(path.outlet().id()), + draft.profileKey(), + maximumSurfaceDischarge(edges), + edges, + segments + ), null, 0); } private void addTributaryDiagnostic( - RiverCourse course, + long courseId, + HydrologyPoint sourcePoint, HydrologyCandidateRejection rejection, int detail, List diagnostics ) { diagnostics.add(new HydrologyDiagnosticCandidate( - HydrologyHash.mix(course.id(), DIAGNOSTIC_SALT, rejection.ordinal(), 0x5452494255544152L), - HydrologyCandidateKind.SOURCE, + HydrologyHash.mix(courseId, DIAGNOSTIC_SALT, rejection.ordinal(), 0x5452494255544152L), + HydrologyCandidateKind.TRIBUTARY, HydrologyFeatureType.SURFACE_POOL, - course.segments().getFirst().start(), + sourcePoint, rejection, detail )); @@ -1627,32 +1931,28 @@ public final class HydrologyPlanner { List diagnostics ) { ArrayList oceanCandidates = oceanOutletCandidates(grid, surface); + if (!surface && settings.outlets().inlandGrotto().enabled()) { + addOutletLevelDiagnostics(oceanCandidates, diagnostics); + } sortOutletCandidates(key, grid, surface, oceanCandidates); - int maximum = settings.outlets().maximumPerTile(); + // Sea outlets and inland outlets are budgeted separately: a coast never starves the + // sinkholes behind it and a sinkhole never displaces a mouth. + List selectedOcean = limitOutletsByType( + oceanCandidates, + settings.outlets().maximumCoastalPerTile() + ); + addRejectedOutlets(oceanCandidates, selectedOcean, diagnostics); if (!settings.outlets().inlandGrotto().enabled()) { - List selected = limitOutlets( - oceanCandidates, - maximum - ); - addRejectedOutlets(oceanCandidates, selected, diagnostics); - return selected; + return selectedOcean; } boolean[] oceanReachable = outletReachable(grid, oceanCandidates, surface); ArrayList inlandCandidates = inlandOutletCandidates(grid, oceanReachable); sortInlandOutletCandidates(grid, inlandCandidates); - int inlandMaximum = oceanCandidates.isEmpty() - ? maximum - : Math.min(Math.max(1, maximum / 4), maximum - 1); List selectedInland = styledInlandOutlets( grid, - limitOutlets(inlandCandidates, inlandMaximum), + limitOutlets(inlandCandidates, settings.outlets().maximumPerTile()), surface ); - List selectedOcean = limitOutlets( - oceanCandidates, - maximum - selectedInland.size() - ); - addRejectedOutlets(oceanCandidates, selectedOcean, diagnostics); addRejectedOutlets(inlandCandidates, selectedInland, diagnostics); ArrayList selected = new ArrayList<>(selectedOcean.size() + selectedInland.size()); selected.addAll(selectedOcean); @@ -1667,7 +1967,8 @@ public final class HydrologyPlanner { continue; } GridNode ocean = firstOceanNeighbor(grid, land); - if (ocean == null) { + if (ocean == null || !land.terrain().drainsInto(ocean.terrain())) { + // A confined shore only reaches a sea that belongs to its own area. continue; } HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean); @@ -1693,30 +1994,42 @@ public final class HydrologyPlanner { ); oceanCandidates.add(new OutletCandidate(land.index(), ocean.index(), outlet)); } - if (!surface && settings.outlets().inlandGrotto().enabled()) { - oceanCandidates.removeIf((OutletCandidate candidate) -> - candidate.outlet().connectionPoint().y() > settings.underground().maximumFluidY()); - } return oceanCandidates; } private List resolveSurfaceFallbackOutlets(SampledGrid grid) { - ArrayList candidates = oceanOutletCandidates(grid, true); + ArrayList oceanCandidates = oceanOutletCandidates(grid, true); + ArrayList inlandCandidates = new ArrayList<>(); if (settings.outlets().surfaceSinkholesEnabled() && settings.outlets().inlandGrotto().enabled()) { - candidates.addAll(styledInlandOutlets( + inlandCandidates.addAll(styledInlandOutlets( grid, inlandOutletCandidates(grid, new boolean[grid.nodes().size()]), true )); } - if (candidates.isEmpty()) { + if (oceanCandidates.isEmpty() && inlandCandidates.isEmpty()) { return List.of(); } - sortSurfaceFallbackOutletCandidates(grid, candidates); + sortSurfaceFallbackOutletCandidates(grid, oceanCandidates); + sortSurfaceFallbackOutletCandidates(grid, inlandCandidates); + // Trials alternate sea and inland outlets, sea first: a coast with more mouths than trials + // still gives a sinkhole its turn, and a rejected mouth is retried at the next coast. + ArrayList candidates = new ArrayList<>(oceanCandidates.size() + inlandCandidates.size()); + for (int index = 0; index < Math.max(oceanCandidates.size(), inlandCandidates.size()); index++) { + if (index < oceanCandidates.size()) { + candidates.add(oceanCandidates.get(index)); + } + if (index < inlandCandidates.size()) { + candidates.add(inlandCandidates.get(index)); + } + } int maximumTrials = Math.max( OPTIONAL_SOURCE_REJECTIONS_PER_TARGET, - Math.multiplyExact(settings.outlets().maximumPerTile(), 16) + Math.multiplyExact( + Math.max(settings.outlets().maximumPerTile(), settings.outlets().maximumCoastalPerTile()), + 16 + ) ); maximumTrials = Math.min(16, maximumTrials); return limitOutlets(candidates, maximumTrials); @@ -1741,7 +2054,7 @@ public final class HydrologyPlanner { settings.underground().maximumFluidY() ); if (settings.outlets().surfaceSinkholesEnabled() - && node.terrain().naturalHeight() - settings.surface().banks().inset() <= poolY) { + && node.terrain().naturalHeight() - settings.surface().banks().sink() <= poolY) { continue; } long outletId = HydrologyHash.mix( @@ -1778,16 +2091,17 @@ public final class HydrologyPlanner { private void sortSurfaceFallbackOutletCandidates( SampledGrid grid, - List inlandCandidates + List candidates ) { - HashMap capacities = new HashMap<>(inlandCandidates.size()); - for (OutletCandidate candidate : inlandCandidates) { + HashMap capacities = new HashMap<>(candidates.size()); + for (OutletCandidate candidate : candidates) { capacities.put(candidate.landIndex(), surfaceOutletCapacity(grid, candidate.landIndex())); } - inlandCandidates.sort(Comparator + candidates.sort(Comparator .comparing((OutletCandidate candidate) -> !isDrainageBasin(grid, candidate.landIndex())) .thenComparingInt((OutletCandidate candidate) -> capacities.get(candidate.landIndex()) > 0L ? 0 : 1) + .thenComparingInt((OutletCandidate candidate) -> ownedOutletRank(grid, candidate)) .thenComparing(Comparator .comparingLong((OutletCandidate candidate) -> capacities.get(candidate.landIndex())) .reversed()) @@ -1873,7 +2187,7 @@ public final class HydrologyPlanner { if (surface && settings.outlets().surfaceSinkholesEnabled()) { poolY = Math.min( poolY, - landwardTerrain.naturalHeight() - settings.surface().banks().inset() - 1 + landwardTerrain.naturalHeight() - settings.surface().banks().sink() - 1 ); poolY = Math.max( poolY, @@ -1968,8 +2282,12 @@ public final class HydrologyPlanner { boolean surface, List candidates ) { + // An owned coast outranks a neighbour's: a halo mouth only takes a slot when no owned + // coast can serve the tile's sources. + Comparator owned = Comparator.comparingInt( + (OutletCandidate candidate) -> ownedOutletRank(grid, candidate)); if (!surface) { - candidates.sort(outletComparator(key)); + candidates.sort(owned.thenComparing(outletComparator(key))); return; } HashMap capacities = new HashMap<>(candidates.size()); @@ -1980,6 +2298,7 @@ public final class HydrologyPlanner { candidates.sort(Comparator .comparingInt((OutletCandidate candidate) -> capacities.get(candidate.landIndex()) > 0L ? 0 : 1) + .thenComparing(owned) .thenComparingInt(this::surfaceOutletPriority) .thenComparing(Comparator .comparingLong((OutletCandidate candidate) -> capacities.get(candidate.landIndex())) @@ -1987,6 +2306,11 @@ public final class HydrologyPlanner { .thenComparing(stable)); } + private int ownedOutletRank(SampledGrid grid, OutletCandidate candidate) { + GridNode land = grid.node(candidate.landIndex()); + return grid.owns(land.x(), land.z()) ? 0 : 1; + } + private int surfaceOutletPriority(OutletCandidate candidate) { return switch (candidate.outlet().type()) { case MOUTH -> 0; @@ -2048,18 +2372,9 @@ public final class HydrologyPlanner { return List.of(); } ArrayList selected = new ArrayList<>(Math.min(maximum, candidates.size())); - int minimumSpacing = settings.routing().sampleSpacing() * 2; - long minimumSpacingSquared = (long) minimumSpacing * minimumSpacing; + long minimumSpacingSquared = outletSpacingSquared(); for (OutletCandidate candidate : candidates) { - boolean tooClose = false; - for (OutletCandidate existing : selected) { - if (candidate.outlet().landwardPoint().distanceSquared2D(existing.outlet().landwardPoint()) - < minimumSpacingSquared) { - tooClose = true; - break; - } - } - if (!tooClose) { + if (!withinOutletSpacing(candidate, selected, minimumSpacingSquared)) { selected.add(candidate); if (selected.size() == maximum) { break; @@ -2072,6 +2387,62 @@ public final class HydrologyPlanner { return List.copyOf(selected); } + /** + * Takes sea outlets in turns by type (the best mouth, the best coastal grotto, the next mouth, ...) + * from a sorted candidate list until the budget is spent or no type has a candidate left that + * keeps the outlet spacing. A coast with both cliffs and beaches gets both kinds of opening. + */ + private List limitOutletsByType(List candidates, int maximum) { + if (maximum <= 0 || candidates.isEmpty()) { + return List.of(); + } + ArrayList selected = new ArrayList<>(Math.min(maximum, candidates.size())); + long minimumSpacingSquared = outletSpacingSquared(); + boolean[] consumed = new boolean[candidates.size()]; + boolean progressed = true; + while (selected.size() < maximum && progressed) { + progressed = false; + for (HydrologyFeatureType type : COASTAL_OUTLET_TYPES) { + for (int index = 0; index < candidates.size(); index++) { + OutletCandidate candidate = candidates.get(index); + if (consumed[index] || candidate.outlet().type() != type) { + continue; + } + consumed[index] = true; + if (withinOutletSpacing(candidate, selected, minimumSpacingSquared)) { + continue; + } + selected.add(candidate); + progressed = true; + break; + } + if (selected.size() == maximum) { + break; + } + } + } + return List.copyOf(selected); + } + + private long outletSpacingSquared() { + int minimumSpacing = settings.routing().sampleSpacing() * 2; + return (long) minimumSpacing * minimumSpacing; + } + + private boolean withinOutletSpacing( + OutletCandidate candidate, + List selected, + long minimumSpacingSquared + ) { + for (OutletCandidate existing : selected) { + if (candidate.outlet().landwardPoint().distanceSquared2D(existing.outlet().landwardPoint()) + < minimumSpacingSquared) { + return true; + } + } + return false; + } + private void addRejectedOutlets( List candidates, List selected, @@ -2097,6 +2468,39 @@ public final class HydrologyPlanner { } } + /** + * Drops the sea outlets an underground river cannot reach because the sea sits above the + * underground fluid ceiling, reporting each as OUTLET_LEVEL with the shortfall as the detail. + */ + private void addOutletLevelDiagnostics( + List candidates, + List diagnostics + ) { + int maximumFluidY = settings.underground().maximumFluidY(); + ArrayList retained = new ArrayList<>(candidates.size()); + for (OutletCandidate candidate : candidates) { + int seaY = candidate.outlet().connectionPoint().y(); + if (seaY <= maximumFluidY) { + retained.add(candidate); + continue; + } + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix( + candidate.outlet().id(), + DIAGNOSTIC_SALT, + HydrologyCandidateRejection.OUTLET_LEVEL.ordinal() + ), + HydrologyCandidateKind.OUTLET, + candidate.outlet().type(), + candidate.outlet().landwardPoint(), + HydrologyCandidateRejection.OUTLET_LEVEL, + seaY - maximumFluidY + )); + } + candidates.clear(); + candidates.addAll(retained); + } + private RoutingPlan buildRouting( SampledGrid grid, List outlets, @@ -2132,7 +2536,10 @@ public final class HydrologyPlanner { GridNode downstream = grid.node(currentEntry.nodeIndex()); for (GridOffset offset : ROUTING_OFFSETS) { GridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z()); - if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed()) { + if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed() + || !upstream.terrain().drainsInto(downstream.terrain())) { + // Confined ground only drains into its own area, so every course that starts or + // arrives there keeps to that area up to and including its outlet. continue; } if (downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight() > maximumRise) { @@ -2178,7 +2585,7 @@ public final class HydrologyPlanner { * of at least that rise; the lattice may climb no more than the cut the valley solver still accepts. */ private int maximumSurfaceEdgeRise() { - int permitted = settings.surface().maximumIncision() - settings.surface().banks().inset() - settings.surface().minimumDepth(); + int permitted = settings.surface().maximumIncision() - settings.surface().banks().sink() - settings.surface().minimumDepth(); return Math.max(1, permitted); } @@ -2278,7 +2685,9 @@ public final class HydrologyPlanner { continue; } if (!Double.isFinite(routing.potential()[node.index()]) || routing.parent()[node.index()] < 0) { - addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.NO_DRAINAGE_PATH, diagnostics); + addSourceDiagnostic(node, surface, stable, terrain.confinesKey() != null + ? HydrologyCandidateRejection.CONFINED_NO_OUTLET + : HydrologyCandidateRejection.NO_DRAINAGE_PATH, diagnostics); continue; } if (!sourceOutletAllowed(node, routing, surface)) { @@ -2321,7 +2730,12 @@ public final class HydrologyPlanner { ? Math.max(1, sourceSettings.maximumPerTile()) : sourceSettings.maximumPerTile(); target = Math.min(maximum, target); - int coursesPerOutlet = surface ? 1 + settings.routing().tributaries() : Integer.MAX_VALUE; + int tributaries = surface ? settings.routing().tributaries() : settings.underground().tributaries(); + int coursesPerOutlet = 1 + tributaries; + // Tributaries are budgeted on top of the source density: every outlet may draw its extra courses + // beyond the tile's expected count, or no junction would ever fit inside a one-source budget. + int tributaryBudget = Math.multiplyExact(routing.outlets().size(), tributaries); + target = Math.min(Math.addExact(maximum, tributaryBudget), Math.addExact(target, tributaryBudget)); target = effectiveSourceTarget(surface && !enforceGlobalSpacing, target, routing.outlets().size(), coursesPerOutlet); Comparator candidateOrder = Comparator .comparing(SourceCandidate::required) @@ -3033,7 +3447,7 @@ public final class HydrologyPlanner { double maximumOffsetRatio ) { HydrologyTerrainSample terrain = sampleLandBasis(x, z); - if (terrain == null || !terrain.transitAllowed()) { + if (terrain == null || !terrain.transitAllowed() || !withinConfines(terrain, node.terrain().confinesKey())) { return Double.POSITIVE_INFINITY; } double distanceFromDesired = StrictMath.hypot(x - desiredX, z - desiredZ); @@ -3113,7 +3527,8 @@ public final class HydrologyPlanner { nominal, progress, refinement, - elevationTransition ? 0 : effectiveTransverseCandidates + elevationTransition ? 0 : effectiveTransverseCandidates, + upstreamTerrain == null ? null : upstreamTerrain.confinesKey() ); if (candidates.isEmpty()) { return List.of(); @@ -3325,13 +3740,22 @@ public final class HydrologyPlanner { ); } + /** + * {@code confines} is the area the edge's upstream end is confined to, or null: candidates outside it + * are not offered, so a refined route between two confined lattice nodes stays inside their area. + */ + private static boolean withinConfines(HydrologyTerrainSample terrain, String confines) { + return confines == null || confines.equals(terrain.confinesKey()); + } + private List routeCandidates( long upstreamId, long downstreamId, RoutePosition nominal, double progress, int refinement, - int transverseCandidates + int transverseCandidates, + String confines ) { double searchEnvelope = StrictMath.sin(StrictMath.PI * progress); LinkedHashMap candidates = new LinkedHashMap<>(); @@ -3342,7 +3766,7 @@ public final class HydrologyPlanner { int x = (int) StrictMath.round(nominal.x() - nominal.tangent().z() * offset); int z = (int) StrictMath.round(nominal.z() + nominal.tangent().x() * offset); HydrologyTerrainSample terrain = sampleLandBasis(x, z); - if (terrain == null || !terrain.transitAllowed()) { + if (terrain == null || !terrain.transitAllowed() || !withinConfines(terrain, confines)) { continue; } long packed = RiverFootprint.pack(x, z); @@ -3367,7 +3791,8 @@ public final class HydrologyPlanner { HydrologyTerrainSample fallbackTerrain = sampleLandBasis(fallbackX, fallbackZ); if (candidates.isEmpty() && fallbackTerrain != null - && fallbackTerrain.transitAllowed()) { + && fallbackTerrain.transitAllowed() + && withinConfines(fallbackTerrain, confines)) { long packed = RiverFootprint.pack(fallbackX, fallbackZ); double fallbackOffset = (nominal.baseX() - nominal.x()) * -nominal.tangent().z() + (nominal.baseZ() - nominal.z()) * nominal.tangent().x(); @@ -3946,7 +4371,7 @@ public final class HydrologyPlanner { } int head = Math.subtractExact( terrain.naturalHeight(), - settings.surface().banks().inset() + settings.surface().banks().sink() ); penalty += surfaceRouteBankPenalty( point, @@ -3972,7 +4397,7 @@ public final class HydrologyPlanner { if (terrain.slope() < settings.hydraulics().waterfallMinimumDrop()) { int head = Math.subtractExact( terrain.naturalHeight(), - settings.surface().banks().inset() + settings.surface().banks().sink() ); penalty += surfaceRouteBankPenalty( point, @@ -4315,26 +4740,26 @@ public final class HydrologyPlanner { if (mainCourse != null && tributaries >= settings.routing().tributaries()) { break; } - SurfaceCourseBuild candidate = buildSurfaceCourse( - draft.courseId(), - draft.profileKey(), - draft.source(), - draft.path() - ); - if (candidate.course() == null) { - if (mainCourse == null) { + if (mainCourse == null) { + SurfaceCourseBuild candidate = buildSurfaceCourse( + draft.courseId(), + draft.profileKey(), + draft.source(), + draft.path() + ); + if (candidate.course() == null) { mainRejections.put(draft.courseId(), candidate); + continue; } + mainCourse = candidate.course(); + courses.add(mainCourse); continue; } - if (mainCourse == null) { - mainCourse = candidate.course(); - } else { - // A later draft to the same outlet shares the stem's tail; shared-trunk - // normalisation cuts it at the confluence into a tributary. + RiverCourse tributary = buildSurfaceTributary(draft, mainCourse, diagnostics); + if (tributary != null) { + courses.add(tributary); tributaries++; } - courses.add(candidate.course()); } if (mainCourse == null) { for (SurfaceCourseDraft draft : outletDrafts) { @@ -4401,7 +4826,8 @@ public final class HydrologyPlanner { path.edges(), outlet, true, - path.organicSurfaceRequired() + path.organicSurfaceRequired(), + null ); appendOutletSegments( RiverCourseType.SURFACE, @@ -4434,6 +4860,14 @@ public final class HydrologyPlanner { /** 0 when the shape is organic; otherwise 1 too short, 2 too sinuous, 3 grid-locked, 4 sharp turns. */ private int surfaceShapeRejection(List routedCenterline) { + return surfaceShapeRejection(routedCenterline, false); + } + + /** + * As above; a tributary reach keeps the hard turn cap and the overall turn budget but may bend + * once where it leaves its own valley for the stem's, which a main river is not allowed to do. + */ + private int surfaceShapeRejection(List routedCenterline, boolean tributary) { List centerline = withoutDuplicateRoutePoints(routedCenterline); if (centerline.size() < 3) { return 1; @@ -4496,7 +4930,7 @@ public final class HydrologyPlanner { if (turnAngles.isEmpty()) { return 1; } - for (int turnIndex = 0; turnIndex < turnAngles.size(); turnIndex++) { + for (int turnIndex = 0; turnIndex < turnAngles.size() && !tributary; turnIndex++) { double turn = turnAngles.get(turnIndex); double previous = turnIndex == 0 ? 0D : turnAngles.get(turnIndex - 1); double next = turnIndex + 1 == turnAngles.size() ? 0D : turnAngles.get(turnIndex + 1); @@ -4511,7 +4945,7 @@ public final class HydrologyPlanner { double percentile = turnAngles.get( Math.max(0, Math.min(percentileIndex, turnAngles.size() - 1)) ); - if (percentile > SURFACE_MAXIMUM_P95_TURN_DEGREES) { + if (percentile > (tributary ? SURFACE_MAXIMUM_RENDERED_TURN_DEGREES : SURFACE_MAXIMUM_P95_TURN_DEGREES)) { return 4; } return 0; @@ -4587,6 +5021,7 @@ public final class HydrologyPlanner { List courses, List diagnostics ) { + LinkedHashMap> draftsByOutlet = new LinkedHashMap<>(); for (int sourceIndex : sources) { GridNode source = grid.node(sourceIndex); long courseId = HydrologyHash.mix(worldSeed, COURSE_SALT, UNDERGROUND_SOURCE_SALT, source.id()); @@ -4601,47 +5036,154 @@ public final class HydrologyPlanner { ); continue; } - HydrologyPoint sourcePoint = path.points().getFirst(); - HydrologyPoint trunkPoint = path.points().getLast(); - HydrologyTerrainSample trunkTerrain = Objects.requireNonNull( - sampleDetailed(trunkPoint.x(), trunkPoint.z()), - "Hydrology underground trunk left sampled terrain" - ); - String profileKey = chooseProfile(trunkTerrain, path.outlet().id()); - int styledHead = sampleGeometry( - HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, - profileKey, - sourcePoint.x(), - sourcePoint.z(), - 0L, - settings.underground().minimumFluidY(), - settings.underground().maximumFluidY() - ); - int outletHead = outletHead(path.outlet()); - if (outletHead > settings.underground().maximumFluidY()) { - addCompiledSourceDiagnostic( - source, - false, - courseId, - HydrologyCandidateRejection.OUTLET_LEVEL, - diagnostics - ); - continue; - } - int initialHead = Math.max(styledHead, outletHead); - RiverCourse course = buildUndergroundCourse(courseId, profileKey, source, path, initialHead, outletHead); - if (course == null) { - addCompiledSourceDiagnostic( - source, - false, - courseId, - HydrologyCandidateRejection.CAVE_CONTAINMENT, - diagnostics - ); - continue; - } - courses.add(course); + draftsByOutlet.computeIfAbsent(path.outlet().id(), (Long ignored) -> new ArrayList<>()) + .add(new UndergroundCourseDraft(source, courseId, path)); } + for (ArrayList outletDrafts : draftsByOutlet.values()) { + outletDrafts.sort(Comparator + .comparingDouble((UndergroundCourseDraft draft) -> edgeLength( + draft.path().edges(), + draft.path().edges().size() + )) + .reversed() + .thenComparingLong(UndergroundCourseDraft::courseId)); + RiverCourse mainCourse = null; + int tributaries = 0; + for (UndergroundCourseDraft draft : outletDrafts) { + if (mainCourse != null && tributaries >= settings.underground().tributaries()) { + break; + } + if (mainCourse == null) { + RiverCourse course = buildUndergroundCourse(draft, draft.path(), outletHead(draft.path().outlet()), diagnostics); + if (course != null) { + mainCourse = course; + courses.add(course); + } + continue; + } + RiverCourse tributary = buildUndergroundTributary(draft, mainCourse, diagnostics); + if (tributary != null) { + courses.add(tributary); + tributaries++; + } + } + } + } + + private record UndergroundCourseDraft(GridNode source, long courseId, CoursePath path) { + } + + /** + * Builds a later underground draft to the stem's outlet as a tributary: the path is cut at the first + * point the stem's passage already runs through, and the passage is solved with the stem's fluid + * level there as its outlet level, so it joins the stem at or above its water. + */ + private RiverCourse buildUndergroundTributary( + UndergroundCourseDraft draft, + RiverCourse stem, + List diagnostics + ) { + CoursePath path = draft.path(); + HashSet stemEdges = new HashSet<>(); + for (DrainageEdge edge : stem.drainageEdges()) { + stemEdges.add(edge.id()); + } + HashMap stemHeads = new HashMap<>(); + for (HydraulicSegment segment : stem.segments()) { + for (HydrologyPoint point : segment.centerline()) { + stemHeads.putIfAbsent(RiverFootprint.pack(point.x(), point.z()), point.y()); + } + } + int joinIndex = -1; + for (int index = 0; index < path.points().size(); index++) { + HydrologyPoint point = path.points().get(index); + boolean sharedEdge = index > 0 && stemEdges.contains(path.pairEdges().get(index - 1).id()); + if (sharedEdge || stemHeads.containsKey(RiverFootprint.pack(point.x(), point.z()))) { + joinIndex = index; + break; + } + } + if (joinIndex < 1 || !stemHeads.containsKey(RiverFootprint.pack( + path.points().get(joinIndex).x(), path.points().get(joinIndex).z()))) { + addCompiledSourceDiagnostic(draft.source(), false, draft.courseId(), + joinIndex < 0 ? HydrologyCandidateRejection.NO_DRAINAGE_PATH : HydrologyCandidateRejection.COURSE_TOO_SHORT, + diagnostics); + return null; + } + List points = path.points().subList(0, joinIndex + 1); + List pairEdges = path.pairEdges().subList(0, joinIndex); + LinkedHashSet ownedEdges = new LinkedHashSet<>(); + for (DrainageEdge edge : pairEdges) { + if (!stemEdges.contains(edge.id())) { + ownedEdges.add(edge); + } + } + if (ownedEdges.isEmpty() + || centerlineLength(points) < settings.routing().minimumUndergroundCourseLength() / 2D) { + addCompiledSourceDiagnostic(draft.source(), false, draft.courseId(), + HydrologyCandidateRejection.COURSE_TOO_SHORT, joinIndex, diagnostics); + return null; + } + HydrologyPoint junction = points.getLast(); + CoursePath junctionPath = new CoursePath( + List.copyOf(points), + List.copyOf(pairEdges), + List.copyOf(ownedEdges), + path.outlet(), + false, + false, + junction + ); + int junctionHead = stemHeads.get(RiverFootprint.pack(junction.x(), junction.z())); + return buildUndergroundCourse(draft, junctionPath, junctionHead, diagnostics); + } + + private RiverCourse buildUndergroundCourse( + UndergroundCourseDraft draft, + CoursePath path, + int outletHead, + List diagnostics + ) { + GridNode source = draft.source(); + long courseId = draft.courseId(); + HydrologyPoint sourcePoint = path.points().getFirst(); + HydrologyPoint trunkPoint = path.points().getLast(); + HydrologyTerrainSample trunkTerrain = Objects.requireNonNull( + sampleDetailed(trunkPoint.x(), trunkPoint.z()), + "Hydrology underground trunk left sampled terrain" + ); + String profileKey = chooseProfile(trunkTerrain, path.outlet().id()); + int styledHead = sampleGeometry( + HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, + profileKey, + sourcePoint.x(), + sourcePoint.z(), + 0L, + settings.underground().minimumFluidY(), + settings.underground().maximumFluidY() + ); + if (outletHead > settings.underground().maximumFluidY()) { + addCompiledSourceDiagnostic( + source, + false, + courseId, + HydrologyCandidateRejection.OUTLET_LEVEL, + diagnostics + ); + return null; + } + int initialHead = Math.max(styledHead, outletHead); + RiverCourse course = buildUndergroundCourse(courseId, profileKey, source, path, initialHead, outletHead); + if (course == null) { + addCompiledSourceDiagnostic( + source, + false, + courseId, + HydrologyCandidateRejection.CAVE_CONTAINMENT, + diagnostics + ); + } + return course; } private void addCompiledSourceDiagnostic( @@ -5507,7 +6049,8 @@ public final class HydrologyPlanner { List.copyOf(edges), outlet, true, - routing.organicSurfaceRequired() + routing.organicSurfaceRequired(), + null ); } @@ -5713,7 +6256,7 @@ public final class HydrologyPlanner { } int head = Math.subtractExact( terrain.naturalHeight(), - settings.surface().banks().inset() + settings.surface().banks().sink() ); return surfaceRouteBankPenalty( point, @@ -5912,7 +6455,8 @@ public final class HydrologyPlanner { List.copyOf(edges), outlet, true, - false + false, + null ) : null; } @@ -6425,7 +6969,7 @@ public final class HydrologyPlanner { 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; + int rimAllowance = pool.depth() + settings.surface().banks().sink() + 2; for (int station = 0; station < valley.exposedStations(); station++) { if (valley.crossMax()[station] - valley.head()[station] > rimAllowance) { return null; @@ -6476,6 +7020,251 @@ public final class HydrologyPlanner { )); } + /** + * Sea caves: coastal grottos the sea opens into without a river. Every owned land node with a proven + * ocean boundary is a site, ranked by how high the coast stands over the sea; each accepted site is an + * independent course whose chamber is swept inland from the shoreline and opens only through its ocean + * face. Rivers keep their outlets: sea caves stay clear of every mouth and grotto already planned. + */ + private void compileSeaCaves( + SampledGrid grid, + List courses, + List diagnostics + ) { + HydrologyPlannerSettings.SeaCaves seaCaves = settings.seaCaves(); + if (!seaCaves.enabled() || seaCaves.maximumPerTile() == 0) { + return; + } + HydrologyPlannerSettings.Grotto grotto = settings.outlets().coastalGrotto(); + boolean fits = sweptGrottoVolume(grotto, seaCaves.depth()) <= grotto.maximumVolume(); + ArrayList accepted = new ArrayList<>(); + for (SeaCaveSite site : seaCaveSites(grid)) { + if (accepted.size() >= seaCaves.maximumPerTile()) { + addSeaCaveDiagnostic(site, HydrologyCandidateRejection.SOURCE_QUOTA, diagnostics); + continue; + } + if (!fits) { + addSeaCaveDiagnostic(site, HydrologyCandidateRejection.VOLUME_LIMIT, diagnostics); + continue; + } + HydrologyCandidateRejection rejection = seaCaveAdmission(site, accepted, courses); + if (rejection != null) { + addSeaCaveDiagnostic(site, rejection, diagnostics); + continue; + } + courses.add(buildSeaCaveCourse(site)); + accepted.add(site); + } + } + + private ArrayList seaCaveSites(SampledGrid grid) { + int depth = settings.seaCaves().depth(); + ArrayList sites = new ArrayList<>(); + for (GridNode land : grid.nodes()) { + HydrologyTerrainSample terrain = land.terrain(); + if (!grid.owns(land.x(), land.z()) + || terrain.ocean() + || !terrain.transitAllowed() + || !terrain.outletAllowed()) { + continue; + } + GridNode ocean = firstOceanNeighbor(grid, land); + if (ocean == null) { + continue; + } + HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean); + if (boundary == null + || !boundary.landwardTerrain().transitAllowed() + || !boundary.landwardTerrain().outletAllowed()) { + continue; + } + long stableId = HydrologyHash.mix(worldSeed, SEA_CAVE_SALT, land.id(), ocean.id()); + HydrologyPoint inner = seaCaveInnerPoint(land, ocean, boundary.landwardPoint(), stableId, depth); + int seaLevel = settingsSeaLevel(ocean.terrain()); + int coastHeight = seaCaveCoastHeight(boundary, inner, seaLevel); + sites.add(new SeaCaveSite(land, ocean, boundary, inner, coastHeight, stableId)); + } + sites.sort(Comparator.comparingInt(SeaCaveSite::coastHeight).reversed() + .thenComparingLong(SeaCaveSite::stableId)); + return sites; + } + + /** + * How high the coast stands over the sea across the chamber: the lowest land at the shoreline, at the + * back of the chamber and at both flanks of each. Flanks in the sea are ignored (the chamber opens + * there like its face); a back in the sea leaves no coast at all. + */ + private int seaCaveCoastHeight(HydrologyOceanBoundaryRefiner.Result boundary, HydrologyPoint inner, int seaLevel) { + HydrologyTerrainSample innerTerrain = sampleLandBasis(inner.x(), inner.z()); + if (innerTerrain == null) { + return 0; + } + HydrologyPoint landward = boundary.landwardPoint(); + int lowest = Math.min(boundary.landwardTerrain().naturalHeight(), innerTerrain.naturalHeight()); + int radius = settings.outlets().coastalGrotto().horizontalRadius(); + // Flanks lie along the shore, across the crossing from the landward point to the sea. + int spanX = boundary.oceanPoint().x() - landward.x(); + int spanZ = boundary.oceanPoint().z() - landward.z(); + double length = StrictMath.hypot(spanX, spanZ); + int flankX = (int) StrictMath.round(-spanZ / length * radius); + int flankZ = (int) StrictMath.round(spanX / length * radius); + int[][] flanks = { + {landward.x() + flankX, landward.z() + flankZ}, + {landward.x() - flankX, landward.z() - flankZ}, + {inner.x() + flankX, inner.z() + flankZ}, + {inner.x() - flankX, inner.z() - flankZ} + }; + for (int[] flank : flanks) { + HydrologyTerrainSample terrain = sampleLandBasis(flank[0], flank[1]); + if (terrain != null) { + lowest = Math.min(lowest, terrain.naturalHeight()); + } + } + return lowest - seaLevel; + } + + // The chamber runs inland along the coast normal, turned by a stable jitter so caves do not all + // face the same way along a straight shore. + private static HydrologyPoint seaCaveInnerPoint( + GridNode land, + GridNode ocean, + HydrologyPoint landward, + long stableId, + int depth + ) { + double normalX = land.x() - ocean.x(); + double normalZ = land.z() - ocean.z(); + double length = StrictMath.hypot(normalX, normalZ); + double jitter = StrictMath.toRadians( + (HydrologyHash.unit(HydrologyHash.mix(stableId, 7)) * 2D - 1D) * SEA_CAVE_JITTER_DEGREES + ); + double cos = StrictMath.cos(jitter); + double sin = StrictMath.sin(jitter); + double unitX = normalX / length; + double unitZ = normalZ / length; + double inlandX = unitX * cos - unitZ * sin; + double inlandZ = unitX * sin + unitZ * cos; + return new HydrologyPoint( + (int) StrictMath.round(landward.x() + inlandX * depth), + landward.y(), + (int) StrictMath.round(landward.z() + inlandZ * depth) + ); + } + + private HydrologyCandidateRejection seaCaveAdmission( + SeaCaveSite site, + List accepted, + List courses + ) { + HydrologyPlannerSettings.SeaCaves seaCaves = settings.seaCaves(); + if (site.coastHeight() < seaCaves.minimumCoastHeight()) { + return HydrologyCandidateRejection.SURFACE_HEAD_RANGE; + } + HydrologyPoint landward = site.boundary().landwardPoint(); + long spacingSquared = (long) seaCaves.minimumSpacing() * seaCaves.minimumSpacing(); + for (SeaCaveSite other : accepted) { + if (landward.distanceSquared2D(other.boundary().landwardPoint()) < spacingSquared) { + return HydrologyCandidateRejection.SOURCE_SPACING; + } + } + long clearance = 2L * settings.outlets().coastalGrotto().horizontalRadius() + seaCaves.depth(); + long clearanceSquared = clearance * clearance; + for (RiverCourse course : courses) { + if (course.type() == RiverCourseType.SEA_CAVE) { + continue; + } + for (HydraulicSegment segment : course.segments()) { + if (segment.type() != HydrologyFeatureType.MOUTH + && segment.type() != HydrologyFeatureType.COASTAL_GROTTO + && segment.type() != HydrologyFeatureType.INLAND_GROTTO) { + continue; + } + for (HydrologyPoint point : segment.centerline()) { + if (landward.distanceSquared2D(point) < clearanceSquared) { + return HydrologyCandidateRejection.SOURCE_SPACING; + } + } + } + } + return null; + } + + private RiverCourse buildSeaCaveCourse(SeaCaveSite site) { + HydrologyPlannerSettings.Grotto grotto = settings.outlets().coastalGrotto(); + long courseId = HydrologyHash.mix(worldSeed, COURSE_SALT, SEA_CAVE_SALT, site.stableId()); + String profileKey = chooseProfile(site.boundary().landwardTerrain(), courseId); + int seaLevel = settingsSeaLevel(site.ocean().terrain()); + // The last point is the ocean connection: the footprint opens the chamber there and nowhere else. + List centerline = line( + withY(site.inner(), seaLevel), + withY(site.boundary().oceanPoint(), seaLevel), + settings.routing().refinementSpacing() + ); + ArrayList segments = new ArrayList<>(1); + addFlatSegment( + courseId, + HydrologyFeatureType.COASTAL_GROTTO, + seaLevel, + grotto.horizontalRadius() * 2, + grotto.verticalRadius(), + centerline, + segments + ); + return new RiverCourse( + courseId, + RiverCourseType.SEA_CAVE, + OptionalLong.empty(), + OptionalLong.empty(), + profileKey, + 1, + List.of(), + segments + ); + } + + private void addSeaCaveDiagnostic( + SeaCaveSite site, + HydrologyCandidateRejection rejection, + List diagnostics + ) { + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(site.stableId(), DIAGNOSTIC_SALT, rejection.ordinal()), + HydrologyCandidateKind.OUTLET, + HydrologyFeatureType.COASTAL_GROTTO, + site.boundary().landwardPoint(), + rejection, + 0 + )); + } + + /** + * The raster a swept grotto occupies: one ellipsoid plus its central cross-section extruded over the + * sweep. The containment filter measures the real carve; this only refuses chambers that cannot fit. + */ + static long sweptGrottoVolume(HydrologyPlannerSettings.Grotto grotto, int depth) { + int radius = grotto.horizontalRadius(); + long section = 0L; + for (int deltaX = -radius; deltaX <= radius; deltaX++) { + double normalized = Math.min(1D, Math.abs(deltaX) / Math.max(1D, radius)); + double scale = StrictMath.sqrt(Math.max(0D, 1D - normalized * normalized)); + section += (long) StrictMath.ceil(grotto.verticalRadius() * scale) + + (long) StrictMath.floor(grotto.headroom() * scale) + + 1L; + } + return HydrologyPlannerSettings.ellipsoidVolume(radius, grotto.verticalRadius(), grotto.headroom()) + + section * depth; + } + + private record SeaCaveSite( + GridNode land, + GridNode ocean, + HydrologyOceanBoundaryRefiner.Result boundary, + HydrologyPoint inner, + int coastHeight, + long stableId + ) { + } + private void compileDeepFluidCourses( HydrologyTileKey key, List courses, @@ -7644,6 +8433,59 @@ public final class HydrologyPlanner { ); } + private enum DraftPhase { + CONTEXT, + SELECT, + SETTLE, + PUBLISH + } + + /** + * Wall-clock breakdown of one owner draft for the debug log: sampling and routing context, source + * selection, settling, publication passes, and the time spent waiting on lower-rank neighbour drafts. + */ + private static final class DraftProfile { + private final long[] phaseNanos = new long[DraftPhase.values().length]; + private long dependencyNanos; + private int dependencies; + private int admissions; + + private long record(DraftPhase phase, long started) { + long now = System.nanoTime(); + phaseNanos[phase.ordinal()] += now - started; + return now; + } + + private void recordDependencies(int count, long started) { + dependencyNanos += System.nanoTime() - started; + dependencies += count; + admissions++; + } + + private void log(HydrologyTileKey key, int rank, long totalNanos) { + IrisLogging.debug( + "Hydrology owner %d,%d rank=%d drafted in %dms: context=%dms select=%dms settle=%dms publish=%dms deps=%d wait=%dms admissions=%d on %s", + key.tileX(), + key.tileZ(), + rank, + totalNanos / 1_000_000L, + phaseNanos[DraftPhase.CONTEXT.ordinal()] / 1_000_000L, + phaseNanos[DraftPhase.SELECT.ordinal()] / 1_000_000L, + phaseNanos[DraftPhase.SETTLE.ordinal()] / 1_000_000L, + phaseNanos[DraftPhase.PUBLISH.ordinal()] / 1_000_000L, + dependencies, + dependencyNanos / 1_000_000L, + admissions, + Thread.currentThread().getName() + ); + } + } + + private DraftProfile currentDraftProfile() { + DraftProfile profile = draftProfiles.get(); + return profile == null ? new DraftProfile() : profile; + } + private static final class CrossTileResolutionContext { private final Map resolved; private final Set resolving; @@ -7746,13 +8588,18 @@ public final class HydrologyPlanner { } } + /** + * A routed source-to-outlet path, or, when {@code junction} is set, the cut prefix of one that ends + * on the stem it joins: the path then owns only the drainage edges upstream of the junction. + */ private record CoursePath( List points, List pairEdges, List edges, RiverOutlet outlet, boolean reachesOutlet, - boolean organicSurfaceRequired + boolean organicSurfaceRequired, + HydrologyPoint junction ) { } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java index 3c7612cdc..5bb6b6180 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java @@ -11,7 +11,9 @@ public record HydrologyPlannerSettings( Outlets outlets, Geometry geometry, List deepFluids, - List surfacePools + List surfacePools, + double widestShoreBiomeWidth, + SeaCaves seaCaves ) { private static final long PLAN_FORMAT_REVISION = 6L; private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4; @@ -26,7 +28,13 @@ public record HydrologyPlannerSettings( } deepFluids = deepFluids == null ? List.of() : List.copyOf(deepFluids); surfacePools = surfacePools == null ? List.of() : List.copyOf(surfacePools); - int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools); + if (!Double.isFinite(widestShoreBiomeWidth) || widestShoreBiomeWidth < 0D) { + throw new IllegalArgumentException("widestShoreBiomeWidth must be finite and non-negative."); + } + if (seaCaves == null) { + throw new IllegalArgumentException("Sea cave settings are required."); + } + int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves); if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) { throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period."); } @@ -40,7 +48,7 @@ public record HydrologyPlannerSettings( new Routing(2048, 64, 8192, 8192, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0), new Surface(true, surfaceSources, 4, 8, 2, 4, 10, 1.5D, Banks.defaults()), new Hydraulics(8), - new Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true), + new Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true, 1), new Outlets( true, new Grotto(true, 18, 8, 8, 32768), @@ -49,11 +57,14 @@ public record HydrologyPlannerSettings( 12, 64, 8, - 12 + 12, + 2 ), Geometry.defaults(), List.of(), - List.of() + List.of(), + 0D, + SeaCaves.disabled() ); } @@ -66,7 +77,9 @@ public record HydrologyPlannerSettings( hydraulics.hashCode(), underground.hashCode(), outlets.hashCode(), - geometry.hashCode() + geometry.hashCode(), + Double.doubleToLongBits(widestShoreBiomeWidth), + seaCaves.hashCode() ); for (DeepFluid deepFluid : deepFluids) { hash = HydrologyHash.mix(hash, deepFluid.hashCode(), HydrologyHash.text(deepFluid.id())); @@ -78,7 +91,7 @@ public record HydrologyPlannerSettings( } public int publicationRadius() { - return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools); + return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves); } int crossTileColorPeriod() { @@ -92,8 +105,11 @@ public record HydrologyPlannerSettings( Outlets outlets, Geometry geometry, List deepFluids, - List surfacePools + List surfacePools, + double widestShoreBiomeWidth, + SeaCaves seaCaves ) { + double shoreReach = Math.max(surface.shoreWidth(), widestShoreBiomeWidth); int alignedHalo = Math.floorDiv( Math.min( routing.maximumRouteLength(), @@ -106,13 +122,13 @@ public record HydrologyPlannerSettings( if (surface.enabled() && surface.sources().enabled()) { int blendWidth = surface.banks().maximumBlendWidth(); int surfaceRadius = (int) StrictMath.ceil( - surface.maximumWidth() / 2D + surface.shoreWidth() + blendWidth + surface.maximumWidth() * surface.banks().mouthFlareRatio() / 2D + shoreReach + blendWidth ); surfaceRadius = Math.max( surfaceRadius, (int) StrictMath.ceil(geometry.drops().basinWidth( geometry.drops().flowWidth(surface.maximumWidth()) - ) / 2D + surface.shoreWidth() + blendWidth) + ) / 2D + shoreReach + blendWidth) ); surfaceRadius = Math.max(surfaceRadius, outlets.coastalGrotto().horizontalRadius()); surfaceRadius = Math.max(surfaceRadius, outlets.inlandGrotto().horizontalRadius()); @@ -144,9 +160,16 @@ public record HydrologyPlannerSettings( if (!pool.enabled()) { continue; } - radius = Math.max(radius, pool.maximumRadius() + (int) StrictMath.ceil(surface.shoreWidth()) + radius = Math.max(radius, pool.maximumRadius() + (int) StrictMath.ceil(shoreReach) + surface.banks().maximumBlendWidth()); } + if (seaCaves.enabled() && seaCaves.maximumPerTile() > 0) { + // A chamber hangs off a shoreline that may lie a halo away, swept inland by its depth. + radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact( + outlets.coastalGrotto().horizontalRadius(), + Math.addExact(seaCaves.depth(), 1) + ))); + } return radius; } @@ -260,9 +283,12 @@ public record HydrologyPlannerSettings( } } + /** + * {@code sink} is how many blocks the water surface sits below the lowest natural ground beside + * the channel; zero keeps the water flush with the bank and the bank top always meets the water. + */ public record Banks( - int inset, - int freeboard, + int sink, double blendSlope, int minimumBlendWidth, int maximumBlendWidth, @@ -271,13 +297,19 @@ public record HydrologyPlannerSettings( int cascadeRun, int waterfallMinimumDrop, double mouthFlareRatio, + Inlet inlet, double springWidthRatio, int springLength, - boolean exposeCutStrata + boolean exposeCutStrata, + Erosion erosion, + Ponds ponds ) { // Structural invariants only; authoring bounds live in the pack validator. public Banks { - if (inset < 0 || freeboard < 0 + if (erosion == null || ponds == null || inlet == null) { + throw new IllegalArgumentException("Surface erosion, inlet and pond settings are required."); + } + if (sink < 0 || !Double.isFinite(blendSlope) || blendSlope <= 0D || minimumBlendWidth < 1 || maximumBlendWidth < minimumBlendWidth || !Double.isFinite(roughness) || roughness < 0D || roughness > 1D @@ -292,7 +324,98 @@ public record HydrologyPlannerSettings( } public static Banks defaults() { - return new Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true); + return new Banks(0, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, Inlet.defaults(), 2.5D, 24, true, Erosion.defaults(), Ponds.defaults()); + } + + public Banks withInlet(Inlet inlet) { + return new Banks(sink, blendSlope, minimumBlendWidth, maximumBlendWidth, roughness, roughnessWavelength, + cascadeRun, waterfallMinimumDrop, mouthFlareRatio, inlet, springWidthRatio, springLength, + exposeCutStrata, erosion, ponds); + } + } + + /** + * The drowned reach where a surface river meets the sea. Over the last {@code length} blocks before + * the coast the water is held at sea level, the channel widens toward the mouth flare and its bed + * deepens by {@code depth}; the stations above it grade down one block per station into the inlet. + * The inlet and its approach may be cut up to {@code maximumIncision} deep instead of the channel + * cap, so a coastal rise no longer rejects the course; a rise the cap cannot pass ends the inlet + * there, and the inlet never takes more than half the exposed course. A zero length is the plain + * crossing: the head drops to sea level at the coast, the channel neither widens nor deepens. + */ + public record Inlet( + int length, + int depth, + int maximumIncision + ) { + public Inlet { + if (length < 0 || length > 1024 || depth < 0 || depth > 64 || maximumIncision < 0 || maximumIncision > 512) { + throw new IllegalArgumentException("Surface inlet settings are invalid."); + } + } + + public static Inlet none() { + return new Inlet(0, 0, 0); + } + + public static Inlet defaults() { + return new Inlet(64, 3, 32); + } + } + + /** + * How the ground around a channel is eroded into a valley. {@code smoothingRadius} is the run of + * stations the blend width is averaged over, {@code thalwegFraction} the share of the channel + * half-width that stays at full bed depth, {@code blendCurve} the exponent on the blend progress + * (below one hollows the valley sides, above one steepens them near the shore), and + * {@code bedNoise} the share of the channel roughness applied to the bed. Disabled erosion keeps + * only the wet channel, the shore band and the containing lip. + */ + public record Erosion( + boolean enabled, + int smoothingRadius, + double thalwegFraction, + double blendCurve, + double bedNoise + ) { + public Erosion { + if (smoothingRadius < 0 + || !Double.isFinite(thalwegFraction) || thalwegFraction < 0D || thalwegFraction >= 1D + || !Double.isFinite(blendCurve) || blendCurve <= 0D + || !Double.isFinite(bedNoise) || bedNoise < 0D) { + throw new IllegalArgumentException("Surface erosion settings are invalid."); + } + } + + public static Erosion defaults() { + return new Erosion(true, 12, 0.45D, 1D, 0.5D); + } + } + + /** A pond at one end of a surface course: a round bowl holding the course's head at that end. */ + public record Pond(boolean enabled, int minimumRadius, int maximumRadius, int depth) { + public Pond { + if (minimumRadius < 1 || maximumRadius < minimumRadius || depth < 1) { + throw new IllegalArgumentException("Surface pond settings are invalid."); + } + } + } + + /** The ponds at a surface course's source and at an inland terminal. */ + public record Ponds(Pond source, Pond terminal) { + public Ponds { + if (source == null || terminal == null) { + throw new IllegalArgumentException("Surface pond settings are required."); + } + } + + public static Ponds defaults() { + return new Ponds(new Pond(true, 6, 12, 3), new Pond(true, 4, 7, 3)); + } + + /** No pond at either end: what a standing pool or a test channel asks for. */ + public static Ponds none() { + return new Ponds(new Pond(false, 1, 1, 1), new Pond(false, 1, 1, 1)); } } @@ -321,6 +444,33 @@ public record HydrologyPlannerSettings( } } + /** + * Sea caves: coastal grottos that open from the ocean into the coast without a river. A tile keeps at + * most {@code maximumPerTile} of them, the steepest owned coast first, at least {@code minimumSpacing} + * apart, only where the coast stands {@code minimumCoastHeight} above the sea, each chamber swept + * {@code depth} blocks inland from the shoreline. Chamber size and volume come from the coastal grotto. + */ + public record SeaCaves( + boolean enabled, + int maximumPerTile, + int minimumSpacing, + int minimumCoastHeight, + int depth + ) { + public SeaCaves { + if (maximumPerTile < 0 || maximumPerTile > 64 + || minimumSpacing < 16 || minimumSpacing > 8192 + || minimumCoastHeight < 1 || minimumCoastHeight > 128 + || depth < 0 || depth > 128) { + throw new IllegalArgumentException("Sea cave bounds are invalid."); + } + } + + public static SeaCaves disabled() { + return new SeaCaves(false, 0, 16, 1, 0); + } + } + // The routing gates read one hydraulic threshold: the drop that makes a reach a waterfall. public record Hydraulics(int waterfallMinimumDrop) { public Hydraulics { @@ -341,7 +491,8 @@ public record HydrologyPlannerSettings( int maximumDepth, int minimumHeadroom, int maximumHeadroom, - boolean connectToExistingCaves + boolean connectToExistingCaves, + int tributaries ) { public Underground { if (sources == null || minimumFluidY > maximumFluidY) { @@ -350,6 +501,9 @@ public record HydrologyPlannerSettings( requireRange(minimumWidth, maximumWidth, 1, 256, "underground width"); requireRange(minimumDepth, maximumDepth, 1, 64, "underground depth"); requireRange(minimumHeadroom, maximumHeadroom, 1, 128, "underground headroom"); + if (tributaries < 0 || tributaries > 4) { + throw new IllegalArgumentException("underground tributaries must be between 0 and 4."); + } } } @@ -361,7 +515,8 @@ public record HydrologyPlannerSettings( int coastalCliffMinimumHeight, int mouthLevelingDistance, int maximumOceanApron, - int maximumPerTile + int maximumPerTile, + int maximumCoastalPerTile ) { public Outlets { if (coastalGrotto == null || inlandGrotto == null) { @@ -371,7 +526,8 @@ public record HydrologyPlannerSettings( throw new IllegalArgumentException("Surface sinkholes require inland grotto outlets."); } if (coastalCliffMinimumHeight < 0 || maximumOceanApron < 0 || maximumOceanApron > 64 - || mouthLevelingDistance < 0 || maximumPerTile < 1 || maximumPerTile > 256) { + || mouthLevelingDistance < 0 || maximumPerTile < 1 || maximumPerTile > 256 + || maximumCoastalPerTile < 0 || maximumCoastalPerTile > 256) { throw new IllegalArgumentException("Outlet bounds are invalid."); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java index f01cd668b..91a8ce5b7 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java @@ -30,8 +30,16 @@ public record HydrologyTerrainSample( String bankBiomeKey, String floodedCaveBiomeKey, List preferredProfileKeys, - List surfacePoolKeys + List surfacePoolKeys, + double shoreBiomeWidth, + String confinesKey ) { + /** + * {@code shoreBiomeWidth} is the width in blocks of the shore biome band beside a surface river at this + * column; {@code NaN} means the column has no policy value and the geometric shore width applies. + * {@code confinesKey} names the region or biome a river passing this column must stay inside, or is + * null where rivers may flow anywhere. + */ public HydrologyTerrainSample { requireFiniteNonNegative(slope, "slope"); requireFiniteNonNegative(routingCost, "routingCost"); @@ -53,6 +61,20 @@ public record HydrologyTerrainSample( floodedCaveBiomeKey = normalizeKey(floodedCaveBiomeKey, surfaceBiomeKey); preferredProfileKeys = normalizeProfiles(preferredProfileKeys); surfacePoolKeys = surfacePoolKeys == null ? List.of() : List.copyOf(surfacePoolKeys); + if (!Double.isNaN(shoreBiomeWidth) && (!Double.isFinite(shoreBiomeWidth) || shoreBiomeWidth < 0D)) { + throw new IllegalArgumentException("shoreBiomeWidth must be NaN, or finite and non-negative."); + } + confinesKey = confinesKey == null || confinesKey.isBlank() ? null : confinesKey; + } + + /** The shore biome band width at this column, or {@code fallback} when no policy set one. */ + public double shoreBiomeWidth(double fallback) { + return Double.isNaN(shoreBiomeWidth) ? fallback : shoreBiomeWidth; + } + + /** Whether a river at {@code upstream} may drain into this column without leaving its confines. */ + public boolean drainsInto(HydrologyTerrainSample downstream) { + return confinesKey == null || confinesKey.equals(downstream.confinesKey()); } public static HydrologyTerrainSample openLand(int naturalHeight, double slope, String parentBiomeKey) { @@ -84,7 +106,9 @@ public record HydrologyTerrainSample( parentBiomeKey, parentBiomeKey, List.of("default"), - List.of() + List.of(), + Double.NaN, + null ); } @@ -117,7 +141,9 @@ public record HydrologyTerrainSample( parentBiomeKey, parentBiomeKey, List.of("default"), - List.of() + List.of(), + Double.NaN, + null ); } @@ -150,7 +176,9 @@ public record HydrologyTerrainSample( bankBiomeKey, floodedCaveBiomeKey, preferredProfileKeys, - replacementPoolKeys + replacementPoolKeys, + shoreBiomeWidth, + confinesKey ); } @@ -183,7 +211,9 @@ public record HydrologyTerrainSample( bankBiomeKey, floodedCaveBiomeKey, preferredProfileKeys, - surfacePoolKeys + surfacePoolKeys, + shoreBiomeWidth, + confinesKey ); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java index e9ac73e8b..2db9e4868 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java @@ -1,8 +1,6 @@ package art.arcane.iris.engine.hydrology; import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.volmlib.util.cache.CacheKey; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; @@ -12,11 +10,11 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; +import java.util.concurrent.ForkJoinWorkerThread; import java.util.concurrent.atomic.AtomicLong; public final class HydrologyTileCache implements AutoCloseable { @@ -32,7 +30,7 @@ public final class HydrologyTileCache implements AutoCloseable { private final AtomicLong cacheEpoch; private final ThreadLocal localChunkColumns; private final Executor prefetchExecutor; - private final Set prefetching; + private final ConcurrentHashMap> planning; public HydrologyTileCache(HydrologyPlanner planner) { this(planner, DEFAULT_MAXIMUM_ENTRIES); @@ -49,7 +47,7 @@ public final class HydrologyTileCache implements AutoCloseable { public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries, Executor prefetchExecutor) { this.planner = Objects.requireNonNull(planner, "planner"); this.prefetchExecutor = prefetchExecutor; - this.prefetching = ConcurrentHashMap.newKeySet(); + this.planning = new ConcurrentHashMap<>(); if (maximumEntries < 1) { throw new IllegalArgumentException("maximumEntries must be positive."); } @@ -64,8 +62,34 @@ public final class HydrologyTileCache implements AutoCloseable { this.localChunkColumns = new ThreadLocal<>(); } + /** + * The tile, planned on the prefetch executor when there is one so that a caller that gets + * interrupted or cancelled (a map render, a command) never aborts a plan every other caller is + * waiting for; without an executor the tile is planned on the caller. + */ public HydrologyTile get(HydrologyTileKey key) { Objects.requireNonNull(key, "key"); + if (prefetchExecutor == null || plansInline()) { + return planInline(key); + } + return awaitTile(planAsync(key)); + } + + /** + * A pool worker plans on its own thread. Parking the workers of one pool on tasks of another is how + * two saturated pools deadlock each other (generation workers waiting for planning that waits for + * generation workers), and a ForkJoin worker keeps the planner's nested fan-out alive by helping + * with its own forks instead of waiting for them. + */ + private static boolean plansInline() { + return Thread.currentThread() instanceof ForkJoinWorkerThread; + } + + private HydrologyTile planInline(HydrologyTileKey key) { + if (tiles.getIfPresent(key) == null && planning.containsKey(key)) { + IrisLogging.debug("Hydrology tile %d,%d awaited by %s while the planning pool plans it", + key.tileX(), key.tileZ(), Thread.currentThread().getName()); + } return tiles.get(key, this::planOrEmpty); } @@ -73,12 +97,16 @@ public final class HydrologyTileCache implements AutoCloseable { * A tile whose planning throws is published without rivers instead of failing the chunks that * asked for it: one bad column must not take the chunk system down. The failure is logged in * full so the cause can be traced, and the empty tile is cached like any other so the session - * stays consistent. + * stays consistent. A plan that was interrupted is not a bad tile: it is rethrown without being + * cached so the next request plans it again. */ private HydrologyTile planOrEmpty(HydrologyTileKey key) { try { return planner.plan(key); } catch (RuntimeException failure) { + if (interrupted(failure)) { + throw failure; + } IrisLogging.error("Hydrology tile " + key.tileX() + "," + key.tileZ() + " failed to plan and generates without rivers: " + failure.getMessage()); IrisLogging.reportError(failure); @@ -86,6 +114,21 @@ public final class HydrologyTileCache implements AutoCloseable { } } + private static boolean interrupted(Throwable failure) { + if (Thread.currentThread().isInterrupted()) { + return true; + } + for (Throwable cause = failure; cause != null; cause = cause.getCause()) { + if (cause instanceof InterruptedException || cause instanceof java.io.InterruptedIOException) { + return true; + } + if (cause.getCause() == cause) { + break; + } + } + return false; + } + public Optional columnAt(int blockX, int blockZ) { return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ); } @@ -94,6 +137,31 @@ public final class HydrologyTileCache implements AutoCloseable { chunkColumns(blockX, blockZ); } + /** + * Whether every tile the column's chunk composes from is already planned. A caller that must not + * wait (the server thread answering a height or biome query) uses this before sampling: when the + * answer is false the missing tiles are handed to the prefetch executor so a later query finds + * them, and nothing is planned on the caller. + */ + public boolean isPlanned(int blockX, int blockZ) { + int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); + int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); + if (composedChunks.getIfPresent(CacheKey.mix(RiverFootprint.pack(chunkX, chunkZ))) != null) { + return true; + } + boolean planned = true; + for (HydrologyTileKey key : relevantKeys(chunkX, chunkZ)) { + if (tiles.getIfPresent(key) != null) { + continue; + } + planned = false; + if (prefetchExecutor != null) { + prefetch(key); + } + } + return planned; + } + public HydrologyRenderSample renderAt(int blockX, int blockZ) { return columnAt(blockX, blockZ) .map(HydrologyColumnSample::renderSample) @@ -146,7 +214,8 @@ public final class HydrologyTileCache implements AutoCloseable { return columns; } - private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) { + /** The tiles a chunk's columns compose from: every tile within the publication radius of the chunk. */ + private ArrayList relevantKeys(int chunkX, int chunkZ) { int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE); int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); int tileSize = planner.settings().routing().tileSize(); @@ -163,7 +232,18 @@ public final class HydrologyTileCache implements AutoCloseable { relevantKeys.add(new HydrologyTileKey(tileX, tileZ)); } } - List relevantTiles = loadTiles(relevantKeys); + return relevantKeys; + } + + private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) { + int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE); + int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); + ArrayList relevantKeys = relevantKeys(chunkX, chunkZ); + int minimumTileX = relevantKeys.getFirst().tileX(); + int maximumTileX = relevantKeys.getLast().tileX(); + int minimumTileZ = relevantKeys.getFirst().tileZ(); + int maximumTileZ = relevantKeys.getLast().tileZ(); + List relevantTiles = tiles(relevantKeys); prefetchNeighbours(minimumTileX, maximumTileX, minimumTileZ, maximumTileZ); HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT]; for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { @@ -234,60 +314,69 @@ public final class HydrologyTileCache implements AutoCloseable { } private void prefetch(HydrologyTileKey key) { - if (tiles.getIfPresent(key) != null || !prefetching.add(key)) { - return; + planAsync(key); + } + + /** + * Plans every tile that is not cached yet on the prefetch executor and returns all of them in key + * order. At most half the cache bound is in flight at once so a wide request cannot evict tiles + * before the caller has read them; a tile another caller is already planning is joined rather than + * planned twice. Without a prefetch executor the tiles are planned inline on the caller. + */ + public List tiles(List keys) { + Objects.requireNonNull(keys, "keys"); + HydrologyTile[] loaded = new HydrologyTile[keys.size()]; + if (prefetchExecutor == null || plansInline()) { + for (int index = 0; index < loaded.length; index++) { + loaded[index] = planInline(keys.get(index)); + } + return List.of(loaded); + } + int batch = Math.max(1, maximumEntries / 2); + ArrayList> futures = new ArrayList<>(Math.min(batch, loaded.length)); + for (int start = 0; start < loaded.length; start += batch) { + int end = Math.min(loaded.length, start + batch); + futures.clear(); + for (int index = start; index < end; index++) { + futures.add(planAsync(keys.get(index))); + } + for (int index = start; index < end; index++) { + loaded[index] = awaitTile(futures.get(index - start)); + } + } + return List.of(loaded); + } + + /** + * The tile, planned on the prefetch executor unless it is cached or already being planned, in which + * case the caller shares that plan instead of occupying a second executor thread with it. + */ + private CompletableFuture planAsync(HydrologyTileKey key) { + HydrologyTile present = tiles.getIfPresent(key); + if (present != null) { + return CompletableFuture.completedFuture(present); + } + CompletableFuture future = new CompletableFuture<>(); + CompletableFuture existing = planning.putIfAbsent(key, future); + if (existing != null) { + return existing; } try { prefetchExecutor.execute(() -> { try { - get(key); - } catch (RuntimeException failure) { + future.complete(planInline(key)); + } catch (Throwable failure) { + future.completeExceptionally(failure); IrisLogging.reportError(failure); } finally { - prefetching.remove(key); + planning.remove(key, future); } }); } catch (RuntimeException rejected) { - prefetching.remove(key); + planning.remove(key, future); + future.completeExceptionally(rejected); } - } - - private List loadTiles(List keys) { - if (keys.size() < 2 - || !IrisPlatforms.isBound()) { - ArrayList loaded = new ArrayList<>(keys.size()); - for (HydrologyTileKey key : keys) { - loaded.add(get(key)); - } - return List.copyOf(loaded); - } - // Tiles already planned are answered in place; only a tile that still needs planning is - // worth a round trip through the pool, and most chunks find every tile they need cached. - HydrologyTile[] loaded = new HydrologyTile[keys.size()]; - ArrayList> futures = null; - for (int index = 0; index < loaded.length; index++) { - HydrologyTileKey key = keys.get(index); - HydrologyTile present = tiles.getIfPresent(key); - if (present != null) { - loaded[index] = present; - continue; - } - if (futures == null) { - futures = new ArrayList<>(loaded.length); - } - int slot = index; - CompletableFuture future = CompletableFuture.supplyAsync(() -> get(key), MultiBurst.hydrology); - futures.add(future.thenApply(tile -> { - loaded[slot] = tile; - return tile; - })); - } - if (futures != null) { - for (CompletableFuture future : futures) { - awaitTile(future); - } - } - return List.of(loaded); + return future; } private static HydrologyTile awaitTile(CompletableFuture future) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java index 2c3191273..da0caf1e0 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java @@ -38,9 +38,11 @@ public record RiverCourse( } } validateSinkholeContinuation(type, segments); - boolean independent = type == RiverCourseType.DEEP_FLUID || type == RiverCourseType.SURFACE_POOL; + boolean independent = type == RiverCourseType.DEEP_FLUID + || type == RiverCourseType.SURFACE_POOL + || type == RiverCourseType.SEA_CAVE; if (independent && (sourceNodeId.isPresent() || outletId.isPresent())) { - throw new IllegalArgumentException("Independent deep-fluid and pool courses cannot reference drainage nodes or river outlets."); + throw new IllegalArgumentException("Independent deep-fluid, pool and sea-cave courses cannot reference drainage nodes or river outlets."); } if (!independent && (sourceNodeId.isEmpty() || outletId.isEmpty())) { throw new IllegalArgumentException("Surface and underground courses require a source and outlet."); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java index 269726e7b..b8bcc29f1 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java @@ -4,5 +4,7 @@ public enum RiverCourseType { SURFACE, UNDERGROUND, DEEP_FLUID, - SURFACE_POOL + SURFACE_POOL, + // A coastal grotto the sea opens into on its own: no drainage source, no river outlet. + SEA_CAVE } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java index 647b7b51f..ebcfd7708 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java @@ -21,11 +21,17 @@ public record EffectiveRiverPolicy( double depthMultiplier, double incisionMultiplier, double routingMultiplier, - double bankMultiplier + double bankMultiplier, + Double shoreBiomeWidth, + RiverConfinement confinement ) { public EffectiveRiverPolicy { placement = Objects.requireNonNull(placement); routing = Objects.requireNonNull(routing); + confinement = Objects.requireNonNull(confinement); + if (shoreBiomeWidth != null && (!Double.isFinite(shoreBiomeWidth) || shoreBiomeWidth < 0D)) { + throw new IllegalArgumentException("shoreBiomeWidth must be finite and non-negative."); + } profiles = List.copyOf(profiles); surfaceBiomes = List.copyOf(surfaceBiomes); mouthBiomes = List.copyOf(mouthBiomes); diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverConfinement.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverConfinement.java new file mode 100644 index 000000000..302d6c690 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverConfinement.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.hydrology.policy; + +/** Which area a confined river policy binds courses to: the region or the biome that set it, or none. */ +public enum RiverConfinement { + NONE, + REGION, + BIOME +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java index c521f949e..45b5bd89c 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java @@ -27,9 +27,9 @@ public final class RiverPolicyResolver { IrisRiverPolicy biomePolicy ) { State state = new State(); - state.apply(dimensionPolicy); - state.apply(regionPolicy); - state.apply(biomePolicy); + state.apply(dimensionPolicy, RiverConfinement.REGION); + state.apply(regionPolicy, RiverConfinement.REGION); + state.apply(biomePolicy, RiverConfinement.BIOME); return state.build(); } @@ -49,11 +49,20 @@ public final class RiverPolicyResolver { private double incisionMultiplier = 1D; private double routingMultiplier = 1D; private double bankMultiplier = 1D; + private Double shoreBiomeWidth = null; + private RiverConfinement confinement = RiverConfinement.NONE; - private void apply(IrisRiverPolicy policy) { + /** {@code scope} is the confinement a {@code confined: true} at this level binds courses to. */ + private void apply(IrisRiverPolicy policy, RiverConfinement scope) { if (policy == null) { return; } + if (policy.getShoreBiomeWidth() != null) { + shoreBiomeWidth = policy.getShoreBiomeWidth(); + } + if (policy.getConfined() != null) { + confinement = policy.getConfined() ? scope : RiverConfinement.NONE; + } if (policy.getPlacement() != null) { placement = policy.getPlacement(); } @@ -117,7 +126,9 @@ public final class RiverPolicyResolver { depthMultiplier, incisionMultiplier, routingMultiplier, - bankMultiplier + bankMultiplier, + shoreBiomeWidth, + confinement ); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java index 4b51aa725..3b5400bee 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -4,6 +4,7 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.hydrology.HydrologyNaturalTerrainSampler; import art.arcane.iris.engine.hydrology.HydrologyRoutingTerrainSampler; import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyForkJoin; import art.arcane.iris.util.common.parallel.MultiBurst; import it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; @@ -11,8 +12,7 @@ import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; +import java.util.concurrent.Callable; import java.util.concurrent.Executor; final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrainSampler, AutoCloseable { @@ -214,27 +214,19 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return; } int rowsPerWorker = Math.ceilDiv(planeWidth, workerCount); - ArrayList> futures = new ArrayList<>(workerCount); + ArrayList> rows = new ArrayList<>(workerCount); for (int workerIndex = 0; workerIndex < workerCount; workerIndex++) { int minimumRow = workerIndex * rowsPerWorker; if (minimumRow >= planeWidth) { break; } int maximumRow = Math.min(planeWidth, minimumRow + rowsPerWorker); - futures.add(CompletableFuture.runAsync( - () -> fillBasisRows( - plane, - planeWidth, - minimumX, - minimumZ, - spacing, - minimumRow, - maximumRow - ), - samplingOptions.executor() - )); + rows.add(() -> { + fillBasisRows(plane, planeWidth, minimumX, minimumZ, spacing, minimumRow, maximumRow); + return null; + }); } - await(futures); + HydrologyForkJoin.invokeAll(rows, samplingOptions.executor()); } private void fillBasisRows( @@ -264,21 +256,6 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai return Math.min(planeWidth, samplingOptions.maximumWorkers()); } - private static void await(List> futures) { - CompletableFuture[] completions = futures.toArray(new CompletableFuture[futures.size()]); - try { - CompletableFuture.allOf(completions).join(); - } catch (CompletionException failure) { - Throwable cause = failure.getCause(); - if (cause instanceof RuntimeException runtimeException) { - throw runtimeException; - } - if (cause instanceof Error error) { - throw error; - } - throw new IllegalStateException("Hydrology routing terrain sampling failed.", cause); - } - } private void evictOldest(Long2ObjectLinkedOpenHashMap cache) { if (cache.size() <= maximumEntries) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java index b59a937f4..a3f70ff89 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -18,37 +18,43 @@ import art.arcane.iris.engine.hydrology.HydrologyTileKey; import art.arcane.iris.engine.hydrology.cave.CavePosition; import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.engine.hydrology.policy.EffectiveRiverPolicy; +import art.arcane.iris.engine.hydrology.policy.RiverConfinement; 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; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverGrottoConfig; import art.arcane.iris.engine.object.IrisRiverChannelShapeConfig; import art.arcane.iris.engine.object.IrisRiverDropShapeConfig; import art.arcane.iris.engine.object.IrisRiverGeometryConfig; +import art.arcane.iris.engine.object.IrisRiverGrottoConfig; import art.arcane.iris.engine.object.IrisRiverHydrology; import art.arcane.iris.engine.object.IrisRiverInlandOutlet; import art.arcane.iris.engine.object.IrisRiverMeanderConfig; +import art.arcane.iris.engine.object.IrisRiverPolicy; import art.arcane.iris.engine.object.IrisRiverProfile; import art.arcane.iris.engine.object.IrisRiverRoutingConfig; import art.arcane.iris.engine.object.IrisRiverRoutingMode; +import art.arcane.iris.engine.object.IrisSeaCaveConfig; import art.arcane.iris.engine.object.IrisStyledRange; +import art.arcane.iris.engine.object.IrisSurfacePoolConfig; import art.arcane.iris.engine.object.IrisSurfaceRiverBankConfig; import art.arcane.iris.engine.object.IrisSurfaceRiverChannelConfig; -import art.arcane.iris.engine.object.IrisSurfaceRiverFlowConfig; import art.arcane.iris.engine.object.IrisSurfaceRiverConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverFlowConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverPondConfig; import art.arcane.iris.engine.object.IrisSurfaceRiverSourceConfig; import art.arcane.iris.engine.object.IrisUndergroundRiverConfig; import art.arcane.iris.engine.object.IrisUndergroundRiverSourceConfig; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.util.common.data.DataProvider; +import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.util.common.parallel.MultiBurst; import java.util.ArrayList; import java.util.Collections; @@ -60,6 +66,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.IntConsumer; public final class IrisHydrologyRuntime implements AutoCloseable { @@ -84,6 +91,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { private final Set profileKeys; private final String defaultProfileKey; private final Object terrainSampleLock; + private final Set unplannedQueries = ConcurrentHashMap.newKeySet(); private final LinkedHashMap terrainSamples; public IrisHydrologyRuntime(IrisHydrologyRuntimeContext context) { @@ -94,7 +102,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { this.defaultProfileKey = profileKeys.isEmpty() ? "default" : profileKeys.iterator().next(); this.terrainSampleLock = new Object(); this.terrainSamples = new LinkedHashMap<>(MAXIMUM_TERRAIN_SAMPLES, 0.75F, true); - this.settings = createSettings(context.dimension(), hydrology); + this.settings = createSettings(context.dimension(), hydrology, context::data); HydrologyGeometrySampler geometrySampler = geometrySampler(context, hydrology); IrisHydrologyRoutingTerrainSampler.Sources terrainSources = new IrisHydrologyRoutingTerrainSampler.Sources( this::createTerrainBasis, @@ -115,6 +123,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable { context.caveViewFactory() ); this.cache = new HydrologyTileCache(planner, MAXIMUM_CACHE_TILES, IrisPlatforms.isBound() ? MultiBurst.hydrology : null); + IrisLogging.debug("Hydrology runtime: tileSize=%d publicationRadius=%d planningThreads=%d", + settings.routing().tileSize(), settings.publicationRadius(), MultiBurst.hydrology.parallelism()); } public HydrologyPlannerSettings settings() { @@ -125,6 +135,25 @@ public final class IrisHydrologyRuntime implements AutoCloseable { return cache.get(key); } + /** + * Whether the column can be sampled without planning: true when its tiles are cached. Otherwise the + * planning pool is asked for them and the caller should answer from natural terrain for now. + */ + public boolean isPlanned(double x, double z) { + int blockX = (int) StrictMath.floor(x); + int blockZ = (int) StrictMath.floor(z); + boolean planned = cache.isPlanned(blockX, blockZ); + if (!planned) { + int tileSize = settings.routing().tileSize(); + HydrologyTileKey key = new HydrologyTileKey(Math.floorDiv(blockX, tileSize), Math.floorDiv(blockZ, tileSize)); + if (unplannedQueries.add(key)) { + IrisLogging.debug("Hydrology tile %d,%d queried before it was planned; natural terrain answers until the plan lands", + key.tileX(), key.tileZ()); + } + } + return planned; + } + public Optional sample(double x, double z) { int blockX = (int) StrictMath.floor(x); int blockZ = (int) StrictMath.floor(z); @@ -300,8 +329,9 @@ public final class IrisHydrologyRuntime implements AutoCloseable { } /** - * Walks tiles outward from the origin, planning each on demand, and stops as soon as no unvisited - * ring can hold a nearer feature. {@code progress} receives the running tile count after every ring. + * Walks tiles outward from the origin, planning each ring's tiles together on the hydrology pool, and + * stops as soon as no unvisited ring can hold a nearer feature. {@code progress} receives the running + * tile count after every ring. */ public Optional nearestFeature( Set types, @@ -334,8 +364,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { if (nearest != null && lowerBound * lowerBound > nearestDistanceSquared) { break; } - for (HydrologyTileKey key : HydrologyFeatureSearch.ring(originTileX, originTileZ, ring)) { - HydrologyTile tile = cache.get(key); + for (HydrologyTile tile : cache.tiles(HydrologyFeatureSearch.ring(originTileX, originTileZ, ring))) { visited++; HydrologyFeatureRef feature = tile.nearestFeature( types, profileKey, x, z, maximumDistance).orElse(null); @@ -455,6 +484,12 @@ public final class IrisHydrologyRuntime implements AutoCloseable { ? sampledNaturalHeight : rawNaturalHeight; if (!Double.isFinite(resolvedNaturalHeight)) { + // A non-finite height is a transient fault in a shared sampling cache, not a fact about + // the terrain: sample the column once more before giving the tile up. + double resampledNaturalHeight = context.naturalHeightProvider().sample(x, z); + if (Double.isFinite(resampledNaturalHeight)) { + return createTerrainBasis(x, z, resampledNaturalHeight); + } throw new IllegalStateException(nonFiniteHeightMessage( x, z, @@ -527,11 +562,22 @@ public final class IrisHydrologyRuntime implements AutoCloseable { selectKey(policy.bankBiomes(), parentBiomeKey, biomePatchNoise, 4), selectKey(policy.floodedCaveBiomes(), parentBiomeKey, biomePatchNoise, 5), profiles, - policy.surfacePools() + policy.surfacePools(), + policy.shoreBiomeWidth() == null ? Double.NaN : policy.shoreBiomeWidth(), + confinesKey(policy.confinement(), region, biome) ); return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(resolvedNaturalHeight, terrain); } + /** The area a river at this column must stay inside, named so that neighbouring areas never match. */ + static String confinesKey(RiverConfinement confinement, IrisRegion region, IrisBiome biome) { + return switch (confinement) { + case NONE -> null; + case REGION -> region == null ? null : "region:" + region.getLoadKey(); + case BIOME -> biome == null ? null : "biome:" + biome.getLoadKey(); + }; + } + private List validProfiles(List requested) { if (requested == null || requested.isEmpty()) { return List.of(defaultProfileKey); @@ -685,7 +731,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable { static HydrologyPlannerSettings createSettings( IrisDimension dimension, - IrisHydrology hydrology + IrisHydrology hydrology, + DataProvider data ) { IrisRiverHydrology rivers = hydrology.getRivers(); IrisRiverRoutingConfig routing = rivers.getRouting(); @@ -733,8 +780,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable { channel.getMaximumIncision(), banks.getShoreWidth(), new HydrologyPlannerSettings.Banks( - channel.getInset(), - banks.getFreeboard(), + channel.getSink(), banks.getBlendSlope(), banks.getMinimumBlendWidth(), banks.getMaximumBlendWidth(), @@ -743,9 +789,25 @@ public final class IrisHydrologyRuntime implements AutoCloseable { flow.getCascadeRun(), flow.getWaterfallMinimumDrop(), surface.getMouths().getFlareRatio(), + new HydrologyPlannerSettings.Inlet( + surface.getMouths().getInletLength(), + surface.getMouths().getInletDepth(), + surface.getMouths().getMaximumIncision() + ), channel.getSpringWidthRatio(), channel.getSpringLength(), - banks.isExposeCutStrata() + banks.isExposeCutStrata(), + new HydrologyPlannerSettings.Erosion( + surface.getErosion().isEnabled(), + surface.getErosion().getSmoothingRadius(), + surface.getErosion().getThalwegFraction(), + surface.getErosion().getBlendCurve(), + surface.getErosion().getBedNoise() + ), + new HydrologyPlannerSettings.Ponds( + pond(surface.getPonds().getSource()), + pond(surface.getPonds().getTerminal()) + ) ) ); HydrologyPlannerSettings.Hydraulics plannerHydraulics = @@ -769,7 +831,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable { maximumInt(underground.getDepth()), minimumInt(underground.getHeadroom()), maximumInt(underground.getHeadroom()), - underground.isConnectToExistingCaves() + underground.isConnectToExistingCaves(), + underground.getTributaries() ); IrisRiverGrottoConfig grottos = rivers.getGrottos(); IrisCoastalRiverGrottoConfig coastal = grottos.getCoastal(); @@ -786,7 +849,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable { Math.max(4, coastal.getVerticalRadius()), underground.getMouthLevelingDistance(), surface.getMouths().getMaximumOceanApron(), - routing.getMaximumOutletsPerTile() + routing.getMaximumOutletsPerTile(), + routing.getMaximumCoastalOutletsPerTile() ); HydrologyPlannerSettings.Geometry plannerGeometry = geometry( rivers.getGeometry(), @@ -806,10 +870,32 @@ public final class IrisHydrologyRuntime implements AutoCloseable { plannerOutlets, plannerGeometry, deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()), - surfacePools(hydrology.getSurfacePools()) + surfacePools(hydrology.getSurfacePools()), + widestShoreBiomeWidth(dimension, data, banks.getShoreWidth()), + seaCaves(coastal) ); } + /** + * The widest shore biome band any policy of the dimension asks for, never below the geometric shore + * width, so the publication envelope reaches every column a river can give shore content to. + */ + static double widestShoreBiomeWidth(IrisDimension dimension, DataProvider data, double shoreWidth) { + double widest = Math.max(0D, shoreWidth); + widest = Math.max(widest, policyShoreBiomeWidth(dimension.getRiverPolicy())); + for (IrisRegion region : dimension.getAllRegions(data)) { + widest = Math.max(widest, policyShoreBiomeWidth(region.getRiverPolicy())); + } + for (IrisBiome biome : dimension.getAllBiomes(data)) { + widest = Math.max(widest, policyShoreBiomeWidth(biome.getRiverPolicy())); + } + return widest; + } + + private static double policyShoreBiomeWidth(IrisRiverPolicy policy) { + return policy == null || policy.getShoreBiomeWidth() == null ? 0D : policy.getShoreBiomeWidth(); + } + private static List surfacePools(List configurations) { ArrayList pools = new ArrayList<>(); for (IrisSurfacePoolConfig configuration : configurations) { @@ -884,6 +970,18 @@ public final class IrisHydrologyRuntime implements AutoCloseable { ); } + // A sea cave is a coastal grotto without a river, so it needs the coastal grotto itself enabled. + private static HydrologyPlannerSettings.SeaCaves seaCaves(IrisCoastalRiverGrottoConfig coastal) { + IrisSeaCaveConfig seaCaves = coastal.getSeaCaves(); + return new HydrologyPlannerSettings.SeaCaves( + coastal.isEnabled() && seaCaves.isEnabled(), + seaCaves.getMaximumPerTile(), + seaCaves.getMinimumSpacing(), + seaCaves.getMinimumCoastHeight(), + seaCaves.getDepth() + ); + } + private static List deepFluids( List configurations, int minimumWorldY, @@ -966,6 +1064,15 @@ public final class IrisHydrologyRuntime implements AutoCloseable { return (int) Math.min(1_048_576L, Math.max(64L, volume)); } + private static HydrologyPlannerSettings.Pond pond(IrisSurfaceRiverPondConfig config) { + return new HydrologyPlannerSettings.Pond( + config.isEnabled(), + config.getMinimumRadius(), + config.getMaximumRadius(), + config.getDepth() + ); + } + private static int minimumInt(IrisStyledRange range) { return (int) StrictMath.floor(Math.min(range.getMin(), range.getMax())); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java index 31dbf3b26..48ce11a87 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java @@ -9,7 +9,6 @@ import java.util.Objects; public final class ChannelProfileBuilder { private static final int SMOOTHING_RADIUS = 16; - private static final int MOUTH_FLARE = 32; private final HydrologyPlannerSettings.Surface surface; private final HydrologyTerrainSampler sampler; @@ -34,6 +33,7 @@ public final class ChannelProfileBuilder { double[] width = new double[count]; double[] depth = new double[count]; double[] bank = new double[count]; + int coast = count; for (int station = 0; station < count; station++) { int stationX = centerline.x()[station]; int stationZ = centerline.z()[station]; @@ -56,6 +56,9 @@ public final class ChannelProfileBuilder { surface.maximumDepth() ); HydrologyTerrainSample terrain = sampler.sample(stationX, stationZ); + if (coast == count && (terrain == null || terrain.ocean())) { + coast = station; + } double widthMultiplier = terrain == null ? 1D : terrain.widthMultiplier(); double depthMultiplier = terrain == null ? 1D : terrain.depthMultiplier(); bank[station] = terrain == null ? 1D : terrain.bankMultiplier(); @@ -74,12 +77,17 @@ public final class ChannelProfileBuilder { smoothWidth[station] *= 1D + (localRatio - 1D) * remaining; smoothDepth[station] += remaining; } - if (directOcean) { - int flare = Math.min(MOUTH_FLARE, count / 3); - int flareStart = count - flare; - for (int station = Math.max(0, flareStart); station < count; station++) { - double progress = SurfaceNoise.smoothStep((station - flareStart) / (double) Math.max(1, flare - 1)); + // The inlet: over its length before the coast the channel widens toward the mouth flare and + // its bed deepens by the inlet depth, so the estuary is wider and deeper than the river. The + // flare completes at the shoreline, the first station in the sea, and the stations past it keep it. + HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet(); + if (directOcean && inlet.length() > 0 && coast > 1) { + int flare = Math.min(inlet.length(), coast - 1); + int flareStart = coast - flare; + for (int station = flareStart; station < count; station++) { + double progress = SurfaceNoise.smoothStep(Math.min(1D, (station - flareStart) / (double) Math.max(1, flare - 1))); smoothWidth[station] *= 1D + (surface.banks().mouthFlareRatio() - 1D) * progress; + smoothDepth[station] += inlet.depth() * progress; } } double[] halfWidth = new double[count]; diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java index 08f8dd08e..961ef5fb9 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.hydrology.surface; +import art.arcane.iris.engine.hydrology.HydrologyHash; import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; @@ -12,12 +13,13 @@ import it.unimi.dsi.fastutil.longs.LongArrayList; import java.util.Objects; public final class ErosionFieldCompiler { - private static final int BLEND_SMOOTHING_RADIUS = 12; - private static final double THALWEG_FRACTION = 0.45D; private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; private static final int[][] DIAGONALS = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; private static final long OUTLINE_SALT = 0x4f55544c494e45L; private static final long BED_SALT = 0x424544L; + private static final long SOURCE_POND_SALT = 0x504f4e4453524345L; + private static final long TERMINAL_POND_SALT = 0x504f4e4454524dL; + private static final int POND_RIM_SAMPLES_PER_BLOCK = 8; private static final double EPSILON = 1.0E-9D; private final HydrologyPlannerSettings.Surface surface; @@ -41,12 +43,29 @@ public final class ErosionFieldCompiler { ValleyProfile valley, SurfaceTerminal terminal, int apronLimit + ) { + return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, surface.banks().ponds()); + } + + /** + * @param ponds the ponds to dig at the course ends; a river passes the configured ones, a standing + * pool passes none + */ + public ErosionField compile( + long courseSeed, + SurfaceCenterline centerline, + ChannelProfile channel, + ValleyProfile valley, + SurfaceTerminal terminal, + int apronLimit, + HydrologyPlannerSettings.Ponds ponds ) { HydrologyPlannerSettings.Banks banks = surface.banks(); + HydrologyPlannerSettings.Erosion erosion = banks.erosion(); int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations(); double roughness = banks.roughness(); double shoreWidth = surface.shoreWidth(); - int freeboard = banks.freeboard(); + int sink = banks.sink(); boolean[] basin = basins(valley, channel, count); double[][] blendWidth = blendWidths(centerline, channel, valley, count); Long2IntOpenHashMap nearest = new Long2IntOpenHashMap(); @@ -54,7 +73,10 @@ public final class ErosionFieldCompiler { Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap(); for (int station = 0; station < count; station++) { double widest = Math.max(blendWidth[station][0], blendWidth[station][1]); - int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + shoreWidth + widest) + 1; + // The shore biome band a policy asks for can be wider than the geometric shore and the + // eroded valley; the search radius has to reach it so those columns get a shore role. + double stationBand = stationShoreBiomeBand(centerline, station, shoreWidth); + int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + Math.max(shoreWidth + widest, stationBand)) + 1; int stationX = centerline.x()[station]; int stationZ = centerline.z()[station]; for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) { @@ -107,42 +129,195 @@ public final class ErosionFieldCompiler { if (wet) { double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline)); double depth = channel.depth()[station]; - double local = 1D + (depth - 1D) * bowl(normalized) - + 0.5D * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()) + double local = 1D + (depth - 1D) * bowl(normalized, erosion.thalwegFraction()) + + erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()) + (basin[station] ? 1D : 0D); int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local))); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); wetKeys.add(key); continue; } - int bankTop = head + freeboard; + int bankTop = head + sink; + // The SHORE role is the shore biome band: the column's policy width, or the geometric shore + // width where no policy sets one. It is independent of the flattened geometric shore and of + // the eroded valley, so a wide band reaches over untouched ground and a zero band leaves the + // geometric shore with the bank biome. + boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D; + SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK; if (cellDistance <= outline + shoreWidth + 0.25D) { int height = Math.min(natural, bankTop); - columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false)); + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); continue; } int cut = natural - bankTop; - if (cut <= 0) { + if (cut <= 0 || !erosion.enabled()) { + if (shoreBiome) { + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); + } continue; } double side = side(centerline, station, cellX, cellZ); double width = blendWidth[station][side < 0D ? 0 : 1]; double progress = (cellDistance - outline - shoreWidth) / width; if (progress >= 1D) { + if (shoreBiome) { + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); + } continue; } - int height = Math.min(natural, (int) StrictMath.round(bankTop + cut * SurfaceNoise.smoothStep(progress))); - columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.BANK, height, height, false)); + int height = Math.min(natural, (int) StrictMath.round(bankTop + cut * blend(progress, erosion.blendCurve()))); + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); } - int uncontained = contain(columns, wetKeys, freeboard); + if (count > 0) { + pond(columns, wetKeys, courseSeed ^ SOURCE_POND_SALT, ponds.source(), centerline, 0, valley.head()[0]); + if (terminal == SurfaceTerminal.SINKHOLE) { + pond(columns, wetKeys, courseSeed ^ TERMINAL_POND_SALT, ponds.terminal(), centerline, count - 1, valley.head()[count - 1]); + } + } + int uncontained = contain(columns, wetKeys, sink); connectSteps(columns, wetKeys); return new ErosionField(columns, uncontained); } + /** + * A round bowl around an end station holding that station's head: wet inside its outline, a shore + * ring and an eroded rim outside it like the channel's own. Channel water inside the bowl joins + * the pond at the pond's level; dry channel columns give way to the bowl. The radius is chosen per + * course within the configured range and shrinks where the ground around the rim falls below the + * water, so the pond never sits above the bank that has to hold it; no radius fits, no pond. + */ + private void pond( + Long2ObjectOpenHashMap columns, + LongArrayList wetKeys, + long seed, + HydrologyPlannerSettings.Pond pond, + SurfaceCenterline centerline, + int station, + int head + ) { + if (!pond.enabled()) { + return; + } + HydrologyPlannerSettings.Banks banks = surface.banks(); + HydrologyPlannerSettings.Erosion erosion = banks.erosion(); + double roughness = banks.roughness(); + double shoreWidth = surface.shoreWidth(); + int sink = banks.sink(); + int centreX = centerline.x()[station]; + int centreZ = centerline.z()[station]; + int radius = pondRadius(seed, pond, centreX, centreZ, head + sink, roughness); + if (radius < 1) { + return; + } + int bankTop = head + sink; + int reach = (int) StrictMath.ceil(radius * (1D + roughness) + shoreWidth + banks.maximumBlendWidth()) + 1; + for (int deltaZ = -reach; deltaZ <= reach; deltaZ++) { + for (int deltaX = -reach; deltaX <= reach; deltaX++) { + double distance = Math.sqrt((double) deltaX * deltaX + (double) deltaZ * deltaZ); + if (distance > reach) { + continue; + } + int cellX = centreX + deltaX; + int cellZ = centreZ + deltaZ; + HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); + if (!SurfaceCellAdmission.writable(terrain, seaLevel)) { + continue; + } + int natural = terrain.naturalHeight(); + long key = RiverFootprint.pack(cellX, cellZ); + SurfaceColumn existing = columns.get(key); + if (existing != null && existing.apron()) { + continue; + } + double outline = radius * (1D + roughness * SurfaceNoise.signed( + seed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength())); + outline = Math.max(Math.max(0.5D, 0.6D * radius), Math.min(1.4D * radius, outline)); + if (distance <= outline + 0.25D) { + double normalized = Math.min(1D, distance / Math.max(0.5D, outline)); + double local = 1D + (pond.depth() - 1D) * bowl(normalized, erosion.thalwegFraction()) + + erosion.bedNoise() * roughness * SurfaceNoise.signed(seed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()); + int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local))); + boolean wasWet = existing != null && existing.role() == SurfaceRole.CHANNEL; + if (wasWet) { + bed = Math.min(bed, existing.height()); + } + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); + if (!wasWet) { + wetKeys.add(key); + } + continue; + } + if (existing != null && existing.role() == SurfaceRole.CHANNEL) { + continue; + } + if (distance <= outline + shoreWidth + 0.25D) { + int height = Math.min(natural, bankTop); + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false)); + continue; + } + int cut = natural - bankTop; + if (cut <= 0 || !erosion.enabled()) { + continue; + } + double width = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), cut * banks.blendSlope())); + double progress = (distance - outline - shoreWidth) / width; + if (progress >= 1D) { + continue; + } + int height = Math.min(natural, (int) StrictMath.round(bankTop + cut * blend(progress, erosion.blendCurve()))); + if (existing != null && existing.height() <= height) { + continue; + } + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.BANK, height, height, false)); + } + } + } + + /** + * The largest radius in the configured range, chosen per course, whose rim ground everywhere + * reaches the bank top; zero when even the smallest radius sits above the ground beside it. + */ + private int pondRadius(long seed, HydrologyPlannerSettings.Pond pond, int centreX, int centreZ, int bankTop, double roughness) { + int chosen = HydrologyHash.between(seed, pond.minimumRadius(), pond.maximumRadius()); + for (int radius = chosen; radius >= pond.minimumRadius(); radius--) { + if (rimHolds(centreX, centreZ, radius * (1D + roughness) + 1D, bankTop)) { + return radius; + } + } + return 0; + } + + private boolean rimHolds(int centreX, int centreZ, double rim, int bankTop) { + int samples = Math.max(8, (int) StrictMath.ceil(rim * POND_RIM_SAMPLES_PER_BLOCK)); + for (int sample = 0; sample < samples; sample++) { + double angle = sample * (2D * Math.PI / samples); + for (double ring = rim; ring <= rim + 1D; ring += 0.5D) { + int cellX = (int) StrictMath.round(centreX + Math.cos(angle) * ring); + int cellZ = (int) StrictMath.round(centreZ + Math.sin(angle) * ring); + HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); + if (!SurfaceCellAdmission.writable(terrain, seaLevel) || terrain.naturalHeight() < bankTop) { + return false; + } + } + } + return true; + } + + /** Eased blend from the bank top (0) to natural terrain (1); the curve exponent reshapes the valley side. */ + static double blend(double progress, double curve) { + return SurfaceNoise.smoothStep(Math.pow(progress, curve)); + } + /** * Where the head steps down between neighbouring wet cells, the upper bed is lowered so its water * column reaches the lower head; the water stays one connected body across every step. */ + /** The shore biome band the terrain at a station asks for, or the geometric shore width without a policy. */ + private double stationShoreBiomeBand(SurfaceCenterline centerline, int station, double shoreWidth) { + HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]); + return terrain == null ? shoreWidth : terrain.shoreBiomeWidth(shoreWidth); + } + private static void connectSteps(Long2ObjectOpenHashMap columns, LongArrayList wetKeys) { for (int index = 0; index < wetKeys.size(); index++) { long key = wetKeys.getLong(index); @@ -167,21 +342,29 @@ public final class ErosionFieldCompiler { * Every dry cell touching water, diagonals included, keeps the lip; only cardinal neighbours can * spill, so only they count as uncontained. */ - private int contain(Long2ObjectOpenHashMap columns, LongArrayList wetKeys, int freeboard) { + private int contain(Long2ObjectOpenHashMap columns, LongArrayList wetKeys, int sink) { int uncontained = 0; for (int index = 0; index < wetKeys.size(); index++) { SurfaceColumn wet = columns.get(wetKeys.getLong(index)); - uncontained += lip(columns, wet, CARDINALS, freeboard, true); - lip(columns, wet, DIAGONALS, freeboard, false); + uncontained += lip(columns, wet, CARDINALS, sink, true); + lip(columns, wet, DIAGONALS, sink, false); } return uncontained; } + /** + * The bank beside water must reach the natural ground the head was taken from: the water's own + * block level plus the sink. A neighbour lower than that is raised to it. The solver takes the head + * from the banks it samples at each station, so a cell it never sampled (between stations, on the + * outside of a bend) can lie a block lower than the water beside it: such a cell may be raised one + * block above its natural height, a lip no taller than the old fixed one, rather than let the + * water spill. A deeper shortfall stays uncontained and is counted. + */ private int lip( Long2ObjectOpenHashMap columns, SurfaceColumn wet, int[][] offsets, - int freeboard, + int sink, boolean countSpills ) { int uncontained = 0; @@ -189,7 +372,9 @@ public final class ErosionFieldCompiler { long key = RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1]); SurfaceColumn neighbour = columns.get(key); if (neighbour == null) { - if (countSpills && wet.headY() != seaLevel) { + // Water that meets the sea at the mouth is not spilling; anything else beyond the field is. + if (countSpills && wet.headY() != seaLevel + && SurfaceCellAdmission.writable(sampler.sample(wet.x() + offset[0], wet.z() + offset[1]), seaLevel)) { uncontained++; } continue; @@ -197,9 +382,10 @@ public final class ErosionFieldCompiler { if (neighbour.role() == SurfaceRole.CHANNEL) { continue; } - int required = wet.headY() + freeboard; + int required = wet.headY() + sink; if (neighbour.height() < required) { - int raised = Math.min(neighbour.terrain().naturalHeight(), required); + int ceiling = neighbour.terrain().naturalHeight() + (countSpills ? 1 : 0); + int raised = Math.min(ceiling, required); neighbour = neighbour.withHeight(raised); columns.put(key, neighbour); } @@ -243,10 +429,13 @@ public final class ErosionFieldCompiler { private double[][] blendWidths(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, int count) { HydrologyPlannerSettings.Banks banks = surface.banks(); double[][] widths = new double[count][2]; + if (!banks.erosion().enabled()) { + return widths; + } for (int station = 0; station < count; station++) { double halfWidth = channel.halfWidth()[station]; double probe = halfWidth + surface.shoreWidth() + Math.max(2D, halfWidth); - int bankTop = valley.head()[station] + banks.freeboard(); + int bankTop = valley.head()[station] + banks.sink(); for (int side = 0; side < 2; side++) { double direction = side == 0 ? -1D : 1D; int probeX = (int) StrictMath.round(centerline.x()[station] + centerline.normalX(station) * probe * direction); @@ -257,7 +446,7 @@ public final class ErosionFieldCompiler { widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width)); } } - return smoothWidths(widths, BLEND_SMOOTHING_RADIUS); + return smoothWidths(widths, banks.erosion().smoothingRadius()); } /** @@ -293,11 +482,11 @@ public final class ErosionFieldCompiler { * Bed depth factor across the channel: a level thalweg over the inner part of the width that eases * up to a one-block edge, so the channel reads as a broad bowl rather than a V-shaped trough. */ - static double bowl(double normalized) { - if (normalized <= THALWEG_FRACTION) { + static double bowl(double normalized, double thalwegFraction) { + if (normalized <= thalwegFraction) { return 1D; } - double t = Math.min(1D, (normalized - THALWEG_FRACTION) / (1D - THALWEG_FRACTION)); + double t = Math.min(1D, (normalized - thalwegFraction) / (1D - thalwegFraction)); return 1D - t * t * (3D - 2D * t); } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java index a92512ca1..a3b27c8f9 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java @@ -74,7 +74,8 @@ public final class SurfaceFootprintCompiler { channel, valley, terminal, - settings.outlets().maximumOceanApron() + settings.outlets().maximumOceanApron(), + pool ? HydrologyPlannerSettings.Ponds.none() : settings.surface().banks().ponds() ); ArrayList ordered = new ArrayList<>(field.columns().values()); ordered.sort(Comparator diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java index 9ea6c7ed0..2925510d9 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java @@ -3,5 +3,7 @@ package art.arcane.iris.engine.hydrology.surface; public enum SurfaceTerminal { OCEAN_MOUTH, SINKHOLE, - COASTAL_GROTTO + COASTAL_GROTTO, + /** The course ends on another surface course; the planner levels its tail to the stem's water at the junction. */ + TRIBUTARY } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java index c74939e6b..c70744fe1 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java @@ -8,6 +8,7 @@ import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; import java.util.Objects; public final class ValleyProfileSolver { + private static final int BANK_LOOKAHEAD = 2; private final HydrologyPlannerSettings.Surface surface; private final HydrologyTerrainSampler sampler; private final int seaLevel; @@ -84,11 +85,28 @@ public final class ValleyProfileSolver { if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH) { return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed); } - int inset = surface.banks().inset(); + // The channel's rounded end caps reach past the first and last stations; the ground under + // them bounds those stations' water like any other bank cell. + crossMin[0] = Math.min(crossMin[0], capMinimum(centerline, channel, 0, -1D, roughness)); + if (exposed == count) { + crossMin[exposed - 1] = Math.min(crossMin[exposed - 1], capMinimum(centerline, channel, exposed - 1, 1D, roughness)); + } + int sink = surface.banks().sink(); int[] head = new int[count]; for (int station = 0; station < exposed; station++) { - int ceiling = crossMin[station] - inset; - head[station] = station == 0 ? ceiling : Math.min(head[station - 1], ceiling); + // The cells beside a station's water include cells the next stations own, so on ground + // that falls gently along the course the head steps down a station early rather than + // onto a bank the lip cannot raise above its natural height. A larger drop is a step or + // a fall and keeps its edge where the ground breaks. + int ceiling = crossMin[station] - sink; + int lookahead = ceiling; + for (int ahead = 1; ahead <= BANK_LOOKAHEAD && station + ahead < exposed; ahead++) { + int next = crossMin[station + ahead] - sink; + if (next < lookahead && next >= ceiling - 1) { + lookahead = next; + } + } + head[station] = station == 0 ? lookahead : Math.min(head[station - 1], lookahead); } if (terminal == SurfaceTerminal.OCEAN_MOUTH || terminal == SurfaceTerminal.COASTAL_GROTTO) { for (int station = 0; station < exposed; station++) { @@ -100,25 +118,108 @@ public final class ValleyProfileSolver { head[station] = seaLevel; } } + // The inlet: the drowned reach runs inland from the coast, holding the water at sea level, as + // far as the inlet length allows and the ground can be cut to sea level within the inlet cap; + // a rise the cap cannot pass ends it there. Above it the approach grades down one block per + // station into the estuary wherever that cut fits, instead of dropping in a wall at the coast. + // The drowned reach is the end of a river, never most of one: it takes at most half the exposed + // course and the approach half of that again, so the headwater keeps its natural head. + // Every head set here is at or below the terrain-supported head, so the course stays non-rising. + HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet(); + int channelIncision = surface.maximumIncision(); + int inletIncision = Math.max(channelIncision, inlet.maximumIncision()); + int rampStart = exposed; + if (terminal == SurfaceTerminal.OCEAN_MOUTH && inlet.length() > 0) { + int reach = Math.min(inlet.length(), exposed / 2); + int inletStart = exposed; + while (inletStart > 0 && exposed - inletStart < reach + && cutFits(inletStart - 1, seaLevel, channel, centerNatural, incisionMultiplier, inletIncision)) { + inletStart--; + } + for (int station = inletStart; station < exposed; station++) { + head[station] = seaLevel; + } + rampStart = Math.max(0, inletStart - reach / 2); + // The ramp is contiguous with the inlet: the first station the cap cannot lower ends it, + // otherwise a station lowered further upstream would sit below one left at its natural head. + for (int station = inletStart - 1; station >= rampStart; station--) { + int target = seaLevel + (inletStart - station); + if (head[station] <= target) { + continue; + } + if (!cutFits(station, target, channel, centerNatural, incisionMultiplier, inletIncision)) { + break; + } + head[station] = target; + } + } if (terminal == SurfaceTerminal.SINKHOLE && head[exposed - 1] < terminalHead) { return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE, terminalHead - head[exposed - 1]); } if (terminal == SurfaceTerminal.COASTAL_GROTTO && head[exposed - 1] < terminalHead) { return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE, terminalHead - head[exposed - 1]); } - int maximumIncision = surface.maximumIncision(); int deepestCut = 0; boolean rejected = false; for (int station = 0; station < exposed; station++) { - int permitted = Math.min(maximumIncision, (int) StrictMath.floor(maximumIncision * incisionMultiplier[station])); - int bed = head[station] - (int) StrictMath.round(channel.depth()[station]); - int cut = centerNatural[station] - bed; + int maximumIncision = station >= rampStart ? inletIncision : channelIncision; + int cut = cut(station, head[station], channel, centerNatural); deepestCut = Math.max(deepestCut, cut); - rejected |= cut > permitted; + rejected |= cut > permitted(station, incisionMultiplier, maximumIncision); } if (rejected) { return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, deepestCut); } return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null, 0); } + + private static boolean cutFits( + int station, + int head, + ChannelProfile channel, + int[] centerNatural, + double[] incisionMultiplier, + int maximumIncision + ) { + return cut(station, head, channel, centerNatural) <= permitted(station, incisionMultiplier, maximumIncision); + } + + /** How far below the natural ground the bed under a head sits at a station. */ + private static int cut(int station, int head, ChannelProfile channel, int[] centerNatural) { + return centerNatural[station] - (head - (int) StrictMath.round(channel.depth()[station])); + } + + private static int permitted(int station, double[] incisionMultiplier, int maximumIncision) { + return Math.min(maximumIncision, (int) StrictMath.floor(maximumIncision * incisionMultiplier[station])); + } + + /** + * Lowest natural ground under the rounded cap the channel outline draws past an end station, in + * the given direction along the tangent; cells the channel cannot write are ignored. + */ + private int capMinimum(SurfaceCenterline centerline, ChannelProfile channel, int station, double direction, double roughness) { + double reach = channel.halfWidth()[station] * (1D + roughness) + 2D; + double tangentX = centerline.tangentX()[station] * direction; + double tangentZ = centerline.tangentZ()[station] * direction; + double normalX = centerline.normalX(station); + double normalZ = centerline.normalZ(station); + int stationX = centerline.x()[station]; + int stationZ = centerline.z()[station]; + int minimum = Integer.MAX_VALUE; + for (double along = 0.5D; along <= reach; along += 0.5D) { + for (double across = -reach; across <= reach; across += 0.5D) { + if (along * along + across * across > reach * reach) { + continue; + } + int cellX = (int) StrictMath.round(stationX + tangentX * along + normalX * across); + int cellZ = (int) StrictMath.round(stationZ + tangentZ * along + normalZ * across); + HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); + if (!SurfaceCellAdmission.writable(terrain, seaLevel)) { + continue; + } + minimum = Math.min(minimum, terrain.naturalHeight()); + } + } + return minimum; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCoastalRiverGrottoConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCoastalRiverGrottoConfig.java index c02ba3384..f4f97e32a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisCoastalRiverGrottoConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCoastalRiverGrottoConfig.java @@ -9,7 +9,7 @@ import lombok.experimental.Accessors; @Accessors(chain = true) @NoArgsConstructor -@Desc("Controls direct, contained river-to-ocean grotto outlets.") +@Desc("Controls coastal grottos: sea-level chambers whose only opening is their ocean face, admitted either as a river outlet or as a standalone sea cave.") @Data public class IrisCoastalRiverGrottoConfig { @Desc("Allow a coastal grotto to serve as an explicit accepted outlet.") @@ -37,4 +37,7 @@ public class IrisCoastalRiverGrottoConfig { @MaxNumber(1048576) @Desc("Maximum accepted coastal grotto volume in blocks.") private int maximumVolume = 8192; + + @Desc("Standalone sea caves: coastal grottos opening from the ocean into the coast without a river.") + private IrisSeaCaveConfig seaCaves = new IrisSeaCaveConfig(); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMouthConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMouthConfig.java index d7a61ac0b..d6db3888a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMouthConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMouthConfig.java @@ -21,4 +21,19 @@ public class IrisRiverMouthConfig { @MaxNumber(32) @Desc("Maximum number of blocks the recorded mouth footprint may enter the ocean before terminating.") private int maximumOceanApron = 8; + + @MinNumber(0) + @MaxNumber(256) + @Desc("Blocks of river before the coast held at sea level as a drowned inlet, widened toward flareRatio over that reach. Zero ends the river at the shoreline with no inlet.") + private int inletLength = 64; + + @MinNumber(0) + @MaxNumber(16) + @Desc("Extra bed depth reached at the shoreline over the inlet, so the estuary is deeper than the river above it.") + private int inletDepth = 3; + + @MinNumber(0) + @MaxNumber(128) + @Desc("Deepest cut allowed in the inlet and its approach ramp, replacing channel.maximumIncision there, so the coast may be cut down to sea level through a rise of this height.") + private int maximumIncision = 32; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java index 89c088dca..d6bd7c612 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java @@ -83,6 +83,14 @@ public class IrisRiverPolicy { @Desc("Multiplier applied to the eroded bank width outside the shore band.") private Double bankMultiplier = null; + @MinNumber(0) + @MaxNumber(32) + @Desc("Width in blocks of the shore biome band beside a surface river in this area. Null inherits, and without any value the band is the surface banks shore width. Zero gives the area no shore biome band; the geometric shore and erosion are unchanged.") + private Double shoreBiomeWidth = null; + + @Desc("Rivers that start in this area stay inside it: their whole course and their outlet lie in this region (when set on a region) or this biome (when set on a biome), and a river that flows into the area never leaves it. A source with no reachable outlet inside the area is rejected. Null inherits, false releases the area.") + private Boolean confined = null; + public KSet getAllBiomeIds() { KSet biomeIds = new KSet<>(); addAll(biomeIds, surfaceBiomes); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java index 482a35255..ac0d0c902 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java @@ -41,9 +41,14 @@ public class IrisRiverRoutingConfig { @MinNumber(1) @MaxNumber(256) - @Desc("Maximum drainage roots selected per planning tile.") + @Desc("Maximum inland drainage roots (sinkhole grottos) selected per planning tile. Unused when no inland outlet kind is enabled.") private int maximumOutletsPerTile = 4; + @MinNumber(0) + @MaxNumber(64) + @Desc("Maximum sea outlets (river mouths and coastal grottos) selected per planning tile, chosen by type in turn: the best mouth, the best coastal grotto, the next mouth, and so on, each at least two sample spacings from the others. Budgeted separately from maximumOutletsPerTile.") + private int maximumCoastalOutletsPerTile = 2; + @Desc("Allow rivers to terminate at an ocean reservoir.") private boolean oceanOutlets = true; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSeaCaveConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSeaCaveConfig.java new file mode 100644 index 000000000..328d54bc0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSeaCaveConfig.java @@ -0,0 +1,37 @@ +package art.arcane.iris.engine.object; + +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 lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Sea caves: coastal grottos that open from the ocean into the coast without a river. The chamber size, headroom and volume cap are the coastal grotto's.") +@Data +public class IrisSeaCaveConfig { + @Desc("Plan sea caves along the coast. Requires the coastal grotto to be enabled.") + private boolean enabled = true; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Sea caves accepted per planning tile; the steepest owned coast is taken first.") + private int maximumPerTile = 3; + + @MinNumber(16) + @MaxNumber(8192) + @Desc("Least distance in blocks between two sea caves. Must be at least twice the coastal grotto horizontalRadius.") + private int minimumSpacing = 160; + + @MinNumber(1) + @MaxNumber(128) + @Desc("The coast must stand this many blocks above the sea both at the shoreline and at the back of the chamber.") + private int minimumCoastHeight = 8; + + @MinNumber(0) + @MaxNumber(128) + @Desc("How far inland from the shoreline the chamber is swept. 0 leaves a single chamber at the shore.") + private int depth = 12; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBankConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBankConfig.java index 7127627da..8e47e411a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBankConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBankConfig.java @@ -9,14 +9,9 @@ import lombok.experimental.Accessors; @Accessors(chain = true) @NoArgsConstructor -@Desc("Controls the lip, shore band and eroded valley around a surface river.") +@Desc("Controls the shore band and the eroded valley around a surface river.") @Data public class IrisSurfaceRiverBankConfig { - @MinNumber(0) - @MaxNumber(4) - @Desc("Blocks the bank top sits above the water surface at the channel edge.") - private int freeboard = 1; - @MinNumber(0.5) @MaxNumber(6) @Desc("Width in blocks of the shore band that receives shore biome content.") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java index 3670d9302..0bd051ddb 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java @@ -20,8 +20,8 @@ public class IrisSurfaceRiverChannelConfig { @MinNumber(0) @MaxNumber(3) - @Desc("Blocks the water surface sits below the lowest natural bank beside the channel.") - private int inset = 1; + @Desc("Blocks the water surface sinks below the lowest natural ground beside the channel; 0 keeps the water flush with the bank, and the bank always meets the water at its own height.") + private int sink = 0; @MinNumber(1) @MaxNumber(32) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java index e97d4e91d..0b370fc26 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java @@ -16,10 +16,10 @@ public class IrisSurfaceRiverConfig { @Desc("Surface-river source allocation.") private IrisSurfaceRiverSourceConfig sources = new IrisSurfaceRiverSourceConfig(); - @Desc("Wet channel width, depth, inset and roughness.") + @Desc("Wet channel width, depth, sink and roughness.") private IrisSurfaceRiverChannelConfig channel = new IrisSurfaceRiverChannelConfig(); - @Desc("Lip, shore band and eroded valley around the channel.") + @Desc("Shore band and eroded valley around the channel.") private IrisSurfaceRiverBankConfig banks = new IrisSurfaceRiverBankConfig(); @Desc("Material under and beside the water: falling-block replacement and padding.") private IrisSurfaceRiverBedConfig bed = new IrisSurfaceRiverBedConfig(); @@ -29,4 +29,8 @@ public class IrisSurfaceRiverConfig { @Desc("Coastal mouth flare and ocean-apron limits.") private IrisRiverMouthConfig mouths = new IrisRiverMouthConfig(); + @Desc("How the ground around the channel is eroded into a valley.") + private IrisSurfaceRiverErosionConfig erosion = new IrisSurfaceRiverErosionConfig(); + @Desc("Ponds at the source and at the inland end of every surface river.") + private IrisSurfaceRiverPondsConfig ponds = new IrisSurfaceRiverPondsConfig(); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverErosionConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverErosionConfig.java new file mode 100644 index 000000000..756c8c4b0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverErosionConfig.java @@ -0,0 +1,37 @@ +package art.arcane.iris.engine.object; + +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 lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls how the ground around a surface river is eroded into a valley. The valley's reach comes from banks.blendSlope and the blend widths; this section shapes it.") +@Data +public class IrisSurfaceRiverErosionConfig { + @Desc("Erode the ground beyond the shore band into a valley; false keeps only the wet channel, the shore band and the lip that holds the water.") + private boolean enabled = true; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Stations along the course the valley width is averaged over, so it widens and narrows gradually; 0 follows the local cut exactly.") + private int smoothingRadius = 12; + + @MinNumber(0) + @MaxNumber(0.95) + @Desc("Share of the channel half-width that stays at full bed depth before the bed rises to the edge; higher is a flatter, broader bed.") + private double thalwegFraction = 0.45D; + + @MinNumber(0.25) + @MaxNumber(4) + @Desc("Exponent on the blend from the bank top out to natural terrain; below 1 hollows the valley sides, above 1 keeps them steep near the shore.") + private double blendCurve = 1D; + + @MinNumber(0) + @MaxNumber(2) + @Desc("Share of the channel roughness applied to the bed as depth variation; 0 leaves a smooth bed.") + private double bedNoise = 0.5D; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondConfig.java new file mode 100644 index 000000000..0ac73c0f8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondConfig.java @@ -0,0 +1,38 @@ +package art.arcane.iris.engine.object; + +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 lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("A round pond at one end of a surface river, holding the river's water level there.") +@Data +public class IrisSurfaceRiverPondConfig { + @Desc("Dig the pond.") + private boolean enabled = true; + + @MinNumber(1) + @MaxNumber(32) + @Desc("Smallest pond radius in blocks; the radius is chosen per river between the minimum and the maximum, and shrinks where the ground falls away around the rim.") + private int minimumRadius = 6; + + @MinNumber(1) + @MaxNumber(32) + @Desc("Largest pond radius in blocks.") + private int maximumRadius = 12; + + @MinNumber(1) + @MaxNumber(8) + @Desc("Depth of the pond bowl below the water surface at its center.") + private int depth = 3; + + public IrisSurfaceRiverPondConfig(int minimumRadius, int maximumRadius, int depth) { + this.minimumRadius = minimumRadius; + this.maximumRadius = maximumRadius; + this.depth = depth; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondsConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondsConfig.java new file mode 100644 index 000000000..26678ae0f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverPondsConfig.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Ponds at the two ends of a surface river: the spring it rises from and, for a river that ends inland, the pool it drains into.") +@Data +public class IrisSurfaceRiverPondsConfig { + @Desc("The spring pond every surface river rises from.") + private IrisSurfaceRiverPondConfig source = new IrisSurfaceRiverPondConfig(6, 12, 3); + + @Desc("The pond a river that ends inland drains into; rivers that reach the ocean get none.") + private IrisSurfaceRiverPondConfig terminal = new IrisSurfaceRiverPondConfig(4, 7, 3); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java index a2aca6c52..c8a333058 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java @@ -38,6 +38,11 @@ public class IrisUndergroundRiverConfig { @Desc("Distance in blocks over which an ocean-bound underground river levels to sea level before its mouth.") private int mouthLevelingDistance = 64; + @MinNumber(0) + @MaxNumber(4) + @Desc("Extra underground courses an outlet may accept as tributaries joining its main passage; they are budgeted on top of the source density, and 0 keeps one passage per outlet.") + private int tributaries = 1; + private static IrisStyledRange range(double min, double max, double zoom) { return new IrisStyledRange(min, max, new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(zoom)); } diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java index f5bbbd86c..80c4f8823 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java @@ -78,6 +78,7 @@ public class PackHydrologyValidatorTest { "minimumSurfaceCourseLength": 0, "minimumUndergroundCourseLength": 0, "maximumOutletsPerTile": 1, + "maximumCoastalOutletsPerTile": 0, "oceanOutlets": true, "valleyPreference": 0, "uphillPenalty": 0, @@ -88,13 +89,12 @@ public class PackHydrologyValidatorTest { "channel": { "width": {"min": 1, "max": 1}, "depth": {"min": 1, "max": 1}, - "inset": 0, + "sink": 0, "maximumIncision": 1, "roughness": 0, "roughnessWavelength": 4 }, "banks": { - "freeboard": 0, "shoreWidth": 0.5, "blendSlope": 0.5, "minimumBlendWidth": 1, @@ -104,7 +104,15 @@ public class PackHydrologyValidatorTest { "flow": {"cascadeRun": 1, "waterfallMinimumDrop": 2}, "mouths": {"flareRatio": 1, "maximumOceanApron": 0} }, - "underground": {"mouthLevelingDistance": 16} + "underground": {"mouthLevelingDistance": 16}, + "grottos": { + "coastal": { + "horizontalRadius": 8, + "verticalRadius": 1, + "headroom": 1, + "seaCaves": {"enabled": false, "maximumPerTile": 0, "minimumSpacing": 16, "minimumCoastHeight": 1, "depth": 0} + } + } } """, "\"bankMultiplier\": 0")); @@ -247,6 +255,7 @@ public class PackHydrologyValidatorTest { "minimumSurfaceCourseLength": 32768, "minimumUndergroundCourseLength": 32768, "maximumOutletsPerTile": 256, + "maximumCoastalOutletsPerTile": 64, "oceanOutlets": true, "valleyPreference": 8, "uphillPenalty": 128, @@ -257,13 +266,12 @@ public class PackHydrologyValidatorTest { "channel": { "width": {"min": 128, "max": 128}, "depth": {"min": 64, "max": 64}, - "inset": 3, + "sink": 3, "maximumIncision": 32, "roughness": 1, "roughnessWavelength": 64 }, "banks": { - "freeboard": 4, "shoreWidth": 6, "blendSlope": 12, "minimumBlendWidth": 64, @@ -273,7 +281,13 @@ public class PackHydrologyValidatorTest { "flow": {"cascadeRun": 8, "waterfallMinimumDrop": 32}, "mouths": {"flareRatio": 4, "maximumOceanApron": 32} }, - "underground": {"mouthLevelingDistance": 512} + "underground": {"mouthLevelingDistance": 512}, + "grottos": { + "coastal": { + "horizontalRadius": 128, + "seaCaves": {"enabled": true, "maximumPerTile": 64, "minimumSpacing": 8192, "minimumCoastHeight": 128, "depth": 128} + } + } } """, "\"bankMultiplier\": 4")); @@ -294,6 +308,7 @@ public class PackHydrologyValidatorTest { "minimumSurfaceCourseLength": 512, "minimumUndergroundCourseLength": 512, "maximumOutletsPerTile": 0, + "maximumCoastalOutletsPerTile": 65, "valleyPreference": 8.5, "uphillPenalty": 128.5, "slopePenalty": 16.5, @@ -310,6 +325,7 @@ public class PackHydrologyValidatorTest { "tileSize": 128, "sampleSpacing": 4, "maximumRouteLength": 128, + "maximumCoastalOutletsPerTile": -1, "valleyPreference": -0.5, "uphillPenalty": -0.5, "slopePenalty": -0.5, @@ -326,6 +342,7 @@ public class PackHydrologyValidatorTest { assertContains(aboveResult.errors(), "minimumSurfaceCourseLength must not exceed maximumRouteLength"); assertContains(aboveResult.errors(), "minimumUndergroundCourseLength must not exceed maximumRouteLength"); assertContains(aboveResult.errors(), "maximumOutletsPerTile must be at least 1"); + assertContains(aboveResult.errors(), "maximumCoastalOutletsPerTile must be at most 64"); assertContains(aboveResult.errors(), "valleyPreference must be at most 8.0"); assertContains(aboveResult.errors(), "uphillPenalty must be at most 128.0"); assertContains(aboveResult.errors(), "slopePenalty must be at most 16.0"); @@ -334,6 +351,7 @@ public class PackHydrologyValidatorTest { assertContains(belowResult.errors(), "tileSize must be at least 256"); assertContains(belowResult.errors(), "sampleSpacing must be at least 8"); assertContains(belowResult.errors(), "maximumRouteLength must be at least 256"); + assertContains(belowResult.errors(), "maximumCoastalOutletsPerTile must be at least 0"); assertContains(belowResult.errors(), "valleyPreference must be at least 0.0"); assertContains(belowResult.errors(), "uphillPenalty must be at least 0.0"); assertContains(belowResult.errors(), "slopePenalty must be at least 0.0"); @@ -349,7 +367,7 @@ public class PackHydrologyValidatorTest { "channel": { "width": {"min": 20, "max": 2}, "depth": {"min": 2, "max": 65}, - "inset": 4, + "sink": 4, "maximumIncision": 33, "roughness": 1.5, "roughnessWavelength": 65, @@ -366,7 +384,7 @@ public class PackHydrologyValidatorTest { "channel": { "width": {"min": 0, "max": 8}, "depth": {"min": 0, "max": 4}, - "inset": -1, + "sink": -1, "maximumIncision": 0, "roughness": -0.1, "roughnessWavelength": 3, @@ -382,7 +400,7 @@ public class PackHydrologyValidatorTest { assertContains(aboveResult.errors(), "width.min must not exceed max"); assertContains(aboveResult.errors(), "depth.max must be at most 64.0"); - assertContains(aboveResult.errors(), "inset must be at most 3"); + assertContains(aboveResult.errors(), "sink must be at most 3"); assertContains(aboveResult.errors(), "maximumIncision must be at most 32"); assertContains(aboveResult.errors(), "roughness must be at most 1.0"); assertContains(aboveResult.errors(), "roughnessWavelength must be at most 64"); @@ -392,12 +410,52 @@ public class PackHydrologyValidatorTest { assertContains(belowResult.errors(), "springLength must be at least 4"); assertContains(belowResult.errors(), "width.min must be at least 1.0"); assertContains(belowResult.errors(), "depth.min must be at least 1.0"); - assertContains(belowResult.errors(), "inset must be at least 0"); + assertContains(belowResult.errors(), "sink must be at least 0"); assertContains(belowResult.errors(), "maximumIncision must be at least 1"); assertContains(belowResult.errors(), "roughness must be at least 0.0"); assertContains(belowResult.errors(), "roughnessWavelength must be at least 4"); } + @Test + public void rejectsSurfaceErosionAndPondValuesOutsideTheirBounds() throws Exception { + File above = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "erosion": {"enabled": "yes", "smoothingRadius": 65, "thalwegFraction": 0.96, "blendCurve": 4.5, "bedNoise": 2.5}, + "ponds": {"source": {"minimumRadius": 33, "maximumRadius": 33, "depth": 9}, "terminal": {"minimumRadius": 6, "maximumRadius": 5, "depth": 1}} + } + } + """)); + File below = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "erosion": {"smoothingRadius": -1, "thalwegFraction": -0.1, "blendCurve": 0.1, "bedNoise": -1}, + "ponds": {"source": {"minimumRadius": 0, "maximumRadius": 0, "depth": 0}} + } + } + """)); + + PackHydrologyValidator.Validation aboveResult = validate(above); + PackHydrologyValidator.Validation belowResult = validate(below); + + assertContains(aboveResult.errors(), "enabled must be a boolean"); + assertContains(aboveResult.errors(), "smoothingRadius must be at most 64"); + assertContains(aboveResult.errors(), "thalwegFraction must be at most 0.95"); + assertContains(aboveResult.errors(), "blendCurve must be at most 4.0"); + assertContains(aboveResult.errors(), "bedNoise must be at most 2.0"); + assertContains(aboveResult.errors(), "minimumRadius must be at most 32"); + assertContains(aboveResult.errors(), "depth must be at most 8"); + assertContains(aboveResult.errors(), "ponds.terminal.minimumRadius must not exceed maximumRadius"); + assertContains(belowResult.errors(), "smoothingRadius must be at least 0"); + assertContains(belowResult.errors(), "thalwegFraction must be at least 0.0"); + assertContains(belowResult.errors(), "blendCurve must be at least 0.25"); + assertContains(belowResult.errors(), "bedNoise must be at least 0.0"); + assertContains(belowResult.errors(), "minimumRadius must be at least 1"); + assertContains(belowResult.errors(), "depth must be at least 1"); + } + @Test public void rejectsSurfaceBankValuesOutsideTheirBounds() throws Exception { File above = pack(riversDimension(""" @@ -405,7 +463,6 @@ public class PackHydrologyValidatorTest { "enabled": true, "surface": { "banks": { - "freeboard": 5, "shoreWidth": 6.5, "blendSlope": 12.5, "minimumBlendWidth": 40, @@ -420,7 +477,6 @@ public class PackHydrologyValidatorTest { "enabled": true, "surface": { "banks": { - "freeboard": -1, "shoreWidth": 0.4, "blendSlope": 0.4, "minimumBlendWidth": 0, @@ -442,12 +498,10 @@ public class PackHydrologyValidatorTest { PackHydrologyValidator.Validation belowResult = validate(below); PackHydrologyValidator.Validation unorderedResult = validate(unordered); - assertContains(aboveResult.errors(), "freeboard must be at most 4"); assertContains(aboveResult.errors(), "shoreWidth must be at most 6.0"); assertContains(aboveResult.errors(), "blendSlope must be at most 12.0"); assertContains(aboveResult.errors(), "maximumBlendWidth must be at most 64"); assertContains(aboveResult.errors(), "exposeCutStrata must be a boolean"); - assertContains(belowResult.errors(), "freeboard must be at least 0"); assertContains(belowResult.errors(), "shoreWidth must be at least 0.5"); assertContains(belowResult.errors(), "blendSlope must be at least 0.5"); assertContains(belowResult.errors(), "minimumBlendWidth must be at least 1"); @@ -461,7 +515,7 @@ public class PackHydrologyValidatorTest { "enabled": true, "surface": { "flow": {"cascadeRun": 9, "waterfallMinimumDrop": 33}, - "mouths": {"flareRatio": 4.5, "maximumOceanApron": 33} + "mouths": {"flareRatio": 4.5, "maximumOceanApron": 33, "inletLength": 257, "inletDepth": 17, "maximumIncision": 129} } } """)); @@ -470,22 +524,51 @@ public class PackHydrologyValidatorTest { "enabled": true, "surface": { "flow": {"cascadeRun": 0, "waterfallMinimumDrop": 1}, - "mouths": {"flareRatio": 0.5, "maximumOceanApron": -1} + "mouths": {"flareRatio": 0.5, "maximumOceanApron": -1, "inletLength": -1, "inletDepth": -1, "maximumIncision": -1} + } + } + """)); + File longInlet = pack(riversDimension(""" + { + "enabled": true, + "routing": {"minimumSurfaceCourseLength": 128}, + "surface": { + "mouths": {"inletLength": 200} + } + } + """)); + File fittingInlet = pack(riversDimension(""" + { + "enabled": true, + "routing": {"minimumSurfaceCourseLength": 128}, + "surface": { + "mouths": {"inletLength": 128, "inletDepth": 0, "maximumIncision": 0} } } """)); PackHydrologyValidator.Validation aboveResult = validate(above); PackHydrologyValidator.Validation belowResult = validate(below); + PackHydrologyValidator.Validation longInletResult = validate(longInlet); + PackHydrologyValidator.Validation fittingInletResult = validate(fittingInlet); assertContains(aboveResult.errors(), "cascadeRun must be at most 8"); assertContains(aboveResult.errors(), "waterfallMinimumDrop must be at most 32"); assertContains(aboveResult.errors(), "flareRatio must be at most 4.0"); assertContains(aboveResult.errors(), "maximumOceanApron must be at most 32"); + assertContains(aboveResult.errors(), "inletLength must be at most 256"); + assertContains(aboveResult.errors(), "inletDepth must be at most 16"); + assertContains(aboveResult.errors(), "mouths.maximumIncision must be at most 128"); assertContains(belowResult.errors(), "cascadeRun must be at least 1"); assertContains(belowResult.errors(), "waterfallMinimumDrop must be at least 2"); assertContains(belowResult.errors(), "flareRatio must be at least 1.0"); assertContains(belowResult.errors(), "maximumOceanApron must be at least 0"); + assertContains(belowResult.errors(), "inletLength must be at least 0"); + assertContains(belowResult.errors(), "inletDepth must be at least 0"); + assertContains(belowResult.errors(), "mouths.maximumIncision must be at least 0"); + assertContains(longInletResult.errors(), "mouths.inletLength must not exceed routing.minimumSurfaceCourseLength"); + assertTrue(fittingInletResult.errors().toString(), + fittingInletResult.errors().stream().noneMatch(error -> error.contains("inletLength"))); } @Test @@ -1153,6 +1236,58 @@ public class PackHydrologyValidatorTest { assertContains(result.getBlockingErrors(), "tileSize must be divisible by sampleSpacing"); } + @Test + public void rejectsSeaCaveValuesOutsideTheirBoundsAndSpacingBelowTheChamberWidth() throws Exception { + File above = pack(riversDimension(""" + { + "enabled": true, + "grottos": { + "coastal": { + "horizontalRadius": 12, + "seaCaves": {"enabled": true, "maximumPerTile": 65, "minimumSpacing": 8193, "minimumCoastHeight": 129, "depth": 129} + } + } + } + """)); + File below = pack(riversDimension(""" + { + "enabled": true, + "grottos": { + "coastal": { + "horizontalRadius": 12, + "seaCaves": {"enabled": "yes", "maximumPerTile": -1, "minimumSpacing": 20, "minimumCoastHeight": 0, "depth": -1} + } + } + } + """)); + File canonical = pack(riversDimension(""" + { + "enabled": true, + "grottos": { + "coastal": { + "horizontalRadius": 12, + "seaCaves": {"enabled": true, "maximumPerTile": 3, "minimumSpacing": 160, "minimumCoastHeight": 8, "depth": 12} + } + } + } + """)); + + PackHydrologyValidator.Validation aboveResult = validate(above); + PackHydrologyValidator.Validation belowResult = validate(below); + PackHydrologyValidator.Validation canonicalResult = validate(canonical); + + assertContains(aboveResult.errors(), "seaCaves.maximumPerTile must be at most 64"); + assertContains(aboveResult.errors(), "seaCaves.minimumSpacing must be at most 8192"); + assertContains(aboveResult.errors(), "seaCaves.minimumCoastHeight must be at most 128"); + assertContains(aboveResult.errors(), "seaCaves.depth must be at most 128"); + assertContains(belowResult.errors(), "seaCaves.enabled must be a boolean"); + assertContains(belowResult.errors(), "seaCaves.maximumPerTile must be at least 0"); + assertContains(belowResult.errors(), "seaCaves.minimumSpacing must be at least twice"); + assertContains(belowResult.errors(), "seaCaves.minimumCoastHeight must be at least 1"); + assertContains(belowResult.errors(), "seaCaves.depth must be at least 0"); + assertTrue(canonicalResult.errors().toString(), canonicalResult.errors().isEmpty()); + } + private PackHydrologyValidator.Validation validate(File pack) { File[] dimensions = new File(pack, "dimensions").listFiles( file -> file.isFile() && file.getName().endsWith(".json")); @@ -1258,7 +1393,6 @@ public class PackHydrologyValidatorTest { "roughnessWavelength": 16 }, "banks": { - "freeboard": 1, "shoreWidth": 1.5, "blendSlope": 3, "minimumBlendWidth": 4, @@ -1291,7 +1425,14 @@ public class PackHydrologyValidatorTest { "horizontalRadius": 12, "verticalRadius": 7, "headroom": 4, - "maximumVolume": 8192 + "maximumVolume": 8192, + "seaCaves": { + "enabled": true, + "maximumPerTile": 3, + "minimumSpacing": 160, + "minimumCoastHeight": 8, + "depth": 12 + } }, "inland": { "enabled": true, diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java index 0c3d24228..dfd991003 100644 --- a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java @@ -112,13 +112,25 @@ public class IrisRiverSchemaTest { assertEquals(32, drops.getJSONObject("maximumBasinDepth").getInt("maximum")); assertSnippetBackedObjectReference(channel.getJSONObject("width")); assertSnippetBackedObjectReference(channel.getJSONObject("depth")); - assertEquals(0, channel.getJSONObject("inset").getInt("minimum")); - assertEquals(3, channel.getJSONObject("inset").getInt("maximum")); + assertEquals(0, channel.getJSONObject("sink").getInt("minimum")); + assertEquals(3, channel.getJSONObject("sink").getInt("maximum")); + assertFalse(channel.has("inset")); assertEquals(32, channel.getJSONObject("maximumIncision").getInt("maximum")); assertEquals(1D, channel.getJSONObject("roughness").getDouble("maximum"), 0D); assertEquals(64, channel.getJSONObject("roughnessWavelength").getInt("maximum")); - assertEquals(0, banks.getJSONObject("freeboard").getInt("minimum")); - assertEquals(4, banks.getJSONObject("freeboard").getInt("maximum")); + assertFalse(banks.has("freeboard")); + JSONObject erosion = referencedProperties(definitions, surface.getJSONObject("erosion")); + assertEquals("boolean", erosion.getJSONObject("enabled").getString("type")); + assertEquals(64, erosion.getJSONObject("smoothingRadius").getInt("maximum")); + assertEquals(0.95D, erosion.getJSONObject("thalwegFraction").getDouble("maximum"), 0D); + assertEquals(0.25D, erosion.getJSONObject("blendCurve").getDouble("minimum"), 0D); + assertEquals(2D, erosion.getJSONObject("bedNoise").getDouble("maximum"), 0D); + JSONObject ponds = referencedProperties(definitions, surface.getJSONObject("ponds")); + JSONObject sourcePond = referencedProperties(definitions, ponds.getJSONObject("source")); + JSONObject terminalPond = referencedProperties(definitions, ponds.getJSONObject("terminal")); + assertEquals("boolean", sourcePond.getJSONObject("enabled").getString("type")); + assertEquals(32, sourcePond.getJSONObject("maximumRadius").getInt("maximum")); + assertEquals(8, terminalPond.getJSONObject("depth").getInt("maximum")); assertEquals(6D, banks.getJSONObject("shoreWidth").getDouble("maximum"), 0D); assertEquals(0.5D, banks.getJSONObject("blendSlope").getDouble("minimum"), 0D); assertEquals(12D, banks.getJSONObject("blendSlope").getDouble("maximum"), 0D); @@ -186,6 +198,9 @@ public class IrisRiverSchemaTest { assertEquals("array", properties.getJSONObject("bankBiomes").getString("type")); assertEquals("array", properties.getJSONObject("floodedCaveBiomes").getString("type")); assertEquals(16D, properties.getJSONObject("widthMultiplier").getDouble("maximum"), 0D); + assertEquals(32D, properties.getJSONObject("shoreBiomeWidth").getDouble("maximum"), 0D); + assertEquals(0D, properties.getJSONObject("shoreBiomeWidth").getDouble("minimum"), 0D); + assertEquals("boolean", properties.getJSONObject("confined").getString("type")); assertEquals(0D, properties.getJSONObject("incisionMultiplier").getDouble("minimum"), 0D); assertEquals(64D, properties.getJSONObject("routingMultiplier").getDouble("maximum"), 0D); assertEquals("#/definitions/erzbiomes", diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java index 75031c863..9a4b1345e 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java @@ -6,6 +6,7 @@ import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCandidate; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlannerSettings; import org.junit.Test; @@ -1067,6 +1068,158 @@ public class HydrologyCaveCourseFilterTest { assertEquals(1, diagnostics.size()); } + @Test + public void seaCaveIsContainedWithItsSeaFaceAsTheOnlyOpening() { + HydrologyTerrainSampler coast = (int x, int z) -> { + if (x >= 112) { + return HydrologyTerrainSample.ocean(48, "ocean"); + } + if (z >= 9) { + return HydrologyTerrainSample.openLand(64, 0D, "beach"); + } + if (x >= 110) { + return HydrologyTerrainSample.openLand(74, 0D, "face"); + } + if (x >= 104) { + return HydrologyTerrainSample.openLand(66, 0D, "low"); + } + return HydrologyTerrainSample.openLand(92, 0D, "cliff"); + }; + HydraulicSegment chamber = new HydraulicSegment( + 921L, + 920L, + HydrologyFeatureType.COASTAL_GROTTO, + 63, + 63, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0)) + ); + RiverCourse seaCave = new RiverCourse( + 920L, + RiverCourseType.SEA_CAVE, + OptionalLong.empty(), + OptionalLong.empty(), + "water", + 1, + List.of(), + List.of(chamber) + ); + RiverFootprint footprint = new HydrologyFootprintCompiler( + seaCaveSettings(), + coast, + request -> request.minimum() + ).compile(List.of(seaCave)); + HydrologyTerrainCaveVoxelView view = new HydrologyTerrainCaveVoxelView(coast, 63, -64, 320); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter terrainFilter = new HydrologyCaveCourseFilter( + view, + new HydrologyCaveCourseFilter.Options(true, 32768, 32768) + ); + HydrologyCaveCourseFilter.Result result = terrainFilter.filter( + List.of(), + List.of(), + List.of(), + List.of(seaCave), + footprint.columns().values(), + diagnostics + ); + + assertEquals("diagnostics=" + diagnostics, List.of(seaCave), result.courses()); + assertTrue(diagnostics.isEmpty()); + assertEquals(1, result.cavePlans().size()); + HydrologyCavePlan plan = result.cavePlans().getFirst(); + assertTrue(plan.accepted()); + assertEquals(920L, plan.source().sourceId()); + AtomicInteger seaOpenings = new AtomicInteger(); + plan.forEachPrecondition((CavePosition position, CaveVoxelPrecondition precondition) -> { + if (position.x() >= 112 + && position.y() > 48 + && position.y() <= 63 + && precondition.voxel() == CaveVoxel.COMPATIBLE_FLUID) { + seaOpenings.incrementAndGet(); + } + }); + assertTrue(seaOpenings.get() > 0); + plan.forEachAction((CavePosition position, HydrologyCaveAction action) -> { + int naturalHeight = coast.sample(position.x(), position.z()).naturalHeight(); + assertTrue( + "carve above the surface at " + position + " " + action, + action == HydrologyCaveAction.SEAL_GUARD ? position.y() <= naturalHeight : position.y() < naturalHeight + ); + }); + + ArrayList sealed = new ArrayList<>(); + for (HydrologyColumnSample sample : footprint.columns().values()) { + List kept = sample.layers().stream() + .filter((HydrologyColumnLayer layer) -> !layer.oceanApron()) + .toList(); + if (!kept.isEmpty()) { + sealed.add(new HydrologyColumnSample( + sample.x(), + sample.z(), + sample.naturalHeight(), + sample.seaLevel(), + sample.ocean(), + sample.parentBiomeKey(), + kept + )); + } + } + ArrayList sealedDiagnostics = new ArrayList<>(); + HydrologyCaveCourseFilter.Result control = terrainFilter.filter( + List.of(), + List.of(), + List.of(), + List.of(seaCave), + sealed, + sealedDiagnostics + ); + + assertTrue(control.courses().isEmpty()); + assertTrue(control.cavePlans().isEmpty()); + assertEquals(1, sealedDiagnostics.size()); + assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, sealedDiagnostics.getFirst().rejection()); + } + + private static HydrologyPlannerSettings seaCaveSettings() { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Outlets outlets = new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(true, 12, 7, 10, 32768), + base.outlets().inlandGrotto(), + base.outlets().surfaceSinkholesEnabled(), + base.outlets().coastalCliffMinimumHeight(), + base.outlets().mouthLevelingDistance(), + 8, + base.outlets().maximumPerTile(), + base.outlets().maximumPerTile() + ); + HydrologyPlannerSettings.Geometry geometry = new HydrologyPlannerSettings.Geometry( + base.geometry().meanders(), + base.geometry().surface(), + base.geometry().underground(), + new HydrologyPlannerSettings.ChannelShape(2.4D, 0D, 0D, 11), + base.geometry().drops() + ); + return new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + base.surface(), + base.hydraulics(), + base.underground(), + outlets, + geometry, + base.deepFluids(), + base.surfacePools(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() + ); + } + private RiverCourse course(HydraulicSegment segment) { return new RiverCourse( segment.courseId(), diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java index 5da3bd5d9..fe535efc4 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java @@ -20,10 +20,10 @@ public class HydrologyFootprintCompilerTest { /** * Published dry cells cardinally adjacent to owned water whose final terrain sits below the - * water head plus the configured freeboard. + * water head plus the configured sink. */ private static int unsupportedBankCells(RiverFootprint footprint, long courseId) { - int freeboard = HydrologyPlannerSettings.defaults().surface().banks().freeboard(); + int sink = HydrologyPlannerSettings.defaults().surface().banks().sink(); int unsupported = 0; for (HydrologyColumnSample sample : footprint.columns().values()) { HydrologyColumnLayer fluid = sample.primarySurfaceFluidLayer().orElse(null); @@ -40,7 +40,7 @@ public class HydrologyFootprintCompilerTest { if (neighbor == null || neighbor.primarySurfaceFluidLayer().isPresent()) { continue; } - if (neighbor.terrainHeight() < fluid.fluidHeadY() + freeboard) { + if (neighbor.terrainHeight() < fluid.fluidHeadY() + sink) { unsupported++; } } @@ -213,7 +213,7 @@ public class HydrologyFootprintCompilerTest { } @Test - public void surfaceChannelAtTerrainLevelHasNoContainingBank() { + public void surfaceChannelAtTerrainLevelIsHeldByTheGroundBesideIt() { HydraulicSegment segment = new HydraulicSegment( 15L, 14L, @@ -231,7 +231,7 @@ public class HydrologyFootprintCompilerTest { HydrologyFootprintCompiler compiler = compiler(terrain); RiverFootprint footprint = compiler.compile(List.of(course)); - assertTrue(unsupportedBankCells(footprint, 14L) > 0); + assertEquals(0, unsupportedBankCells(footprint, 14L)); for (HydrologyColumnSample sample : footprint.columns().values()) { assertTrue(sample.terrainHeight() <= sample.naturalHeight()); } @@ -268,7 +268,9 @@ public class HydrologyFootprintCompilerTest { base.underground(), base.outlets(), HydrologyPlannerSettings.Geometry.defaults(), - List.of(deepFluid), List.of() + List.of(deepFluid), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); HydraulicSegment segment = new HydraulicSegment( 15L, @@ -717,7 +719,7 @@ public class HydrologyFootprintCompilerTest { 2, false, false, - List.of(new HydrologyPoint(32, 40, 0)) + List.of(new HydrologyPoint(96, 40, 0)) ) ); HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); @@ -1434,13 +1436,13 @@ public class HydrologyFootprintCompilerTest { if (neighbor.primarySurfaceFluidLayer().isPresent()) { continue; } - assertTrue(neighbor.terrainHeight() > fluid.fluidHeadY()); + assertTrue(neighbor.terrainHeight() >= fluid.fluidHeadY()); } } } @Test - public void unsupportedWetEdgeIsReportedWithoutRaisingItsNaturalBank() { + public void aOneBlockLowBankIsLippedAndADeeperOneIsReportedWithoutRaising() { HydraulicSegment channel = new HydraulicSegment( 529L, 528L, @@ -1464,21 +1466,33 @@ public class HydrologyFootprintCompilerTest { } int lowBankZ = bankZ; HydrologyTerrainSampler lowBankTerrain = (int x, int z) -> HydrologyTerrainSample.openLand( - x == 12 && z == lowBankZ ? 70 : 82, + x == 12 && z == lowBankZ ? 69 : 82, 0D, "parent" ); HydrologyFootprintCompiler lowBankCompiler = compiler(lowBankTerrain); RiverFootprint lowBankFootprint = lowBankCompiler.compile(List.of(course)); HydrologyColumnSample lowBank = lowBankFootprint.sample(12, lowBankZ).orElseThrow(); + HydrologyTerrainSampler deepBankTerrain = (int x, int z) -> HydrologyTerrainSample.openLand( + x == 12 && z == lowBankZ ? 68 : 82, + 0D, + "parent" + ); + RiverFootprint deepBankFootprint = compiler(deepBankTerrain).compile(List.of(course)); + HydrologyColumnSample deepBank = deepBankFootprint.sample(12, lowBankZ).orElseThrow(); assertEquals(0, unsupportedBankCells(highFootprint, 528L)); - assertEquals(70, lowBank.naturalHeight()); + // One block short of the water: a one-block lip holds it and nothing is reported. + assertEquals(69, lowBank.naturalHeight()); assertEquals(70, lowBank.terrainHeight()); - assertTrue(unsupportedBankCells(lowBankFootprint, 528L) > 0); + assertEquals(0, unsupportedBankCells(lowBankFootprint, 528L)); for (HydrologyColumnSample sample : lowBankFootprint.columns().values()) { - assertTrue(sample.terrainHeight() <= sample.naturalHeight()); + assertTrue(sample.terrainHeight() <= sample.naturalHeight() + 1); } + // Two blocks short: the bank keeps its natural height and the edge is reported. + assertEquals(68, deepBank.naturalHeight()); + assertEquals(68, deepBank.terrainHeight()); + assertTrue(unsupportedBankCells(deepBankFootprint, 528L) > 0); } @Test @@ -1731,6 +1745,250 @@ public class HydrologyFootprintCompilerTest { + @Test + public void coastalGrottoApronReachesTheChamberRadius() { + HydrologyTerrainSampler coast = (int x, int z) -> x >= 112 + ? HydrologyTerrainSample.ocean(48, "ocean") + : HydrologyTerrainSample.openLand(80, 0D, "parent"); + RiverCourse seaCave = seaCaveCourse(900L); + RiverFootprint footprint = new HydrologyFootprintCompiler( + seaCaveSettings(8, 12), + coast, + request -> request.minimum() + ).compile(List.of(seaCave)); + RiverFootprint wideApron = new HydrologyFootprintCompiler( + seaCaveSettings(12, 12), + coast, + request -> request.minimum() + ).compile(List.of(seaCave)); + + int chamberColumns = 0; + int apronColumns = 0; + double farthestApron = 0D; + for (HydrologyColumnSample sample : footprint.columns().values()) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.oceanApron()) { + apronColumns++; + assertTrue(sample.ocean()); + farthestApron = Math.max(farthestApron, StrictMath.hypot(sample.x() - 112, sample.z())); + assertTrue( + "apron beyond the chamber at " + sample.x() + "," + sample.z(), + nearestCenterlineDistance(seaCave, sample.x(), sample.z()) <= 12.25D + || touchesChamber(footprint, sample.x(), sample.z()) + ); + continue; + } + if (!chamberLayer(layer)) { + continue; + } + chamberColumns++; + assertFalse(sample.ocean()); + for (int[] cardinal : CARDINALS) { + int neighborX = sample.x() + cardinal[0]; + int neighborZ = sample.z() + cardinal[1]; + if (!coast.sample(neighborX, neighborZ).ocean()) { + continue; + } + assertTrue( + "sea column " + neighborX + "," + neighborZ + " beside the chamber has no apron", + apronAt(footprint, neighborX, neighborZ) + ); + } + } + } + assertTrue(chamberColumns > 0); + assertTrue(apronColumns > 0); + assertTrue("farthest apron " + farthestApron, farthestApron > 8.25D); + assertEquals(wideApron, footprint); + + HydraulicSegment mouth = new HydraulicSegment( + 902L, + 901L, + HydrologyFeatureType.MOUTH, + 63, + 63, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(96, 63, 0), new HydrologyPoint(112, 63, 0)) + ); + RiverFootprint mouthFootprint = new HydrologyFootprintCompiler( + seaCaveSettings(8, 12), + coast, + request -> request.minimum() + ).compile(List.of(course(901L, RiverCourseType.SURFACE, mouth))); + assertTrue(mouthFootprint.sample(112, 0).isPresent()); + assertTrue(mouthFootprint.sample(112 + 8, 0).isEmpty()); + for (HydrologyColumnSample sample : mouthFootprint.columns().values()) { + if (sample.ocean()) { + assertTrue(sample.x() - 112 < 8); + } + } + } + + @Test + public void seaCaveChamberStaysUnderTheNaturalSurface() { + HydrologyTerrainSampler coast = this::cliffCoast; + RiverCourse seaCave = seaCaveCourse(910L); + RiverFootprint footprint = new HydrologyFootprintCompiler( + seaCaveSettings(8, 12), + coast, + request -> request.minimum() + ).compile(List.of(seaCave)); + + int chamberColumns = 0; + for (HydrologyColumnSample sample : footprint.columns().values()) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (!chamberLayer(layer)) { + continue; + } + chamberColumns++; + String where = "chamber at " + sample.x() + "," + sample.z() + " " + layer; + assertFalse(where, sample.ocean()); + assertTrue(where, sample.naturalHeight() != 64); + assertEquals(where, 63, layer.fluidHeadY()); + assertTrue(where, layer.bedY() < 63); + assertTrue(where, layer.ceilingY() >= 64); + assertTrue(where, layer.ceilingY() <= sample.naturalHeight() - 1); + for (int[] cardinal : CARDINALS) { + HydrologyTerrainSample neighbor = coast.sample(sample.x() + cardinal[0], sample.z() + cardinal[1]); + if (neighbor.ocean()) { + continue; + } + assertTrue(where, layer.ceilingY() <= neighbor.naturalHeight() - 1); + } + } + } + assertTrue(chamberColumns > 0); + HydrologyColumnLayer deepRoof = chamberLayerAt(footprint, 100, 0); + assertTrue(deepRoof != null); + assertEquals(73, deepRoof.ceilingY()); + HydrologyColumnLayer lowRoof = chamberLayerAt(footprint, 108, 0); + assertTrue(lowRoof != null); + assertEquals(65, lowRoof.ceilingY()); + HydrologyColumnLayer face = chamberLayerAt(footprint, 111, 0); + assertTrue(face != null); + assertTrue(face.ceilingY() >= 64 && face.ceilingY() <= 73); + assertTrue(chamberLayerAt(footprint, 111, 9) == null); + assertTrue(chamberLayerAt(footprint, 100, 9) == null); + } + + private HydrologyTerrainSample cliffCoast(int x, int z) { + if (x >= 112) { + return HydrologyTerrainSample.ocean(48, "ocean"); + } + if (z >= 9) { + return HydrologyTerrainSample.openLand(64, 0D, "beach"); + } + if (x >= 110) { + return HydrologyTerrainSample.openLand(74, 0D, "face"); + } + if (x >= 104) { + return HydrologyTerrainSample.openLand(66, 0D, "low"); + } + return HydrologyTerrainSample.openLand(92, 0D, "cliff"); + } + + private RiverCourse seaCaveCourse(long id) { + HydraulicSegment chamber = new HydraulicSegment( + id + 1L, + id, + HydrologyFeatureType.COASTAL_GROTTO, + 63, + 63, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0)) + ); + return new RiverCourse( + id, + RiverCourseType.SEA_CAVE, + OptionalLong.empty(), + OptionalLong.empty(), + "water", + 1, + List.of(), + List.of(chamber) + ); + } + + private static HydrologyPlannerSettings seaCaveSettings(int maximumOceanApron, int chamberRadius) { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Outlets outlets = new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(true, chamberRadius, 7, 10, 32768), + base.outlets().inlandGrotto(), + base.outlets().surfaceSinkholesEnabled(), + base.outlets().coastalCliffMinimumHeight(), + base.outlets().mouthLevelingDistance(), + maximumOceanApron, + base.outlets().maximumPerTile(), + base.outlets().maximumPerTile() + ); + HydrologyPlannerSettings.Geometry geometry = new HydrologyPlannerSettings.Geometry( + base.geometry().meanders(), + base.geometry().surface(), + base.geometry().underground(), + new HydrologyPlannerSettings.ChannelShape(2.4D, 0D, 0D, 11), + base.geometry().drops() + ); + return new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + base.surface(), + base.hydraulics(), + base.underground(), + outlets, + geometry, + base.deepFluids(), + base.surfacePools(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() + ); + } + + private static boolean chamberLayer(HydrologyColumnLayer layer) { + return layer.feature().type() == HydrologyFeatureType.COASTAL_GROTTO + && layer.terrainOwned() + && !layer.oceanApron(); + } + + private HydrologyColumnLayer chamberLayerAt(RiverFootprint footprint, int x, int z) { + return footprint.sample(x, z) + .flatMap((HydrologyColumnSample sample) -> sample.layers().stream() + .filter(HydrologyFootprintCompilerTest::chamberLayer) + .findFirst()) + .orElse(null); + } + + private boolean apronAt(RiverFootprint footprint, int x, int z) { + return footprint.sample(x, z) + .map((HydrologyColumnSample sample) -> sample.layers().stream().anyMatch(HydrologyColumnLayer::oceanApron)) + .orElse(false); + } + + private boolean touchesChamber(RiverFootprint footprint, int x, int z) { + for (int[] cardinal : CARDINALS) { + if (chamberLayerAt(footprint, x + cardinal[0], z + cardinal[1]) != null) { + return true; + } + } + return false; + } + + private double nearestCenterlineDistance(RiverCourse course, int x, int z) { + double nearest = Double.MAX_VALUE; + for (HydraulicSegment segment : course.segments()) { + for (HydrologyPoint point : segment.centerline()) { + nearest = Math.min(nearest, StrictMath.hypot(x - point.x(), z - point.z())); + } + } + return nearest; + } + private RiverCourse course(long id, RiverCourseType type, HydraulicSegment segment) { return new RiverCourse( id, diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyForkJoinTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyForkJoinTest.java new file mode 100644 index 000000000..b9179aaec --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyForkJoinTest.java @@ -0,0 +1,109 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyForkJoinTest { + @Test + public void nestedFanOutCompletesOnASaturatedForkJoinPool() throws Exception { + ForkJoinPool pool = new ForkJoinPool(1); + try { + List results = pool.submit(() -> { + List> outer = new ArrayList<>(); + for (int index = 0; index < 3; index++) { + int value = index; + outer.add(() -> { + List> inner = List.of(() -> value * 10, () -> value * 10 + 1); + List nested = HydrologyForkJoin.invokeAll(inner, Runnable::run); + return nested.get(0) + nested.get(1); + }); + } + return HydrologyForkJoin.invokeAll(outer, Runnable::run); + }).get(10, TimeUnit.SECONDS); + + assertEquals(List.of(1, 21, 41), results); + } finally { + pool.shutdownNow(); + } + } + + @Test + public void outsideAPoolTasksRunOnTheFallbackExecutorInOrder() throws Exception { + List threads = Collections.synchronizedList(new ArrayList<>()); + ExecutorService executor = Executors.newFixedThreadPool(2, runnable -> new Thread(runnable, "fallback-executor")); + try { + List> tasks = new ArrayList<>(); + for (int index = 0; index < 4; index++) { + int value = index; + tasks.add(() -> { + threads.add(Thread.currentThread().getName()); + return value; + }); + } + + List results = HydrologyForkJoin.invokeAll(tasks, executor); + + assertEquals(List.of(0, 1, 2, 3), results); + assertEquals(4, threads.size()); + assertTrue(threads.stream().allMatch("fallback-executor"::equals)); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void withoutAFallbackTasksRunOnTheCaller() { + String caller = Thread.currentThread().getName(); + List> tasks = List.of( + () -> Thread.currentThread().getName(), + () -> Thread.currentThread().getName() + ); + + assertEquals(List.of(caller, caller), HydrologyForkJoin.invokeAll(tasks, null)); + } + + @Test + public void aFailingTaskRethrowsItsExceptionOnEveryPath() throws Exception { + List> tasks = List.of(() -> 1, () -> { + throw new IllegalStateException("planning failed"); + }); + + IllegalStateException inline = assertThrows(IllegalStateException.class, () -> HydrologyForkJoin.invokeAll(tasks, null)); + assertEquals("planning failed", inline.getMessage()); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + IllegalStateException pooled = assertThrows(IllegalStateException.class, () -> HydrologyForkJoin.invokeAll(tasks, executor)); + assertEquals("planning failed", pooled.getMessage()); + } finally { + executor.shutdownNow(); + } + + ForkJoinPool pool = new ForkJoinPool(1); + try { + String message = pool.submit(() -> { + try { + HydrologyForkJoin.invokeAll(tasks, Runnable::run); + return "no failure"; + } catch (IllegalStateException failure) { + return failure.getMessage(); + } + }).get(10, TimeUnit.SECONDS); + assertEquals("planning failed", message); + } finally { + pool.shutdownNow(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java index 893d5d091..5030eac5a 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java @@ -185,6 +185,68 @@ public class HydrologyModelTest { )); } + @Test + public void seaCaveCoursesAreIndependent() { + HydraulicSegment chamber = new HydraulicSegment( + 1L, + 21L, + HydrologyFeatureType.COASTAL_GROTTO, + 63, + 63, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(100, 63, 0), new HydrologyPoint(112, 63, 0)) + ); + + RiverCourse seaCave = new RiverCourse( + 21L, + RiverCourseType.SEA_CAVE, + OptionalLong.empty(), + OptionalLong.empty(), + "water", + 1, + List.of(), + List.of(chamber) + ); + + assertEquals(RiverCourseType.SEA_CAVE, seaCave.type()); + assertTrue(seaCave.sourceNodeId().isEmpty()); + assertTrue(seaCave.outletId().isEmpty()); + assertFalse(seaCave.surfaceSinkholeContinuation()); + assertThrows(IllegalArgumentException.class, () -> new RiverCourse( + 21L, + RiverCourseType.SEA_CAVE, + OptionalLong.of(4L), + OptionalLong.empty(), + "water", + 1, + List.of(), + List.of(chamber) + )); + assertThrows(IllegalArgumentException.class, () -> new RiverCourse( + 21L, + RiverCourseType.SEA_CAVE, + OptionalLong.empty(), + OptionalLong.of(5L), + "water", + 1, + List.of(), + List.of(chamber) + )); + assertThrows(IllegalArgumentException.class, () -> new RiverCourse( + 21L, + RiverCourseType.SEA_CAVE, + OptionalLong.empty(), + OptionalLong.empty(), + "water", + 1, + List.of(), + List.of() + )); + } + @Test public void oceanColumnsRejectOwnedOrElevatedWrites() { HydrologyFeatureRef feature = feature(HydrologyFeatureType.MOUTH, 7L, 63); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerConfinementTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerConfinementTest.java new file mode 100644 index 000000000..339fa7435 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerConfinementTest.java @@ -0,0 +1,132 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * A 256-block tile whose western half is one region and whose eastern half is another. The only sea is + * along the south edge of the eastern half, plus an optional pocket of sea in the south-western corner; + * terrain falls to the south and slightly to the east, so rivers born in the west naturally want to + * cross into the east. + */ +public class HydrologyPlannerConfinementTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + private static final int BOUNDARY = 128; + private static final String WEST = "region:west"; + + @Test + public void unconfinedWestSourcesMayDrainAcrossTheRegionBoundary() { + HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(false, true)).plan(TILE); + + List westCourses = westCourses(tile); + assertFalse("diagnostics=" + tile.diagnosticCandidates(), westCourses.isEmpty()); + assertTrue("no western course crossed into the east: " + describe(westCourses), + westCourses.stream().anyMatch(course -> maximumX(course) >= BOUNDARY)); + } + + @Test + public void confinedWestSourcesStayWestAllTheWayToTheirOutlet() { + HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(true, true)).plan(TILE); + + List westCourses = westCourses(tile); + assertFalse("diagnostics=" + tile.diagnosticCandidates(), westCourses.isEmpty()); + for (RiverCourse course : westCourses) { + assertTrue("western course left its region: " + describe(List.of(course)), maximumX(course) < BOUNDARY); + } + } + + @Test + public void confinedWestSourcesWithoutAnOutletInsideAreRejectedAndSayWhy() { + HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(true, false)).plan(TILE); + + assertTrue(describe(westCourses(tile)), westCourses(tile).isEmpty()); + assertTrue("diagnostics=" + tile.diagnosticCandidates(), tile.diagnosticCandidates().stream() + .anyMatch(candidate -> candidate.rejection() == HydrologyCandidateRejection.CONFINED_NO_OUTLET)); + } + + private static List westCourses(HydrologyTile tile) { + ArrayList courses = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.SURFACE && sourceX(course) < BOUNDARY) { + courses.add(course); + } + } + return courses; + } + + private static int sourceX(RiverCourse course) { + return course.segments().getFirst().centerline().getFirst().x(); + } + + private static int maximumX(RiverCourse course) { + int maximum = Integer.MIN_VALUE; + for (HydraulicSegment segment : course.segments()) { + for (HydrologyPoint point : segment.centerline()) { + maximum = Math.max(maximum, point.x()); + } + } + return maximum; + } + + private static String describe(List courses) { + StringBuilder out = new StringBuilder(); + for (RiverCourse course : courses) { + out.append(" course ").append(course.id()).append(" source x=").append(sourceX(course)) + .append(" max x=").append(maximumX(course)); + } + return out.toString(); + } + + private static HydrologyTerrainSampler terrain(boolean confineWest, boolean westPocket) { + return (int x, int z) -> { + String confines = confineWest && x < BOUNDARY ? WEST : null; + boolean eastSea = x >= BOUNDARY && z < 24; + boolean pocket = westPocket && x < 24 && z < 24; + if (eastSea || pocket) { + return sample(60, true, confines); + } + int height = (int) StrictMath.round(66D + z * 0.3D - x * 0.05D); + return sample(height, false, confines); + }; + } + + private static HydrologyTerrainSample sample(int height, boolean ocean, String confines) { + return new HydrologyTerrainSample( + height, 1D, ocean, false, height - 32, height - 30, + !ocean, !ocean, !ocean, false, false, false, + 0D, ocean ? 0D : 1D, 0D, 1D, 1D, 1D, 1D, 1D, + "land", "land", "land", "land", "land", "land", + List.of("default"), List.of(), Double.NaN, confines + ); + } + + private static HydrologyPlannerSettings settings() { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source(true, 4D, 80, 1, 6, 32); + HydrologyPlannerSettings.Source undergroundSources = new HydrologyPlannerSettings.Source(false, 0D, Integer.MIN_VALUE, 0, 1, 48); + HydrologyPlannerSettings.ChannelShape stableChannel = new HydrologyPlannerSettings.ChannelShape(2D, 0D, 0D, 11); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing(256, 16, 1_089, 256, 16, 8, 0.5D, 12D, 0.5D, 0.2D, 1D, 0), + new HydrologyPlannerSettings.Surface(true, surfaceSources, 4, 12, 2, 4, 12, 1.5D, HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(4), + new HydrologyPlannerSettings.Underground(false, undergroundSources, 68, 82, 4, 14, 2, 5, 6, 12, false, 0), + new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + false, 12, 48, 2, 8, 8), + new HydrologyPlannerSettings.Geometry( + new HydrologyPlannerSettings.Meanders(224, 72, 0D, 0D, 0D, 0, 75D), + stableChannel, stableChannel, stableChannel, + HydrologyPlannerSettings.Geometry.defaults().drops()), + List.of(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java index 86fe516f5..987e5b366 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java @@ -5,7 +5,10 @@ import art.arcane.iris.engine.hydrology.cave.CaveVoxel; import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveConflictPolicy; +import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; import org.junit.Test; import java.util.ArrayList; @@ -17,6 +20,7 @@ import java.util.Map; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { private static final long SEED = 77L; @@ -65,16 +69,46 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { assertEquals(forward.get(pair.first()).diagnosticCandidates().toString(), 1, firstSources.size()); assertEquals(forward.get(pair.second()).diagnosticCandidates().toString(), 1, secondSources.size()); assertPointNear(new HydrologyPoint(32, 121, -272), firstSources.getFirst(), 8); - assertPointNear(new HydrologyPoint(16, 126, -192), secondSources.getFirst(), 8); + // The second tile keeps the mouth on its own coast, so its heaviest required source no longer + // competes with the first tile's network and is accepted ahead of the lighter fallback source. + assertPointNear(new HydrologyPoint(16, 126, -224), secondSources.getFirst(), 8); assertNoIncompatibleCaveOverlaps(pair, forward.get(pair.first()), forward.get(pair.second())); } + @Test + public void boundPlatformResolvesNeighbourOwnersInParallelToTheSameTiles() { + List corner = List.of( + new HydrologyTileKey(-1, -1), + new HydrologyTileKey(0, -1), + new HydrologyTileKey(-1, 0), + new HydrologyTileKey(0, 0) + ); + Map lazy = plans(planner(), corner); + + IrisSettings previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + IrisPlatforms.unbind(); + IrisPlatforms.bind(mock(IrisPlatform.class)); + Map parallel; + try { + parallel = plans(planner(), corner); + } finally { + IrisPlatforms.unbind(); + IrisSettings.settings = previousSettings; + } + + assertEquals(lazy, parallel); + } + @Test public void colorRankBoundsTheCompleteOwnerDependencyGeometry() { HydrologyPlanner planner = planner(); assertEquals(1, planner.maximumCrossTileDependencyOwners(new HydrologyTileKey(0, 0))); - assertEquals(49, planner.maximumCrossTileDependencyOwners(new HydrologyTileKey(-1, -1))); + // The mouth flare reaches maximumWidth * flareRatio / 2 beside the coast, which pushes these + // 256-block tiles to a three-colour period: rank 8 at (-1, -1), two tiles of offset per rank. + assertEquals(3, settings().crossTileColorPeriod()); + assertEquals(1089, planner.maximumCrossTileDependencyOwners(new HydrologyTileKey(-1, -1))); } @Test @@ -275,7 +309,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { 5, 6, 12, - true + true, + 0 ), new HydrologyPlannerSettings.Outlets( true, @@ -285,10 +320,13 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { 12, 48, 2, + 8, 8 ), stableGeometry(), - List.of(deepFluid), List.of() + List.of(deepFluid), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -349,7 +387,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { baseUnderground.maximumDepth(), baseUnderground.minimumHeadroom(), baseUnderground.maximumHeadroom(), - baseUnderground.connectToExistingCaves() + baseUnderground.connectToExistingCaves(), + 0 ), new HydrologyPlannerSettings.Outlets( base.outlets().oceanEnabled(), @@ -359,10 +398,13 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { base.outlets().coastalCliffMinimumHeight(), base.outlets().mouthLevelingDistance(), base.outlets().maximumOceanApron(), + base.outlets().maximumPerTile(), base.outlets().maximumPerTile() ), base.geometry(), - base.deepFluids(), List.of() + base.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -404,7 +446,9 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { "shore", "dry", "flooded", - List.of("water"), List.of() + List.of("water"), List.of(), + Double.NaN, + null ); }; } @@ -445,7 +489,9 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { sample.shoreBiomeKey(), sample.bankBiomeKey(), sample.floodedCaveBiomeKey(), - sample.preferredProfileKeys(), List.of() + sample.preferredProfileKeys(), List.of(), + Double.NaN, + null ); }; } @@ -478,7 +524,9 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { "shore", "dry", "flooded", - List.of("water"), List.of() + List.of("water"), List.of(), + Double.NaN, + null ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java index 5e12fe090..8d930daa3 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java @@ -667,8 +667,10 @@ public class HydrologyPlannerGlobalInvariantTest { 0.5D, 12D, 0.5D, - 0.1D - , 1D, 0), + 0.1D, + 1D, + 0 + ), new HydrologyPlannerSettings.Surface( surfaceEnabled, surfaceSources, @@ -691,7 +693,8 @@ public class HydrologyPlannerGlobalInvariantTest { 2, 2, 4, - false + false, + 0 ), new HydrologyPlannerSettings.Outlets( true, @@ -701,10 +704,13 @@ public class HydrologyPlannerGlobalInvariantTest { 32, 16, 1, + 8, 8 ), HydrologyPlannerSettings.Geometry.defaults(), - deepFluids, List.of() + deepFluids, List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -748,11 +754,14 @@ public class HydrologyPlannerGlobalInvariantTest { underground.maximumDepth(), underground.minimumHeadroom(), underground.maximumHeadroom(), - underground.connectToExistingCaves() + underground.connectToExistingCaves(), + 0 ), base.outlets(), HydrologyPlannerSettings.Geometry.defaults(), - base.deepFluids(), List.of() + base.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -945,7 +954,9 @@ public class HydrologyPlannerGlobalInvariantTest { "shore", "dry", "flooded", - List.of("default"), List.of() + List.of("default"), List.of(), + Double.NaN, + null ); } @@ -996,7 +1007,9 @@ public class HydrologyPlannerGlobalInvariantTest { "shore", "dry", "flooded", - List.of("default"), List.of() + List.of("default"), List.of(), + Double.NaN, + null ); } @@ -1045,7 +1058,9 @@ public class HydrologyPlannerGlobalInvariantTest { "shore", "dry", "flooded", - List.of("default"), List.of() + List.of("default"), List.of(), + Double.NaN, + null ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSeaCaveTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSeaCaveTest.java new file mode 100644 index 000000000..4c80c8417 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSeaCaveTest.java @@ -0,0 +1,331 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class HydrologyPlannerSeaCaveTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + private static final int SEA = 63; + private static final HydrologyPlannerSettings.Grotto CHAMBER = new HydrologyPlannerSettings.Grotto(true, 12, 7, 10, 32768); + + @Test + public void seaCavesOpenFromTheSeaIntoTheCliffWithoutARiver() { + HydrologyPlannerSettings.SeaCaves seaCaves = new HydrologyPlannerSettings.SeaCaves(true, 2, 64, 8, 12); + HydrologyPlannerSettings settings = settings(128, 0D, CHAMBER, 12, seaCaves); + HydrologyTerrainSampler cliffCoast = cliffCoast(112, 92); + + HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast).plan(TILE); + List caves = courses(tile, RiverCourseType.SEA_CAVE); + + assertFalse("diagnostics=" + tile.diagnosticCandidates(), caves.isEmpty()); + for (RiverCourse cave : caves) { + assertEquals(1, cave.segments().size()); + HydraulicSegment chamber = cave.segments().getFirst(); + assertEquals(HydrologyFeatureType.COASTAL_GROTTO, chamber.type()); + assertEquals(SEA, chamber.upstreamHeadY()); + assertEquals(SEA, chamber.downstreamHeadY()); + HydrologyPoint inner = chamber.start(); + HydrologyPoint connection = chamber.end(); + assertTrue(cliffCoast.sample(connection.x(), connection.z()).ocean()); + assertFalse(cliffCoast.sample(inner.x(), inner.z()).ocean()); + assertTrue("inner=" + inner + " connection=" + connection, + inner.distanceSquared2D(connection) >= (long) seaCaves.depth() * seaCaves.depth()); + HydrologyCavePlan plan = tile.cavePlan(cave.id()).orElse(null); + assertTrue("cave plan missing or rejected for " + cave.id() + " diagnostics=" + tile.diagnosticCandidates(), + plan != null && plan.accepted()); + + int chamberColumns = 0; + int apronColumns = 0; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().courseId() != cave.id()) { + continue; + } + assertEquals(HydrologyFeatureType.COASTAL_GROTTO, layer.feature().type()); + if (layer.oceanApron()) { + apronColumns++; + assertTrue(cliffCoast.sample(column.x(), column.z()).ocean()); + assertTrue(StrictMath.hypot(column.x() - connection.x(), column.z() - connection.z()) <= 12.25D); + continue; + } + if (!layer.terrainOwned() || !layer.channel()) { + continue; + } + chamberColumns++; + assertFalse(cliffCoast.sample(column.x(), column.z()).ocean()); + assertTrue(layer.bedY() <= SEA); + assertEquals(SEA, layer.fluidHeadY()); + assertTrue(layer.ceilingY() >= SEA); + assertTrue(layer.ceilingY() <= column.naturalHeight() - 1); + } + } + assertTrue("chamber=" + chamberColumns, chamberColumns >= 100); + assertTrue("apron=" + apronColumns, apronColumns > 0); + HydrologyColumnLayer center = tile.columnAt(inner.x(), inner.z()).orElseThrow().layers().stream() + .filter((HydrologyColumnLayer layer) -> layer.feature().courseId() == cave.id() && layer.channel()) + .findFirst() + .orElseThrow(); + // The chamber walls carry the grotto roughness, so the centre sits within a few blocks of the ellipsoid. + assertTrue("bed=" + center.bedY(), center.bedY() <= SEA - CHAMBER.verticalRadius() + 3 + && center.bedY() >= SEA - CHAMBER.verticalRadius() - 3); + assertTrue("ceiling=" + center.ceilingY(), center.ceilingY() <= SEA + CHAMBER.headroom() + && center.ceilingY() >= SEA + CHAMBER.headroom() - 3); + } + boolean featured = false; + for (HydrologyFeatureRef feature : tile.features()) { + featured |= feature.type() == HydrologyFeatureType.COASTAL_GROTTO; + } + assertTrue(featured); + + HydrologyTile withoutCaves = new HydrologyPlanner( + 994L, + settings(128, 0D, CHAMBER, 12, HydrologyPlannerSettings.SeaCaves.disabled()), + cliffCoast + ).plan(TILE); + assertTrue(courses(withoutCaves, RiverCourseType.SEA_CAVE).isEmpty()); + assertEquals(withoutCaves.outlets(), tile.outlets()); + } + + @Test + public void seaCavesAreDeterministicAndOwnedByExactlyOneTile() { + HydrologyPlannerSettings settings = settings(128, 0D, CHAMBER, 12, + new HydrologyPlannerSettings.SeaCaves(true, 2, 64, 8, 12)); + HydrologyTerrainSampler cliffCoast = cliffCoast(112, 92); + List keys = List.of( + TILE, + new HydrologyTileKey(0, -1), + new HydrologyTileKey(0, 1), + new HydrologyTileKey(-1, 0), + new HydrologyTileKey(1, 0) + ); + HydrologyPlanner forward = new HydrologyPlanner(41L, settings, cliffCoast); + HydrologyPlanner reversed = new HydrologyPlanner(41L, settings, cliffCoast); + List forwardTiles = new ArrayList<>(); + for (HydrologyTileKey key : keys) { + forwardTiles.add(forward.plan(key)); + } + List reversedTiles = new ArrayList<>(); + for (HydrologyTileKey key : keys.reversed()) { + reversedTiles.add(reversed.plan(key)); + } + + for (int index = 0; index < keys.size(); index++) { + assertEquals(keys.get(index).toString(), forwardTiles.get(index), reversedTiles.get(keys.size() - 1 - index)); + } + List caves = courses(forwardTiles.getFirst(), RiverCourseType.SEA_CAVE); + assertFalse(caves.isEmpty()); + Set ownedIds = new HashSet<>(); + for (RiverCourse cave : caves) { + ownedIds.add(cave.id()); + } + for (int index = 1; index < keys.size(); index++) { + for (RiverCourse course : forwardTiles.get(index).courses()) { + assertFalse(keys.get(index) + " republished " + course.id(), ownedIds.contains(course.id())); + } + } + } + + @Test + public void seaCavesPreferTheSteeperCoastAndKeepTheirSpacing() { + // The high stretch is 128 blocks of coast; the spacing leaves room for a second cave wherever the first lands. + HydrologyPlannerSettings.SeaCaves seaCaves = new HydrologyPlannerSettings.SeaCaves(true, 2, 32, 4, 12); + HydrologyPlannerSettings settings = settings(256, 0D, CHAMBER, 12, seaCaves); + HydrologyTerrainSampler coast = (int x, int z) -> x >= 240 + ? HydrologyTerrainSample.ocean(54, "ocean") + : HydrologyTerrainSample.openLand(Math.floorDiv(z, 128) % 2 == 0 ? 70 : 90, 2D, "land"); + + HydrologyTile tile = new HydrologyPlanner(17L, settings, coast).plan(TILE); + List caves = courses(tile, RiverCourseType.SEA_CAVE); + + assertEquals("diagnostics=" + tile.diagnosticCandidates(), 2, caves.size()); + for (RiverCourse cave : caves) { + HydrologyPoint connection = cave.segments().getFirst().end(); + assertTrue("connection=" + connection + " diagnostics=" + tile.diagnosticCandidates(), + connection.z() >= 128 && connection.z() < 256); + } + HydrologyPoint first = caves.get(0).segments().getFirst().end(); + HydrologyPoint second = caves.get(1).segments().getFirst().end(); + long minimum = seaCaves.minimumSpacing() - 2L; + assertTrue(first + " vs " + second, first.distanceSquared2D(second) >= minimum * minimum); + int lowCoastRejections = 0; + for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + if (candidate.kind() != HydrologyCandidateKind.OUTLET + || candidate.projectedType() != HydrologyFeatureType.COASTAL_GROTTO + || candidate.point().z() < 0 || candidate.point().z() >= 128) { + continue; + } + lowCoastRejections++; + assertEquals(candidate.toString(), HydrologyCandidateRejection.SOURCE_QUOTA, candidate.rejection()); + } + assertTrue(lowCoastRejections > 0); + } + + @Test + public void seaCavesKeepClearOfRiverMouthsAndLowCoasts() { + HydrologyPlannerSettings settings = settings(128, 2D, CHAMBER, 12, + new HydrologyPlannerSettings.SeaCaves(true, 4, 32, 8, 12)); + HydrologyTerrainSampler riverCoast = (int x, int z) -> x >= 112 + ? HydrologyTerrainSample.ocean(54, "ocean") + : land(92, x <= 16); + + HydrologyTile tile = new HydrologyPlanner(994L, settings, riverCoast).plan(TILE); + List outletPoints = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.SEA_CAVE) { + continue; + } + for (HydraulicSegment segment : course.segments()) { + if (segment.type() == HydrologyFeatureType.MOUTH + || segment.type() == HydrologyFeatureType.COASTAL_GROTTO + || segment.type() == HydrologyFeatureType.INLAND_GROTTO) { + outletPoints.addAll(segment.centerline()); + } + } + } + List caves = courses(tile, RiverCourseType.SEA_CAVE); + + assertFalse("diagnostics=" + tile.diagnosticCandidates(), outletPoints.isEmpty()); + assertFalse("diagnostics=" + tile.diagnosticCandidates(), caves.isEmpty()); + long clearance = 2L * CHAMBER.horizontalRadius() + 12L; + for (RiverCourse cave : caves) { + for (HydrologyPoint point : cave.segments().getFirst().centerline()) { + for (HydrologyPoint outlet : outletPoints) { + assertTrue(point + " within " + clearance + " of " + outlet, + point.distanceSquared2D(outlet) >= clearance * clearance); + } + } + } + + HydrologyTile lowCoast = new HydrologyPlanner( + 994L, + settings(128, 0D, CHAMBER, 12, new HydrologyPlannerSettings.SeaCaves(true, 4, 32, 8, 12)), + cliffCoast(112, 66) + ).plan(TILE); + assertTrue(courses(lowCoast, RiverCourseType.SEA_CAVE).isEmpty()); + assertTrue(lowCoast.diagnosticCandidates().toString(), + hasDiagnostic(lowCoast, HydrologyCandidateRejection.SURFACE_HEAD_RANGE)); + } + + @Test + public void seaCaveChamberFitsTheVolumeCap() { + HydrologyPlannerSettings settings = settings(128, 0D, + new HydrologyPlannerSettings.Grotto(true, 12, 7, 10, 1000), 12, + new HydrologyPlannerSettings.SeaCaves(true, 2, 64, 8, 12)); + + HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast(112, 92)).plan(TILE); + + assertTrue(courses(tile, RiverCourseType.SEA_CAVE).isEmpty()); + assertTrue(tile.diagnosticCandidates().toString(), + hasDiagnostic(tile, HydrologyCandidateRejection.VOLUME_LIMIT)); + } + + private static boolean hasDiagnostic(HydrologyTile tile, HydrologyCandidateRejection rejection) { + for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + if (candidate.kind() == HydrologyCandidateKind.OUTLET + && candidate.projectedType() == HydrologyFeatureType.COASTAL_GROTTO + && candidate.rejection() == rejection) { + return true; + } + } + return false; + } + + private static List courses(HydrologyTile tile, RiverCourseType type) { + ArrayList selected = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == type) { + selected.add(course); + } + } + return selected; + } + + private static HydrologyTerrainSampler cliffCoast(int coastX, int height) { + return (int x, int z) -> x >= coastX + ? HydrologyTerrainSample.ocean(54, "ocean") + : HydrologyTerrainSample.openLand(height, 2D, "land"); + } + + private static HydrologyTerrainSample land(int height, boolean surfaceSource) { + return new HydrologyTerrainSample( + height, + 2D, + false, + true, + 72, + 74, + true, + true, + surfaceSource, + surfaceSource, + false, + false, + 0D, + surfaceSource ? 1D : 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("beta", "alpha"), + List.of(), + Double.NaN, + null + ); + } + + private static HydrologyPlannerSettings settings( + int tileSize, + double surfaceDensity, + HydrologyPlannerSettings.Grotto coastal, + int maximumOceanApron, + HydrologyPlannerSettings.SeaCaves seaCaves + ) { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, surfaceDensity, 80, 0, 6, 24); + HydrologyPlannerSettings.Source undergroundSources = new HydrologyPlannerSettings.Source( + true, 0D, Integer.MIN_VALUE, 0, 4, 32); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + true, surfaceSources, 4, 18, 2, 4, 10, 1.5D, HydrologyPlannerSettings.Banks.defaults()); + return new HydrologyPlannerSettings( + SEA, + new HydrologyPlannerSettings.Routing(tileSize, 16, 512, 256, 0, 0, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), + surface, + new HydrologyPlannerSettings.Hydraulics(4), + new HydrologyPlannerSettings.Underground( + false, undergroundSources, 68, 82, 4, 12, 2, 4, 5, 9, true, 0), + new HydrologyPlannerSettings.Outlets( + true, + coastal, + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + false, + 12, + 32, + maximumOceanApron, + 4, + 4 + ), + HydrologyPlannerSettings.Geometry.defaults(), + List.of(), + List.of(), + surface.shoreWidth(), + seaCaves + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java index d4fda0066..9cb348ca4 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java @@ -62,8 +62,10 @@ public class HydrologyPlannerSettingsTest { base.underground(), base.outlets(), HydrologyPlannerSettings.Geometry.defaults(), - List.of(overlongChannel), List.of() - )); + List.of(overlongChannel), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled()) + ); } @Test @@ -78,8 +80,10 @@ public class HydrologyPlannerSettingsTest { 0D, 0D, 0D, - 0D - , 1D, 0); + 0D, + 1D, + 0 + ); HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, 1D, 0, 0, 1, 0); HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( true, @@ -101,6 +105,7 @@ public class HydrologyPlannerSettingsTest { 0, 0, 64, + 1, 1 ); HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( @@ -137,25 +142,77 @@ public class HydrologyPlannerSettingsTest { public void bankSettingsRejectValuesOutsideTheirBounds() { HydrologyPlannerSettings.Banks banks = HydrologyPlannerSettings.Banks.defaults(); - assertEquals(1, banks.inset()); - assertEquals(1, banks.freeboard()); + assertEquals(0, banks.sink()); + assertTrue(banks.erosion().enabled()); + assertEquals(12, banks.erosion().smoothingRadius()); + assertTrue(banks.ponds().source().enabled()); + assertTrue(banks.ponds().terminal().enabled()); assertEquals(3D, banks.blendSlope(), 0D); assertEquals(4, banks.minimumBlendWidth()); assertEquals(32, banks.maximumBlendWidth()); assertEquals(6, banks.waterfallMinimumDrop()); assertTrue(banks.exposeCutStrata()); - assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(-1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true)); - assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, -1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true)); - assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 0D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true)); - assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 40, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true)); - assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 0, 1.6D, 2.5D, 24, true)); - assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0.25D, 16, 0, 6, 1.6D, 2.5D, 24, true)); - assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 1.5D, 16, 2, 6, 1.6D, 2.5D, 24, true)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(-1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults())); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 0D, 4, 32, 0.25D, 16, 2, 6, 1.6D, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults())); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 3D, 40, 32, 0.25D, 16, 2, 6, 1.6D, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults())); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 3D, 4, 32, 0.25D, 16, 2, 0, 1.6D, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults())); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 3D, 4, 32, 0.25D, 16, 0, 6, 1.6D, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults())); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 3D, 4, 32, 1.5D, 16, 2, 6, 1.6D, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults())); assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Surface( true, new HydrologyPlannerSettings.Source(true, 1D, 0, 0, 1, 0), 1, 1, 1, 1, 0, 1D, null)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Erosion(true, -1, 0.45D, 1D, 0.5D)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Erosion(true, 12, 1D, 1D, 0.5D)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 0D, 0.5D)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, -0.1D)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Pond(true, 0, 4, 3)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Pond(true, 5, 4, 3)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Pond(true, 4, 6, 0)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Ponds(null, new HydrologyPlannerSettings.Pond(true, 4, 6, 3))); + } + + @Test + public void inletSettingsRejectValuesOutsideTheirBounds() { + HydrologyPlannerSettings.Inlet inlet = HydrologyPlannerSettings.defaults().surface().banks().inlet(); + + assertEquals(64, inlet.length()); + assertEquals(3, inlet.depth()); + assertEquals(32, inlet.maximumIncision()); + assertEquals(new HydrologyPlannerSettings.Inlet(0, 0, 0), HydrologyPlannerSettings.Inlet.none()); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Inlet(-1, 3, 32)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Inlet(1025, 3, 32)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Inlet(64, -1, 32)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Inlet(64, 65, 32)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Inlet(64, 3, -1)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Inlet(64, 3, 513)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks( + 0, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, null, 2.5D, 24, true, + HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults())); + new HydrologyPlannerSettings.Inlet(1024, 64, 512); + } + + @Test + public void publicationRadiusGrowsWithTheMouthFlare() { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Banks banks = base.surface().banks(); + HydrologyPlannerSettings.Banks flared = new HydrologyPlannerSettings.Banks( + banks.sink(), banks.blendSlope(), banks.minimumBlendWidth(), banks.maximumBlendWidth(), banks.roughness(), + banks.roughnessWavelength(), banks.cascadeRun(), banks.waterfallMinimumDrop(), 4D, banks.inlet(), + banks.springWidthRatio(), banks.springLength(), banks.exposeCutStrata(), banks.erosion(), banks.ponds()); + HydrologyPlannerSettings.Surface surface = base.surface(); + HydrologyPlannerSettings wide = new HydrologyPlannerSettings( + base.seaLevel(), base.routing(), + new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), + surface.maximumWidth(), surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), + surface.shoreWidth(), flared), + base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), 0D, HydrologyPlannerSettings.SeaCaves.disabled()); + + assertEquals(1.6D, banks.mouthFlareRatio(), 0D); + int widened = (int) StrictMath.ceil(surface.maximumWidth() * (4D - banks.mouthFlareRatio()) / 2D); + assertTrue(wide.publicationRadius() - base.publicationRadius() >= widened - 1); + assertTrue(wide.publicationRadius() > base.publicationRadius()); } @Test @@ -178,8 +235,9 @@ public class HydrologyPlannerSettingsTest { 0, 0, 1, - 1 - )); + 1, + 1) + ); assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Outlets( true, accepted, @@ -188,8 +246,33 @@ public class HydrologyPlannerSettingsTest { 0, 0, 65, - 1 - )); + 1, + 1) + ); + } + + @Test + public void coastalOutletBudgetIsBounded() { + HydrologyPlannerSettings.Grotto grotto = new HydrologyPlannerSettings.Grotto(true, 1, 1, 1, 1); + HydrologyPlannerSettings.Outlets separate = new HydrologyPlannerSettings.Outlets( + true, + grotto, + grotto, + true, + 0, + 0, + 1, + 3, + 0 + ); + + assertEquals(0, separate.maximumCoastalPerTile()); + assertEquals(256, new HydrologyPlannerSettings.Outlets(true, grotto, grotto, true, 0, 0, 1, 3, 256) + .maximumCoastalPerTile()); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Outlets( + true, grotto, grotto, true, 0, 0, 1, 3, -1)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Outlets( + true, grotto, grotto, true, 0, 0, 1, 3, 257)); } @Test @@ -259,4 +342,78 @@ public class HydrologyPlannerSettingsTest { 0 )); } + + @Test + public void seaCaveBoundsAreEnforcedAndTheDefaultsPlanNone() { + HydrologyPlannerSettings.SeaCaves seaCaves = new HydrologyPlannerSettings.SeaCaves(true, 3, 160, 8, 12); + assertEquals(3, seaCaves.maximumPerTile()); + assertFalse(HydrologyPlannerSettings.SeaCaves.disabled().enabled()); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.SeaCaves(true, -1, 160, 8, 12)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.SeaCaves(true, 65, 160, 8, 12)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.SeaCaves(true, 3, 15, 8, 12)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.SeaCaves(true, 3, 8193, 8, 12)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.SeaCaves(true, 3, 160, 0, 12)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.SeaCaves(true, 3, 160, 129, 12)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.SeaCaves(true, 3, 160, 8, -1)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.SeaCaves(true, 3, 160, 8, 129)); + + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + assertEquals(HydrologyPlannerSettings.SeaCaves.disabled(), base.seaCaves()); + HydrologyPlannerSettings withCaves = new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + base.surface(), + base.hydraulics(), + base.underground(), + base.outlets(), + base.geometry(), + base.deepFluids(), + base.surfacePools(), + base.widestShoreBiomeWidth(), + seaCaves + ); + assertEquals(seaCaves, withCaves.seaCaves()); + assertTrue(base.fingerprint() != withCaves.fingerprint()); + } + + @Test + public void seaCavesAloneReachTheChamberAndItsSweepBeyondTheHalo() { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Source noSources = new HydrologyPlannerSettings.Source(false, 0D, 0, 0, 0, 0); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + false, noSources, 4, 8, 2, 4, 10, 1.5D, HydrologyPlannerSettings.Banks.defaults()); + HydrologyPlannerSettings.Underground underground = new HydrologyPlannerSettings.Underground( + false, noSources, -48, 72, 3, 8, 1, 3, 6, 14, true, 0); + HydrologyPlannerSettings quiet = new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + surface, + base.hydraulics(), + underground, + base.outlets(), + base.geometry(), + List.of(), + List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() + ); + HydrologyPlannerSettings withCaves = new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + surface, + base.hydraulics(), + underground, + base.outlets(), + base.geometry(), + List.of(), + List.of(), + surface.shoreWidth(), + new HydrologyPlannerSettings.SeaCaves(true, 1, 64, 8, 12) + ); + + int alignedHalo = Math.min(base.routing().maximumRouteLength(), base.routing().sampleSpacing() * 2) + / base.routing().sampleSpacing() * base.routing().sampleSpacing(); + assertEquals(0, quiet.publicationRadius()); + assertEquals(alignedHalo + base.outlets().coastalGrotto().horizontalRadius() + 12 + 1, withCaves.publicationRadius()); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java index 46f6b0657..603875466 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java @@ -31,7 +31,7 @@ public class HydrologyPlannerSurfacePoolTest { assertEquals(HydrologyFeatureType.STANDING_POOL, segment.type()); assertTrue(segment.width() >= 8 && segment.width() <= 12); assertTrue(segment.start().x() < 512); - assertEquals(79, segment.upstreamHeadY()); + assertEquals(80, segment.upstreamHeadY()); assertEquals(segment.upstreamHeadY(), segment.downstreamHeadY()); } } @@ -58,7 +58,7 @@ public class HydrologyPlannerSurfacePoolTest { if (layer.channel()) { wet++; assertEquals("lava_pool", layer.profileKey()); - assertEquals(79, layer.fluidHeadY()); + assertEquals(80, layer.fluidHeadY()); assertTrue(layer.bedY() < layer.fluidHeadY()); assertTrue(center.distanceSquared2D(new HydrologyPoint(column.x(), 0, column.z())) <= 8 * 8); } else if (layer.shore()) { @@ -113,7 +113,9 @@ public class HydrologyPlannerSurfacePoolTest { base.geometry(), List.of(), List.of(new HydrologyPlannerSettings.SurfacePool( - "lava_pool", true, density, spacing, minimumRadius, maximumRadius, depth, 64, null)) + "lava_pool", true, density, spacing, minimumRadius, maximumRadius, depth, 64, null)), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java index 1ac509b8f..92d17fe9b 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java @@ -125,8 +125,9 @@ public class HydrologyPlannerTest { configured.coastalCliffMinimumHeight(), 0, configured.maximumOceanApron(), - configured.maximumPerTile() - )); + configured.maximumPerTile(), + configured.maximumPerTile()) + ); HydrologyTile tile = planner(78L, settings, rollingCoast(112)).plan(TILE); @@ -438,10 +439,10 @@ public class HydrologyPlannerTest { for (HydrologyPoint point : segment.centerline()) { assertTrue(point.y() <= previousHead); assertTrue(previousHead - point.y() <= 1); - assertTrue(rollingTerrain.sample(point.x() - 1, point.z()).naturalHeight() > point.y()); - assertTrue(rollingTerrain.sample(point.x() + 1, point.z()).naturalHeight() > point.y()); - assertTrue(rollingTerrain.sample(point.x(), point.z() - 1).naturalHeight() > point.y()); - assertTrue(rollingTerrain.sample(point.x(), point.z() + 1).naturalHeight() > point.y()); + assertTrue(rollingTerrain.sample(point.x() - 1, point.z()).naturalHeight() >= point.y()); + assertTrue(rollingTerrain.sample(point.x() + 1, point.z()).naturalHeight() >= point.y()); + assertTrue(rollingTerrain.sample(point.x(), point.z() - 1).naturalHeight() >= point.y()); + assertTrue(rollingTerrain.sample(point.x(), point.z() + 1).naturalHeight() >= point.y()); previousHead = point.y(); } assertEquals(segment.downstreamHeadY(), previousHead); @@ -514,7 +515,9 @@ public class HydrologyPlannerTest { "shore", "dry", "flooded", - List.of("alpha"), List.of() + List.of("alpha"), List.of(), + Double.NaN, + null ); }; HydrologyTile tile = new HydrologyPlanner(812L, settings, terrain, solidCaveView()).plan(TILE); @@ -588,7 +591,9 @@ public class HydrologyPlannerTest { base.underground(), base.outlets(), base.geometry(), - base.deepFluids(), List.of() + base.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); HydrologyTerrainSampler repeatedRidges = (int x, int z) -> { if (x >= 112) { @@ -756,8 +761,9 @@ public class HydrologyPlannerTest { configured.coastalCliffMinimumHeight(), configured.mouthLevelingDistance(), configured.maximumOceanApron(), - 1 - )); + 1, + 1) + ); HydrologyTerrainSampler coast = (int x, int z) -> { if (x >= 112) { return oceanTerrain(); @@ -1122,7 +1128,9 @@ public class HydrologyPlannerTest { base.underground(), base.outlets(), HydrologyPlannerSettings.Geometry.defaults(), - base.deepFluids(), List.of() + base.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); HydrologyPlannerSettings settings = withOutlets(shaped, new HydrologyPlannerSettings.Outlets( true, @@ -1132,8 +1140,9 @@ public class HydrologyPlannerTest { base.outlets().coastalCliffMinimumHeight(), base.outlets().mouthLevelingDistance(), base.outlets().maximumOceanApron(), - 1 - )); + 1, + 1) + ); HydrologyTile tile = new HydrologyPlanner(642L, settings, optionalRollingCoast(112)).plan(TILE); List courses = surfaceCourses(tile); @@ -1236,8 +1245,9 @@ public class HydrologyPlannerTest { 12, base.outlets().mouthLevelingDistance(), base.outlets().maximumOceanApron(), - base.outlets().maximumPerTile() - )); + base.outlets().maximumPerTile(), + base.outlets().maximumPerTile()) + ); HydrologyTerrainSampler cliffCoast = (int x, int z) -> { if (x >= 112) { return oceanTerrain(); @@ -1285,6 +1295,192 @@ public class HydrologyPlannerTest { } } + @Test + public void coastalAndInlandOutletsAreBudgetedSeparately() { + HydrologyPlannerSettings base = standardSettings(2D, 0D, true, true, List.of()); + HydrologyPlannerSettings settings = withOutlets(base, new HydrologyPlannerSettings.Outlets( + true, + base.outlets().coastalGrotto(), + base.outlets().inlandGrotto(), + base.outlets().surfaceSinkholesEnabled(), + base.outlets().coastalCliffMinimumHeight(), + base.outlets().mouthLevelingDistance(), + base.outlets().maximumOceanApron(), + 1, + 1 + )); + HydrologyTerrainSampler terrain = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + if (x == 48) { + return blockedTerrain(); + } + boolean source = (x == 0 || x == 80) && z == 0; + return terrain(90 - Math.floorDiv(x, 16), 1D, false, true, source, source, false, false); + }; + + HydrologyTile tile = new HydrologyPlanner(70143L, settings, terrain, solidCaveView()).plan(TILE); + + assertEquals( + "outlets=" + tile.outlets() + " diagnostics=" + tile.diagnosticCandidates(), + 1, + tile.outlets().stream().filter(RiverOutlet::directOcean).count() + ); + assertEquals( + "outlets=" + tile.outlets() + " diagnostics=" + tile.diagnosticCandidates(), + 1, + tile.outlets().stream().filter((RiverOutlet outlet) -> !outlet.directOcean()).count() + ); + } + + @Test + public void aCliffAndABeachOnTheSameCoastSelectAMouthAndACoastalGrotto() { + HydrologyTerrainSampler coast = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + if (z < 64) { + return terrain(92, 2D, false, true, x <= 16, true, false, false); + } + return terrain(66 + Math.floorDiv((96 - x) * 3, 16), 1D, false, false, x <= 16, true, false, false); + }; + + HydrologyTile mixed = new HydrologyPlanner(995L, mixedCoastSettings(2), coast).plan(TILE); + HydrologyTile single = new HydrologyPlanner(995L, mixedCoastSettings(1), coast).plan(TILE); + + assertTrue("outlets=" + mixed.outlets(), mixed.outlets().stream().allMatch(RiverOutlet::directOcean)); + assertTrue("outlets=" + mixed.outlets(), mixed.outlets().stream() + .anyMatch((RiverOutlet outlet) -> outlet.type() == HydrologyFeatureType.MOUTH)); + assertTrue("outlets=" + mixed.outlets(), mixed.outlets().stream() + .anyMatch((RiverOutlet outlet) -> outlet.type() == HydrologyFeatureType.COASTAL_GROTTO)); + assertEquals("outlets=" + single.outlets(), 1, single.outlets().size()); + assertEquals(HydrologyFeatureType.MOUTH, single.outlets().getFirst().type()); + } + + @Test + public void ownedCoastOutranksHaloCoast() { + HydrologyPlannerSettings settings = standardSettings(2D, 0D, true, false, List.of()); + HydrologyTerrainSampler twoCoasts = (int x, int z) -> { + if (x >= 112 || x <= -32) { + return oceanTerrain(); + } + boolean source = x >= 48 && x <= 96; + return terrain(88 - Math.floorDiv(x, 16), 1D, false, true, source, source, false, false); + }; + + HydrologyTile tile = new HydrologyPlanner(883L, settings, twoCoasts).plan(TILE); + + assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty()); + assertFalse(surfaceCourses(tile).isEmpty()); + for (RiverOutlet outlet : tile.outlets()) { + assertTrue( + "outlet=" + outlet, + TILE.contains(outlet.landwardPoint().x(), outlet.landwardPoint().z(), tile.tileSize()) + ); + } + } + + @Test + public void undergroundSeaLevelOutletsAreReportedAsOutletLevel() { + HydrologyPlannerSettings base = standardSettings(2D, 1D, true, true, List.of()); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + base.surface(), + base.hydraulics(), + new HydrologyPlannerSettings.Underground( + true, + base.underground().sources(), + 40, + 50, + 4, + 12, + 2, + 4, + 5, + 9, + true, + 0 + ), + base.outlets(), + base.geometry(), + base.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() + ); + HydrologyTerrainSampler coast = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + boolean source = x <= 16; + return terrain(88 - Math.floorDiv(x, 16), 1D, false, true, source, source, source, false); + }; + + HydrologyTile tile = new HydrologyPlanner(884L, settings, coast, solidCaveView()).plan(TILE); + + List level = tile.diagnosticCandidates().stream() + .filter((HydrologyDiagnosticCandidate candidate) -> + candidate.kind() == HydrologyCandidateKind.OUTLET + && candidate.rejection() == HydrologyCandidateRejection.OUTLET_LEVEL) + .toList(); + assertFalse("diagnostics=" + tile.diagnosticCandidates(), level.isEmpty()); + for (HydrologyDiagnosticCandidate candidate : level) { + assertTrue( + candidate.toString(), + candidate.projectedType() == HydrologyFeatureType.MOUTH + || candidate.projectedType() == HydrologyFeatureType.COASTAL_GROTTO + ); + assertEquals(settings.seaLevel(), candidate.point().y()); + assertFalse(coast.sample(candidate.point().x(), candidate.point().z()).ocean()); + } + assertEquals(level.size(), level.stream().map(HydrologyDiagnosticCandidate::id).distinct().count()); + } + + @Test + public void fallbackAlternatesCoastalAndInlandTrials() { + // An island whose coast is walled by a berm between the lattice columns: every mouth course + // needs a cut deeper than the channel may make, so the surface routing falls back. The coast + // offers more fallback mouths than the trial budget, so a sinkhole only gets its trial when + // the fallback alternates coastal and inland candidates. + HydrologyPlannerSettings settings = standardSettings(1D, 0D, true, true, List.of()); + HydrologyTerrainSampler island = (int x, int z) -> { + if (x < -16 || x > 144 || z < -16 || z > 144) { + return oceanTerrain(); + } + boolean berm = (x > -13 && x < -3) || (x > 131 && x < 141) + || (z > -13 && z < -3) || (z > 131 && z < 141); + boolean source = x >= 32 && x <= 96 && z >= 32 && z <= 96; + return terrain(berm ? 170 : 90, 1D, false, true, source, source, false, false); + }; + + HydrologyTile tile = new HydrologyPlanner(885L, settings, island, solidCaveView()).plan(TILE); + + assertFalse("diagnostics=" + tile.diagnosticCandidates(), surfaceCourses(tile).isEmpty()); + assertTrue(tile.diagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) -> + candidate.rejection() == HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED)); + for (RiverCourse course : surfaceCourses(tile)) { + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertFalse("outlet=" + outlet, outlet.directOcean()); + assertTrue(course.surfaceSinkholeContinuation()); + } + } + + private HydrologyPlannerSettings mixedCoastSettings(int maximumCoastalPerTile) { + HydrologyPlannerSettings base = standardSettings(2D, 0D, true, false, List.of()); + return withOutlets(base, new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(true, 4, 3, 3, 4096), + base.outlets().inlandGrotto(), + base.outlets().surfaceSinkholesEnabled(), + 12, + base.outlets().mouthLevelingDistance(), + base.outlets().maximumOceanApron(), + base.outlets().maximumPerTile(), + maximumCoastalPerTile + )); + } + @Test public void configuredSurfaceRiverContinuesThroughOneContainedSinkholeCourse() { HydrologyPlannerSettings settings = standardSettings(0D, 0D, false, true, List.of()); @@ -1355,8 +1551,9 @@ public class HydrologyPlannerTest { configured.coastalCliffMinimumHeight(), configured.mouthLevelingDistance(), configured.maximumOceanApron(), - configured.maximumPerTile() - )); + configured.maximumPerTile(), + configured.maximumPerTile()) + ); HydrologyTile tile = new HydrologyPlanner( 7013L, @@ -1408,7 +1605,9 @@ public class HydrologyPlannerTest { base.underground(), base.outlets(), base.geometry(), - base.deepFluids(), List.of() + base.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); HydrologyTerrainSampler terrain = (int x, int z) -> { if (x >= 112) { @@ -1756,7 +1955,8 @@ public class HydrologyPlannerTest { 4, 5, 9, - true + true, + 0 ), new HydrologyPlannerSettings.Outlets( oceanEnabled, @@ -1766,10 +1966,13 @@ public class HydrologyPlannerTest { 12, 32, 2, + 4, 4 ), stableGeometry(), - deepFluids, List.of() + deepFluids, List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -1799,8 +2002,10 @@ public class HydrologyPlannerTest { 1.5D, 24D, 2D, - 0.2D - , 1D, 0), + 0.2D, + 1D, + 0 + ), new HydrologyPlannerSettings.Surface( true, new HydrologyPlannerSettings.Source(true, 8D, 80, 0, 8, 128), @@ -1821,10 +2026,13 @@ public class HydrologyPlannerTest { outlets.coastalCliffMinimumHeight(), 48, outlets.maximumOceanApron(), + 1, 1 ), HydrologyPlannerSettings.Geometry.defaults(), - List.of(), List.of() + List.of(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -1893,7 +2101,9 @@ public class HydrologyPlannerTest { "shore", "dry", "flooded", - List.of("alpha"), List.of() + List.of("alpha"), List.of(), + Double.NaN, + null ); }; } @@ -1958,7 +2168,9 @@ public class HydrologyPlannerTest { "shore", "dry", "flooded", - List.of("alpha", "beta"), List.of() + List.of("alpha", "beta"), List.of(), + Double.NaN, + null ); } @@ -1990,7 +2202,9 @@ public class HydrologyPlannerTest { "shore", "dry", "flooded", - List.of("alpha"), List.of() + List.of("alpha"), List.of(), + Double.NaN, + null ); } @@ -2031,7 +2245,9 @@ public class HydrologyPlannerTest { "shore", "dry", "flooded", - List.of("beta", "alpha"), List.of() + List.of("beta", "alpha"), List.of(), + Double.NaN, + null ); } @@ -2063,7 +2279,9 @@ public class HydrologyPlannerTest { "shore", "dry", "flooded", - List.of("alpha"), List.of() + List.of("alpha"), List.of(), + Double.NaN, + null ); } @@ -2095,7 +2313,9 @@ public class HydrologyPlannerTest { "shore", "dry", "flooded", - List.of("alpha", "beta"), List.of() + List.of("alpha", "beta"), List.of(), + Double.NaN, + null ); } @@ -2121,7 +2341,9 @@ public class HydrologyPlannerTest { settings.underground(), settings.outlets(), HydrologyPlannerSettings.Geometry.defaults(), - settings.deepFluids(), List.of() + settings.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -2146,11 +2368,14 @@ public class HydrologyPlannerTest { underground.maximumDepth(), underground.minimumHeadroom(), underground.maximumHeadroom(), - underground.connectToExistingCaves() + underground.connectToExistingCaves(), + 0 ), settings.outlets(), HydrologyPlannerSettings.Geometry.defaults(), - settings.deepFluids(), List.of() + settings.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -2176,11 +2401,14 @@ public class HydrologyPlannerTest { underground.maximumDepth(), underground.minimumHeadroom(), underground.maximumHeadroom(), - underground.connectToExistingCaves() + underground.connectToExistingCaves(), + 0 ), settings.outlets(), HydrologyPlannerSettings.Geometry.defaults(), - settings.deepFluids(), List.of() + settings.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -2205,11 +2433,14 @@ public class HydrologyPlannerTest { underground.maximumDepth(), underground.minimumHeadroom(), underground.maximumHeadroom(), - underground.connectToExistingCaves() + underground.connectToExistingCaves(), + 0 ), settings.outlets(), HydrologyPlannerSettings.Geometry.defaults(), - settings.deepFluids(), List.of() + settings.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -2269,7 +2500,9 @@ public class HydrologyPlannerTest { settings.underground(), outlets, HydrologyPlannerSettings.Geometry.defaults(), - settings.deepFluids(), List.of() + settings.deepFluids(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java index 1c3fc554f..459f93403 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java @@ -6,6 +6,7 @@ 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.assertNotNull; import static org.junit.Assert.assertTrue; @@ -56,6 +57,110 @@ public class HydrologyPlannerTributaryTest { } } + @Test + public void theApproachIntoTheStemNeverRisesWhereTheGroundHoldsAStationUp() { + // A level tributary tail at 120 hangs twenty blocks above its stem. The ground beside the junction + // falls to the stem, but a knoll a few stations back stands too high for the channel to cut it + // down: the stations over the knoll keep their head, and every station upstream of them stays at + // least as high, so the graded reach still descends into the stem instead of rising over the knoll. + HydrologyTerrainSampler terrain = (x, z) -> land(x >= 14 && x <= 17 ? 130 : x >= 18 ? 100 + (20 - x) * 4 : 121); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyPlanner planner = new HydrologyPlanner(19L, settings, terrain); + ArrayList centerline = new ArrayList<>(); + for (int x = 0; x <= 20; x++) { + centerline.add(new HydrologyPoint(x, 120, 0)); + } + ArrayList segments = new ArrayList<>(List.of(new HydraulicSegment( + 1L, 7L, HydrologyFeatureType.SURFACE_POOL, 120, 120, 4, 2, false, false, List.copyOf(centerline)))); + + planner.levelApproach(segments, 100); + + List graded = new ArrayList<>(); + int previousHead = Integer.MAX_VALUE; + for (HydraulicSegment segment : segments) { + assertTrue(segment.upstreamHeadY() <= previousHead); + assertTrue(segment.upstreamHeadY() >= segment.downstreamHeadY()); + previousHead = segment.downstreamHeadY(); + graded.addAll(segment.centerline()); + } + assertEquals(21, graded.size()); + assertEquals(100, graded.getLast().y()); + int previous = Integer.MAX_VALUE; + for (HydrologyPoint point : graded) { + assertTrue("head rises downstream at x=" + point.x(), point.y() <= previous); + assertTrue("cut below the incision floor at x=" + point.x(), + point.y() >= terrain.sample(point.x(), point.z()).naturalHeight() - settings.surface().maximumIncision()); + previous = point.y(); + } + assertEquals(120, graded.get(14).y()); + assertEquals(120, graded.get(17).y()); + assertTrue(graded.get(18).y() < 120); + } + + @Test + public void aLevelReachWithABumpStaysGradedOnceItsTailIsCutDown() { + // A pool reach may carry stations above its heads. Cutting its tail down to the stem turns it + // into a graded transition, which must descend monotonically: the bump is held at the head. + HydrologyTerrainSampler terrain = (x, z) -> land(121); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyPlanner planner = new HydrologyPlanner(23L, settings, terrain); + ArrayList centerline = new ArrayList<>(); + for (int x = 0; x <= 12; x++) { + centerline.add(new HydrologyPoint(x, x >= 4 && x <= 6 ? 122 : 120, 0)); + } + ArrayList segments = new ArrayList<>(List.of(new HydraulicSegment( + 1L, 7L, HydrologyFeatureType.SURFACE_POOL, 120, 120, 4, 2, false, false, List.copyOf(centerline)))); + + planner.levelApproach(segments, 112); + + List graded = segments.getFirst().centerline(); + assertEquals(13, graded.size()); + assertEquals(112, graded.getLast().y()); + int previous = Integer.MAX_VALUE; + for (HydrologyPoint point : graded) { + assertTrue("head rises downstream at x=" + point.x(), point.y() <= previous); + previous = point.y(); + } + assertTrue("bump above the head at x=5", graded.get(5).y() <= 120); + assertTrue(graded.getFirst().y() <= 120); + assertTrue(segments.getFirst().upstreamHeadY() >= segments.getFirst().downstreamHeadY()); + } + + private static HydrologyTerrainSample land(int height) { + return new HydrologyTerrainSample( + height, + 0D, + false, + false, + height - 40, + height - 38, + true, + true, + false, + false, + false, + false, + 0D, + 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + 1D, + "land", + "land", + "land", + "land", + "land", + "land", + List.of("default"), + List.of(), + Double.NaN, + null + ); + } + private static int stations(RiverCourse course) { int stations = 0; for (HydraulicSegment segment : course.segments()) { @@ -114,19 +219,25 @@ public class HydrologyPlannerTributaryTest { "land", "land", List.of("default"), - List.of() + List.of(), + Double.NaN, + null ); } private static HydrologyPlannerSettings settings(int tributaries) { + return settings(tributaries, 4D, 4); + } + + private static HydrologyPlannerSettings settings(int tributaries, double density, int maximumPerTile) { 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, tributaries); - HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, 4D, 0, 0, 4, 128); + HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, density, 0, 0, maximumPerTile, 128); HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( true, sources, 4, 8, 2, 4, 40, 1.5D, HydrologyPlannerSettings.Banks.defaults()); HydrologyPlannerSettings.Underground underground = new HydrologyPlannerSettings.Underground( - false, base.underground().sources(), -48, 72, 3, 8, 1, 3, 6, 14, true); + false, base.underground().sources(), -48, 72, 3, 8, 1, 3, 6, 14, true, 0); HydrologyPlannerSettings.Outlets outlets = new HydrologyPlannerSettings.Outlets( true, base.outlets().coastalGrotto(), @@ -135,6 +246,7 @@ public class HydrologyPlannerTributaryTest { base.outlets().coastalCliffMinimumHeight(), base.outlets().mouthLevelingDistance(), base.outlets().maximumOceanApron(), + 1, 1 ); return new HydrologyPlannerSettings( @@ -146,7 +258,161 @@ public class HydrologyPlannerTributaryTest { outlets, base.geometry(), List.of(), - List.of() + List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() + ); + } + @Test + public void aTributaryOwnsOnlyTheDrainageUpstreamOfItsJunctionAndMeetsTheStemWater() { + HydrologyTerrainSampler terrain = HydrologyPlannerTributaryTest::valley; + + HydrologyTile split = new HydrologyPlanner(19L, settings(1), terrain).plan(TILE); + + List courses = surface(split); + assertEquals(split.diagnosticCandidates().toString(), 2, courses.size()); + RiverCourse stem = stations(courses.get(0)) >= stations(courses.get(1)) ? courses.get(0) : courses.get(1); + RiverCourse tributary = stem == courses.get(0) ? courses.get(1) : courses.get(0); + java.util.Set stemEdges = new java.util.HashSet<>(); + for (DrainageEdge edge : stem.drainageEdges()) { + stemEdges.add(edge.id()); + } + assertFalse(tributary.drainageEdges().isEmpty()); + for (DrainageEdge edge : tributary.drainageEdges()) { + assertFalse("tributary shares stem drainage " + edge.id(), stemEdges.contains(edge.id())); + } + HydrologyPoint confluence = tributary.segments().getLast().end(); + HydrologyPoint stemEnd = stem.segments().getLast().end(); + assertTrue("junction sits at the stem's mouth", confluence.x() != stemEnd.x() || confluence.z() != stemEnd.z()); + Integer stemHead = null; + for (HydraulicSegment segment : stem.segments()) { + for (HydrologyPoint point : segment.centerline()) { + if (point.x() == confluence.x() && point.z() == confluence.z()) { + stemHead = point.y(); + } + } + } + assertNotNull("tributary ends on the stem", stemHead); + assertEquals(stemHead.intValue(), tributary.segments().getLast().downstreamHeadY()); + for (HydrologyDiagnosticCandidate candidate : split.diagnosticCandidates()) { + assertTrue(candidate.toString(), candidate.kind() != HydrologyCandidateKind.TRIBUTARY); + } + } + + @Test + public void tributariesAreBudgetedOnTopOfTheSourceDensity() { + HydrologyTerrainSampler terrain = HydrologyPlannerTributaryTest::valley; + + HydrologyTile single = new HydrologyPlanner(19L, settings(0, 1D, 1), terrain).plan(TILE); + HydrologyTile joined = new HydrologyPlanner(19L, settings(1, 1D, 1), terrain).plan(TILE); + + assertEquals(single.diagnosticCandidates().toString(), 1, surface(single).size()); + assertEquals(joined.diagnosticCandidates().toString(), 2, surface(joined).size()); + } + + @Test + public void undergroundSourcesJoinTheirStemWhereTheirPassagesMeet() { + HydrologyTerrainSampler terrain = HydrologyPlannerTributaryTest::cavernousCoast; + + HydrologyTile single = new HydrologyPlanner(23L, undergroundSettings(0), terrain).plan(TILE); + HydrologyTile joined = new HydrologyPlanner(23L, undergroundSettings(1), terrain).plan(TILE); + + assertEquals(single.diagnosticCandidates().toString(), 1, underground(single).size()); + List courses = underground(joined); + assertEquals(joined.diagnosticCandidates().toString(), 2, courses.size()); + RiverCourse stem = stations(courses.get(0)) >= stations(courses.get(1)) ? courses.get(0) : courses.get(1); + RiverCourse tributary = stem == courses.get(0) ? courses.get(1) : courses.get(0); + assertEquals(stem.outletId(), tributary.outletId()); + HydrologyPoint junction = tributary.segments().getLast().end(); + Integer stemHead = null; + for (HydraulicSegment segment : stem.segments()) { + for (HydrologyPoint point : segment.centerline()) { + if (point.x() == junction.x() && point.z() == junction.z()) { + stemHead = point.y(); + } + } + } + assertNotNull("underground tributary ends on its stem", stemHead); + assertTrue(tributary.segments().getLast().downstreamHeadY() >= stemHead); + java.util.Set stemEdges = new java.util.HashSet<>(); + for (DrainageEdge edge : stem.drainageEdges()) { + stemEdges.add(edge.id()); + } + for (DrainageEdge edge : tributary.drainageEdges()) { + assertFalse(stemEdges.contains(edge.id())); + } + for (HydraulicSegment segment : tributary.segments()) { + assertTrue(segment.upstreamHeadY() >= segment.downstreamHeadY()); + } + } + + @Test + public void undergroundTributariesAreBounded() { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + org.junit.Assert.assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Underground( + true, base.underground().sources(), -48, 72, 3, 8, 1, 3, 6, 14, true, 5)); + } + + private static List underground(HydrologyTile tile) { + List courses = new java.util.ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.UNDERGROUND) { + courses.add(course); + } + } + return courses; + } + + /** A small coast with caves: two underground source zones at the west edge drain east into one sea outlet. */ + private static HydrologyTerrainSample cavernousCoast(int x, int z) { + if (x >= 112) { + return new HydrologyTerrainSample( + 54, 0D, true, false, 30, 32, false, false, false, false, false, false, + 0D, 0D, 0D, 1D, 1D, 1D, 1D, 1D, + "ocean_parent", "surface", "mouth", "shore", "dry", "flooded", + List.of("default"), List.of(), Double.NaN, null); + } + int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); + boolean source = x >= 0 && x <= 24 && (z >= 8 && z <= 40 || z >= 72 && z <= 104); + return new HydrologyTerrainSample( + height, 1D, false, true, 70, 74, true, true, false, false, source, false, + 0D, 0D, source ? 1D : 0D, 1D, 1D, 1D, 1D, 1D, + "parent", "surface", "mouth", "shore", "dry", "flooded", + List.of("default"), List.of(), Double.NaN, null); + } + + private static HydrologyPlannerSettings undergroundSettings(int tributaries) { + HydrologyPlannerSettings.Source none = new HydrologyPlannerSettings.Source(false, 0D, 80, 0, 0, 24); + HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, 1D, Integer.MIN_VALUE, 0, 1, 32); + HydrologyPlannerSettings.ChannelShape stableChannel = new HydrologyPlannerSettings.ChannelShape(2D, 0D, 0D, 11); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing(128, 16, 512, 256, 0, 0, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), + new HydrologyPlannerSettings.Surface(false, none, 4, 18, 2, 4, 10, 1.5D, HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(4), + new HydrologyPlannerSettings.Underground(true, sources, 68, 82, 4, 12, 2, 4, 5, 9, true, tributaries), + new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + false, + 12, + 32, + 2, + 1, + 1 + ), + new HydrologyPlannerSettings.Geometry( + new HydrologyPlannerSettings.Meanders(224, 72, 0D, 0D, 0D, 0, 75D), + stableChannel, + stableChannel, + stableChannel, + HydrologyPlannerSettings.Geometry.defaults().drops() + ), + List.of(), + List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java index 51cbdead8..984679600 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java @@ -89,7 +89,7 @@ public class HydrologyStyledGeometryTest { + terrain(surfaceSource.naturalPoint().x(), surfaceSource.naturalPoint().z()).naturalHeight(), surfaceStart.upstreamHeadY() <= terrain(surfaceSource.naturalPoint().x(), surfaceSource.naturalPoint().z()).naturalHeight() - - surfaceSettings.surface().banks().inset() + - surfaceSettings.surface().banks().sink() ); int widestSurfaceWidth = 0; for (HydraulicSegment segment : surface.segments()) { @@ -218,7 +218,8 @@ public class HydrologyStyledGeometryTest { 4, 5, 9, - true + true, + 0 ), new HydrologyPlannerSettings.Outlets( true, @@ -228,10 +229,13 @@ public class HydrologyStyledGeometryTest { 12, 32, 2, + 4, 4 ), HydrologyPlannerSettings.Geometry.defaults(), - deepFluids, List.of() + deepFluids, List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -264,7 +268,9 @@ public class HydrologyStyledGeometryTest { "shore", "dry", "flooded", - List.of("water"), List.of() + List.of("water"), List.of(), + Double.NaN, + null ); } int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); @@ -296,7 +302,9 @@ public class HydrologyStyledGeometryTest { "shore", "dry", "flooded", - List.of("water"), List.of() + List.of("water"), List.of(), + Double.NaN, + null ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java index 03c42a22c..3b4819eb9 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java @@ -6,6 +6,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; @@ -77,7 +80,42 @@ public final class HydrologyTerrainSampleTest { "shore", "dry", "flooded", - profiles, List.of() + profiles, List.of(), + Double.NaN, + null + ); + } + @Test + public void shoreBiomeWidthInheritsWhenUnsetAndRejectsNegatives() { + HydrologyTerrainSample unset = HydrologyTerrainSample.openLand(80, 0D, "parent"); + assertTrue(Double.isNaN(unset.shoreBiomeWidth())); + assertEquals(1.5D, unset.shoreBiomeWidth(1.5D), 0D); + + HydrologyTerrainSample wide = sample(List.of("default"), 12D, null); + assertEquals(12D, wide.shoreBiomeWidth(1.5D), 0D); + assertThrows(IllegalArgumentException.class, () -> sample(List.of("default"), -1D, null)); + assertThrows(IllegalArgumentException.class, () -> sample(List.of("default"), Double.POSITIVE_INFINITY, null)); + } + + @Test + public void confinesKeyIsNormalizedAndDecidesWhereWaterMayDrain() { + HydrologyTerrainSample open = sample(List.of("default"), Double.NaN, " "); + HydrologyTerrainSample west = sample(List.of("default"), Double.NaN, "region:west"); + HydrologyTerrainSample east = sample(List.of("default"), Double.NaN, "region:east"); + + assertNull(open.confinesKey()); + assertTrue(open.drainsInto(west)); + assertTrue(west.drainsInto(west)); + assertFalse(west.drainsInto(east)); + assertFalse(west.drainsInto(open)); + } + + private static HydrologyTerrainSample sample(List profiles, double shoreBiomeWidth, String confinesKey) { + return new HydrologyTerrainSample( + 80, 0D, false, false, 48, 50, true, true, true, false, false, false, + 0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D, + "parent", "parent", "parent", "parent", "parent", "parent", + profiles, List.of(), shoreBiomeWidth, confinesKey ); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java index d008a0584..d48600846 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -247,7 +248,7 @@ public class HydrologyTileCacheTest { new HydrologyPlannerSettings.Surface(false, disabled, 2, 4, 1, 2, 4, 1D, HydrologyPlannerSettings.Banks.defaults()), new HydrologyPlannerSettings.Hydraulics(3), - new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false), + new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false, 0), new HydrologyPlannerSettings.Outlets( false, new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), @@ -256,10 +257,13 @@ public class HydrologyTileCacheTest { 8, 16, 1, + 2, 2 ), HydrologyPlannerSettings.Geometry.defaults(), - List.of(), List.of() + List.of(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -285,7 +289,7 @@ public class HydrologyTileCacheTest { new HydrologyPlannerSettings.Routing(64, 16, 128, 96, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), new HydrologyPlannerSettings.Surface(true, surfaceSources, 4, 8, 2, 3, 20, 1.5D, tileBoundedBanks()), new HydrologyPlannerSettings.Hydraulics(3), - new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false), + new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false, 0), new HydrologyPlannerSettings.Outlets( true, new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), @@ -294,16 +298,20 @@ public class HydrologyTileCacheTest { 8, 16, 1, + 2, 2 ), HydrologyPlannerSettings.Geometry.defaults(), - List.of(), List.of() + List.of(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } // Small-tile settings need a blend envelope that fits inside the bounded cross-tile admission period. private static HydrologyPlannerSettings.Banks tileBoundedBanks() { - return new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 4, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true); + return new HydrologyPlannerSettings.Banks(0, 3D, 4, 4, 0.25D, 16, 2, 6, 1.6D, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, + HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults()); } private HydrologyTerrainSample featureTerrain(int x, int z) { @@ -338,7 +346,9 @@ public class HydrologyTileCacheTest { "shore", "dry", "flooded", - List.of("default"), List.of() + List.of("default"), List.of(), + Double.NaN, + null ); } @@ -419,4 +429,234 @@ public class HydrologyTileCacheTest { assertEquals(new HydrologyTileKey(1, 1), order.getFirst()); assertTrue(order.subList(0, 4).containsAll(List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(0, 1), new HydrologyTileKey(1, 1)))); } + @Test + public void tileBatchesPlanMissingTilesConcurrentlyOnThePrefetchExecutor() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger peak = new AtomicInteger(); + CountDownLatch allStarted = new CountDownLatch(4); + doAnswer(invocation -> { + peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max); + allStarted.countDown(); + assertTrue("every batch member should plan at the same time", allStarted.await(5, TimeUnit.SECONDS)); + inFlight.decrementAndGet(); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, executor); + List keys = List.of( + new HydrologyTileKey(0, 0), + new HydrologyTileKey(1, 0), + new HydrologyTileKey(2, 0), + new HydrologyTileKey(3, 0) + ); + + List tiles = cache.tiles(keys); + + assertEquals(4, tiles.size()); + assertEquals(4, peak.get()); + verify(planner, times(4)).plan(any(HydrologyTileKey.class)); + assertEquals(4, cache.size()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void tileBatchesNeverKeepMoreThanHalfTheCacheBoundInFlight() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger peak = new AtomicInteger(); + List planned = java.util.Collections.synchronizedList(new ArrayList<>()); + doAnswer(invocation -> { + peak.accumulateAndGet(inFlight.incrementAndGet(), Math::max); + Thread.sleep(20L); + planned.add(invocation.getArgument(0)); + inFlight.decrementAndGet(); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 4, executor); + ArrayList keys = new ArrayList<>(); + for (int tileX = 0; tileX < 6; tileX++) { + keys.add(new HydrologyTileKey(tileX, 0)); + } + + List tiles = cache.tiles(keys); + + assertEquals(6, tiles.size()); + assertEquals(6, planned.size()); + assertTrue("in flight " + peak.get(), peak.get() <= 2); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void tileBatchesWithoutAPrefetchExecutorPlanInlineInKeyOrder() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(emptySettings()); + List planned = new ArrayList<>(); + doAnswer(invocation -> { + planned.add(invocation.getArgument(0)); + return mock(HydrologyTile.class); + }).when(planner).plan(any(HydrologyTileKey.class)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 8); + List keys = List.of(new HydrologyTileKey(2, 1), new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 1)); + + List tiles = cache.tiles(keys); + + assertEquals(keys, planned); + assertEquals(3, tiles.size()); + assertSame(cache.get(keys.get(1)), tiles.get(1)); + } + + @Test + public void tileBatchesJoinAPlanAlreadyInFlightInsteadOfQueueingAnother() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger plans = new AtomicInteger(); + doAnswer(invocation -> { + plans.incrementAndGet(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, executor); + HydrologyTileKey key = new HydrologyTileKey(5, 5); + ExecutorService callers = Executors.newFixedThreadPool(3); + try { + List>> results = new ArrayList<>(); + for (int caller = 0; caller < 3; caller++) { + results.add(callers.submit(() -> cache.tiles(List.of(key)))); + } + Thread.sleep(100L); + release.countDown(); + for (Future> result : results) { + assertSame(tile, result.get(5, TimeUnit.SECONDS).getFirst()); + } + } finally { + callers.shutdownNow(); + } + assertEquals(1, plans.get()); + } finally { + executor.shutdownNow(); + } + } + @Test + public void interruptedPlanningIsNotPublishedAsAnEmptyTile() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + AtomicInteger plans = new AtomicInteger(); + doAnswer(invocation -> { + if (plans.incrementAndGet() == 1) { + throw new IllegalStateException("Interrupted", new InterruptedException()); + } + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 8); + HydrologyTileKey key = new HydrologyTileKey(3, 4); + + IllegalStateException interrupted = org.junit.Assert.assertThrows(IllegalStateException.class, () -> cache.get(key)); + + assertEquals("Interrupted", interrupted.getMessage()); + assertEquals(0, cache.size()); + assertSame(tile, cache.get(key)); + assertEquals(2, plans.get()); + verify(planner, org.mockito.Mockito.never()).emptyTile(any(HydrologyTileKey.class)); + } + + @Test + public void directTileRequestsPlanOnThePrefetchExecutorInsteadOfTheCaller() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + List planningThreads = java.util.Collections.synchronizedList(new ArrayList<>()); + doAnswer(invocation -> { + planningThreads.add(Thread.currentThread().getName()); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "hydrology-planning-executor")); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor); + + assertSame(tile, cache.get(new HydrologyTileKey(7, 7))); + + assertEquals(List.of("hydrology-planning-executor"), planningThreads); + } finally { + executor.shutdownNow(); + } + } + @Test + public void forkJoinWorkersPlanOnTheirOwnThreadInsteadOfTheExecutor() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + List planningThreads = java.util.Collections.synchronizedList(new ArrayList<>()); + doAnswer(invocation -> { + planningThreads.add(Thread.currentThread().getName()); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, "hydrology-planning-executor")); + java.util.concurrent.ForkJoinPool pool = new java.util.concurrent.ForkJoinPool(1); + try { + HydrologyTileCache cache = new HydrologyTileCache(planner, 8, executor); + + HydrologyTile planned = pool.submit(() -> cache.get(new HydrologyTileKey(9, 9))).get(5, TimeUnit.SECONDS); + List batch = pool.submit(() -> cache.tiles(List.of(new HydrologyTileKey(10, 9), new HydrologyTileKey(11, 9)))).get(5, TimeUnit.SECONDS); + + assertSame(tile, planned); + assertEquals(2, batch.size()); + assertEquals(3, planningThreads.size()); + assertTrue(planningThreads.toString(), planningThreads.stream().allMatch(name -> name.startsWith("ForkJoinPool-"))); + } finally { + pool.shutdownNow(); + executor.shutdownNow(); + } + } + @Test + public void plannedCheckNeverPlansItselfButAsksThePrefetchExecutorForMissingTiles() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + List queued = new ArrayList<>(); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64, queued::add); + + assertFalse(cache.isPlanned(40, 40)); + assertFalse(queued.isEmpty()); + verify(planner, org.mockito.Mockito.never()).plan(any(HydrologyTileKey.class)); + + for (Runnable task : List.copyOf(queued)) { + task.run(); + } + + assertTrue(cache.isPlanned(40, 40)); + assertTrue(cache.columnAt(40, 40).isEmpty()); + } + + @Test + public void plannedCheckWithoutAnExecutorReportsTheCacheOnly() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty()); + HydrologyTileCache cache = new HydrologyTileCache(planner, 64); + + assertFalse(cache.isPlanned(40, 40)); + cache.columnAt(40, 40); + assertTrue(cache.isPlanned(40, 40)); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java index c88d3e546..f2a1f6de8 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java @@ -13,6 +13,7 @@ import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -150,4 +151,29 @@ public class RiverPolicyResolverTest { private EffectiveRiverPolicy resolvePlacement(IrisRiverPlacementMode placement) { return RiverPolicyResolver.resolve(new IrisRiverPolicy().setPlacement(placement), null, null); } + @Test + public void shoreBiomeWidthInheritsFromDimensionThroughRegionToBiome() { + IrisRiverPolicy region = new IrisRiverPolicy().setShoreBiomeWidth(6D); + IrisRiverPolicy biome = new IrisRiverPolicy().setShoreBiomeWidth(0D); + + assertNull(RiverPolicyResolver.resolve((IrisRiverPolicy) null, null, null).shoreBiomeWidth()); + assertEquals(6D, RiverPolicyResolver.resolve(null, region, null).shoreBiomeWidth(), 0D); + assertEquals(6D, RiverPolicyResolver.resolve(null, region, new IrisRiverPolicy()).shoreBiomeWidth(), 0D); + assertEquals(0D, RiverPolicyResolver.resolve(null, region, biome).shoreBiomeWidth(), 0D); + } + + @Test + public void confinementScopeIsTheLevelThatConfinedTheRiver() { + IrisRiverPolicy confined = new IrisRiverPolicy().setConfined(true); + IrisRiverPolicy released = new IrisRiverPolicy().setConfined(false); + + assertEquals(RiverConfinement.NONE, RiverPolicyResolver.resolve((IrisRiverPolicy) null, null, null).confinement()); + assertEquals(RiverConfinement.REGION, RiverPolicyResolver.resolve(confined, null, null).confinement()); + assertEquals(RiverConfinement.REGION, RiverPolicyResolver.resolve(null, confined, null).confinement()); + assertEquals(RiverConfinement.REGION, RiverPolicyResolver.resolve(null, confined, new IrisRiverPolicy()).confinement()); + assertEquals(RiverConfinement.BIOME, RiverPolicyResolver.resolve(null, null, confined).confinement()); + assertEquals(RiverConfinement.BIOME, RiverPolicyResolver.resolve(null, confined, confined).confinement()); + assertEquals(RiverConfinement.NONE, RiverPolicyResolver.resolve(null, confined, released).confinement()); + assertEquals(RiverConfinement.NONE, RiverPolicyResolver.resolve(confined, released, null).confinement()); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java index 667670f75..74964a2ee 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -440,7 +440,7 @@ public class IrisHydrologyRoutingTerrainSamplerTest { } @Test - public void burstWorkerUsesAvailableParallelRows() throws Exception { + public void burstWorkerForksRowsIntoItsOwnPoolInsteadOfTheExecutor() throws Exception { AtomicInteger submissions = new AtomicInteger(); AtomicInteger heightCalls = new AtomicInteger(); MultiBurst burst = new MultiBurst("Iris Hydrology Test", Thread.NORM_PRIORITY, () -> 2); @@ -471,7 +471,8 @@ public class IrisHydrologyRoutingTerrainSamplerTest { ); completion.get(5L, TimeUnit.SECONDS); - assertTrue(submissions.get() > 1); + // Handing rows to another pool from a pool worker is how saturated pools deadlock each other. + assertEquals(0, submissions.get()); assertEquals((width + 1) * (width + 1), heightCalls.get()); } finally { burst.close(); diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java index 8adcdcaad..f87d04d9b 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.hydrology.runtime; import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.object.IrisCoastalRiverGrottoConfig; import art.arcane.iris.engine.object.IrisDeepFluidConfig; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRiverHydrology; @@ -43,12 +44,12 @@ public class IrisHydrologyRuntimeSettingsTest { rivers.getGrottos().getInland().setEnabled(true).setConnectSurfaceRivers(true); rivers.getRouting().getInlandOutlets().add(IrisRiverInlandOutlet.SINKHOLE_GROTTO); - assertTrue(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + assertTrue(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) .outlets().surfaceSinkholesEnabled()); rivers.getRouting().getInlandOutlets().clear(); - assertFalse(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + assertFalse(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) .outlets().surfaceSinkholesEnabled()); } @@ -63,13 +64,13 @@ public class IrisHydrologyRuntimeSettingsTest { rivers.getUnderground().getSources().setDensity(4.5D); rivers.getRouting().setSampleSpacing(64); - assertEquals(5, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + assertEquals(5, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) .surface().sources().maximumPerTile()); - assertEquals(384, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + assertEquals(384, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) .surface().sources().minimumSpacing()); - assertEquals(5, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + assertEquals(5, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) .underground().sources().maximumPerTile()); - assertEquals(512, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + assertEquals(512, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) .underground().sources().minimumSpacing()); } @@ -77,16 +78,23 @@ public class IrisHydrologyRuntimeSettingsTest { public void surfaceBankAndFlowConfigurationMapsIntoPlannerSettings() { IrisDimension dimension = new IrisDimension(); IrisRiverHydrology rivers = dimension.getHydrology().getRivers(); - rivers.getSurface().getChannel().setInset(2).setMaximumIncision(12); + rivers.getSurface().getChannel().setSink(2).setMaximumIncision(12); + rivers.getSurface().getErosion() + .setEnabled(false) + .setSmoothingRadius(6) + .setThalwegFraction(0.6D) + .setBlendCurve(1.5D) + .setBedNoise(0.25D); + rivers.getSurface().getPonds().getSource().setEnabled(false).setMinimumRadius(3).setMaximumRadius(4).setDepth(2); + rivers.getSurface().getPonds().getTerminal().setMinimumRadius(7).setMaximumRadius(11).setDepth(5); rivers.getSurface().getBanks() - .setFreeboard(2) .setShoreWidth(2.5D) .setBlendSlope(4D) .setMinimumBlendWidth(6) .setMaximumBlendWidth(48) .setExposeCutStrata(false); rivers.getSurface().getFlow().setCascadeRun(3).setWaterfallMinimumDrop(8); - rivers.getSurface().getMouths().setFlareRatio(2.2D); + rivers.getSurface().getMouths().setFlareRatio(2.2D).setInletLength(96).setInletDepth(5).setMaximumIncision(40); rivers.getUnderground().setMouthLevelingDistance(96); rivers.getRouting() .setSampleSpacing(32) @@ -97,17 +105,30 @@ public class IrisHydrologyRuntimeSettingsTest { .setSlopePenalty(3D) .setConfluenceAttraction(0.4D); - HydrologyPlannerSettings settings = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()); + HydrologyPlannerSettings settings = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null); HydrologyPlannerSettings.Banks banks = settings.surface().banks(); - assertEquals(2, banks.inset()); - assertEquals(2, banks.freeboard()); + assertEquals(2, banks.sink()); + assertFalse(banks.erosion().enabled()); + assertEquals(6, banks.erosion().smoothingRadius()); + assertEquals(0.6D, banks.erosion().thalwegFraction(), 0D); + assertEquals(1.5D, banks.erosion().blendCurve(), 0D); + assertEquals(0.25D, banks.erosion().bedNoise(), 0D); + assertFalse(banks.ponds().source().enabled()); + assertEquals(3, banks.ponds().source().minimumRadius()); + assertEquals(4, banks.ponds().source().maximumRadius()); + assertEquals(2, banks.ponds().source().depth()); + assertTrue(banks.ponds().terminal().enabled()); + assertEquals(7, banks.ponds().terminal().minimumRadius()); + assertEquals(11, banks.ponds().terminal().maximumRadius()); + assertEquals(5, banks.ponds().terminal().depth()); assertEquals(4D, banks.blendSlope(), 0D); assertEquals(6, banks.minimumBlendWidth()); assertEquals(48, banks.maximumBlendWidth()); assertEquals(3, banks.cascadeRun()); assertEquals(8, banks.waterfallMinimumDrop()); assertEquals(2.2D, banks.mouthFlareRatio(), 0D); + assertEquals(new HydrologyPlannerSettings.Inlet(96, 5, 40), banks.inlet()); assertFalse(banks.exposeCutStrata()); assertEquals(12, settings.surface().maximumIncision()); assertEquals(2.5D, settings.surface().shoreWidth(), 0D); @@ -127,10 +148,16 @@ public class IrisHydrologyRuntimeSettingsTest { @Test public void routingOutletLimitControlsDrainageRootCount() { IrisDimension dimension = new IrisDimension(); - dimension.getHydrology().getRivers().getRouting().setMaximumOutletsPerTile(2); + dimension.getHydrology().getRivers().getRouting() + .setMaximumOutletsPerTile(2) + .setMaximumCoastalOutletsPerTile(5); - assertEquals(2, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) - .outlets().maximumPerTile()); + HydrologyPlannerSettings.Outlets outlets = IrisHydrologyRuntime + .createSettings(dimension, dimension.getHydrology(), () -> null) + .outlets(); + + assertEquals(2, outlets.maximumPerTile()); + assertEquals(5, outlets.maximumCoastalPerTile()); } @Test @@ -158,7 +185,7 @@ public class IrisHydrologyRuntimeSettingsTest { .setMaximumBasinDepth(9); HydrologyPlannerSettings.Geometry geometry = IrisHydrologyRuntime - .createSettings(dimension, dimension.getHydrology()) + .createSettings(dimension, dimension.getHydrology(), () -> null) .geometry(); assertEquals(47, geometry.meanders().primaryWavelength()); @@ -179,4 +206,29 @@ public class IrisHydrologyRuntimeSettingsTest { assertEquals(2.1D, geometry.drops().basinWidthRatio(), 0D); assertEquals(9, geometry.drops().maximumBasinDepth()); } + + @Test + public void seaCaveConfigurationMapsIntoPlannerSettings() { + IrisDimension dimension = new IrisDimension(); + IrisRiverHydrology rivers = dimension.getHydrology().getRivers(); + rivers.setEnabled(true); + IrisCoastalRiverGrottoConfig coastal = rivers.getGrottos().getCoastal(); + coastal.setEnabled(true); + coastal.getSeaCaves().setEnabled(true).setMaximumPerTile(5).setMinimumSpacing(200).setMinimumCoastHeight(9).setDepth(20); + + HydrologyPlannerSettings.SeaCaves mapped = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + .seaCaves(); + assertEquals(new HydrologyPlannerSettings.SeaCaves(true, 5, 200, 9, 20), mapped); + + coastal.setEnabled(false); + HydrologyPlannerSettings.SeaCaves withoutCoastalGrottos = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null) + .seaCaves(); + assertFalse(withoutCoastalGrottos.enabled()); + assertEquals(5, withoutCoastalGrottos.maximumPerTile()); + assertEquals(20, withoutCoastalGrottos.depth()); + + coastal.setEnabled(true); + coastal.getSeaCaves().setEnabled(false); + assertFalse(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> null).seaCaves().enabled()); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java index e1e52f509..5d9bebac6 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java @@ -40,11 +40,47 @@ public class ChannelProfileBuilderTest { } @Test - public void directOceanCoursesFlareAtTheMouth() { + public void theInletWidensAndDeepensTowardTheCoastOverItsLength() { SurfaceCenterline centerline = straight(400); + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet(); ChannelProfile profile = builder(1D, 1D).build(centerline, "water", true); + ChannelProfile plain = builder(defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none())) + .build(centerline, "water", true); - assertEquals(3D * 1.6D, profile.halfWidth()[399], 0.05D); + assertEquals(64, inlet.length()); + int flareStart = 400 - inlet.length(); + assertEquals(3D * defaults.banks().mouthFlareRatio(), profile.halfWidth()[399], 1.0E-9D); + assertEquals(3D + inlet.depth(), profile.depth()[399], 1.0E-9D); + assertEquals(3D, profile.halfWidth()[flareStart], 1.0E-9D); + assertEquals(3D, profile.depth()[flareStart], 1.0E-9D); + assertTrue(profile.halfWidth()[flareStart + 1] > 3D); + assertTrue(profile.depth()[flareStart + 1] > 3D); + assertEquals(3D, profile.halfWidth()[300], 1.0E-9D); + for (int station = flareStart + 1; station < 400; station++) { + assertTrue(profile.halfWidth()[station] >= profile.halfWidth()[station - 1]); + assertTrue(profile.depth()[station] >= profile.depth()[station - 1]); + } + assertEquals(3D, plain.halfWidth()[399], 1.0E-9D); + assertEquals(3D, plain.depth()[399], 1.0E-9D); + } + + @Test + public void theFlareCompletesAtTheShorelineWhenTheCenterlineRunsIntoTheSea() { + SurfaceCenterline centerline = straight(400); + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet(); + HydrologyTerrainSampler coast = (int x, int z) -> x >= 380 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(80, 0D, "land"); + ChannelProfile profile = new ChannelProfileBuilder(defaults, coast, CONSTANT_GEOMETRY).build(centerline, "water", true); + + int flareStart = 380 - inlet.length(); + assertEquals(3D, profile.halfWidth()[flareStart], 1.0E-9D); + assertTrue(profile.halfWidth()[flareStart + 1] > 3D); + assertEquals(3D * defaults.banks().mouthFlareRatio(), profile.halfWidth()[379], 1.0E-9D); + assertEquals(3D + inlet.depth(), profile.depth()[379], 1.0E-9D); + assertEquals(3D * defaults.banks().mouthFlareRatio(), profile.halfWidth()[399], 1.0E-9D); assertEquals(3D, profile.halfWidth()[300], 1.0E-9D); } @@ -76,6 +112,14 @@ public class ChannelProfileBuilderTest { return builder(widthMultiplier, depthMultiplier, (int x, int z) -> 80); } + private static ChannelProfileBuilder builder(HydrologyPlannerSettings.Banks banks) { + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + defaults.enabled(), defaults.sources(), defaults.minimumWidth(), defaults.maximumWidth(), + defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(), banks); + return new ChannelProfileBuilder(surface, (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land"), CONSTANT_GEOMETRY); + } + private static ChannelProfileBuilder builder(double widthMultiplier, double depthMultiplier, IntBinaryOperator height) { HydrologyTerrainSampler sampler = (int x, int z) -> new HydrologyTerrainSample( height.applyAsInt(x, z), @@ -104,7 +148,9 @@ public class ChannelProfileBuilderTest { "parent", "parent", "parent", - List.of("water"), List.of() + List.of("water"), List.of(), + Double.NaN, + null ); return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java index eddccf880..11af9b06e 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java @@ -25,20 +25,20 @@ public class ErosionFieldCompilerTest { }; @Test - public void flatTerrainProducesAContainedChannelWithLipAndNoWriteAboveNatural() { + public void flatTerrainHoldsTheWaterFlushWithTheBankAndContainedByDefault() { Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); assertEquals(0, compiled.field().uncontainedWetCells()); SurfaceColumn center = compiled.field().column(150, 0); assertNotNull(center); assertEquals(SurfaceRole.CHANNEL, center.role()); - assertEquals(79, center.headY()); - assertTrue(center.height() <= 76); + assertEquals(80, center.headY()); + assertTrue(center.height() <= 77); SurfaceColumn edge = compiled.field().column(150, 3); assertNotNull(edge); assertEquals(SurfaceRole.CHANNEL, edge.role()); assertTrue(edge.height() > center.height()); - assertTrue(edge.height() <= 78); + assertTrue(edge.height() <= 79); SurfaceColumn shore = compiled.field().column(150, 4); assertNotNull(shore); assertEquals(SurfaceRole.SHORE, shore.role()); @@ -47,6 +47,71 @@ public class ErosionFieldCompilerTest { assertChannelContained(compiled); } + @Test + public void aConfiguredSinkLowersTheWaterBelowTheBankByThatMuch() { + HydrologyTerrainSampler flat = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + Compiled sunk = compile(zeroRoughnessSurface(1, HydrologyPlannerSettings.Erosion.defaults()), flat, 300, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(0, sunk.field().uncontainedWetCells()); + assertEquals(79, sunk.field().column(150, 0).headY()); + SurfaceColumn shore = sunk.field().column(150, 4); + assertEquals(SurfaceRole.SHORE, shore.role()); + assertEquals(80, shore.height()); + assertChannelContained(sunk); + } + + @Test + public void disabledErosionKeepsTheChannelShoreAndLipButNoValley() { + HydrologyTerrainSampler hillside = (int x, int z) -> HydrologyTerrainSample.openLand(90 + Math.max(0, z) / 2, 0D, "land"); + HydrologyPlannerSettings.Erosion off = new HydrologyPlannerSettings.Erosion(false, 12, 0.45D, 1D, 0.5D); + Compiled compiled = compile(zeroRoughnessSurface(0, off), hillside, 300, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(0, compiled.field().uncontainedWetCells()); + assertChannelContained(compiled); + assertNoWriteAboveNatural(compiled); + for (SurfaceColumn column : compiled.field().columns().values()) { + assertTrue(column.role() != SurfaceRole.BANK); + } + assertNotNull(compiled.field().column(150, 0)); + assertEquals(SurfaceRole.SHORE, compiled.field().column(150, 4).role()); + } + + @Test + public void erosionDefaultsReproduceTheValleyBitForBitAndACurveReshapesIt() { + HydrologyTerrainSampler hillside = (int x, int z) -> HydrologyTerrainSample.openLand(90 + Math.max(0, z) / 2, 0D, "land"); + Compiled defaults = compile(zeroRoughnessSurface(0, HydrologyPlannerSettings.Erosion.defaults()), hillside, 300, SurfaceTerminal.SINKHOLE, 40); + Compiled explicit = compile(zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D)), hillside, 300, SurfaceTerminal.SINKHOLE, 40); + Compiled steep = compile(zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 2D, 0.5D)), hillside, 300, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(defaults.field().columns().size(), explicit.field().columns().size()); + for (SurfaceColumn column : defaults.field().columns().values()) { + SurfaceColumn same = explicit.field().column(column.x(), column.z()); + assertNotNull(same); + assertEquals(column.height(), same.height()); + assertEquals(column.headY(), same.headY()); + assertEquals(column.role(), same.role()); + } + boolean differs = false; + for (SurfaceColumn column : defaults.field().columns().values()) { + SurfaceColumn other = steep.field().column(column.x(), column.z()); + if (column.role() == SurfaceRole.BANK && other != null && other.height() != column.height()) { + differs = true; + break; + } + } + assertTrue(differs); + assertChannelContained(steep); + assertNoWriteAboveNatural(steep); + } + + @Test + public void blendCurveOneIsTheEasedBlendAndHigherCurvesHoldTheBankTopLonger() { + assertEquals(SurfaceNoise.smoothStep(0.3D), ErosionFieldCompiler.blend(0.3D, 1D), 0D); + assertTrue(ErosionFieldCompiler.blend(0.3D, 2D) < ErosionFieldCompiler.blend(0.3D, 1D)); + assertTrue(ErosionFieldCompiler.blend(0.3D, 0.5D) > ErosionFieldCompiler.blend(0.3D, 1D)); + assertEquals(1D, ErosionFieldCompiler.blend(1D, 3D), 0D); + } + @Test public void hillsideCutGetsABankWhoseWidthGrowsWithTheCut() { Compiled compiled = compile(300, (x, z) -> 90 + Math.max(0, z) / 2, SurfaceTerminal.SINKHOLE, 40); @@ -115,6 +180,77 @@ public class ErosionFieldCompilerTest { assertNoWriteAboveNatural(compiled); } + @Test + public void inletBanksAreCutToSeaLevelAndBlendBackToTheNaturalValley() { + HydrologyTerrainSampler coast = (int x, int z) -> { + if (x >= 240) { + return HydrologyTerrainSample.ocean(50, "ocean"); + } + int height = x < 200 ? 100 - x / 6 : 66 + (x - 200) * 18 / 40; + return HydrologyTerrainSample.openLand(height, 0D, "land"); + }; + HydrologyPlannerSettings.Inlet inlet = new HydrologyPlannerSettings.Inlet(32, 3, 32); + HydrologyPlannerSettings.Surface surface = withBanks(zeroRoughnessSurface(), zeroRoughnessSurface().banks().withInlet(inlet)); + Compiled compiled = compile(surface, coast, 244, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertEquals(0, compiled.field().uncontainedWetCells()); + assertNoWriteAboveNatural(compiled); + assertChannelContained(compiled); + int oceanWrites = 0; + for (SurfaceColumn column : compiled.field().columns().values()) { + HydrologyTerrainSample terrain = coast.sample(column.x(), column.z()); + if ((terrain.ocean() || terrain.naturalHeight() <= SEA_LEVEL) && !(column.apron() && column.height() == terrain.naturalHeight())) { + oceanWrites++; + } + } + assertEquals(0, oceanWrites); + for (int x = 240 - inlet.length(); x < 240; x++) { + SurfaceColumn center = compiled.field().column(x, 0); + assertNotNull(center); + assertEquals(SurfaceRole.CHANNEL, center.role()); + assertEquals(SEA_LEVEL, center.headY()); + assertTrue(center.height() < SEA_LEVEL); + } + int pinned = 220; + int shoreZ = -1; + for (int z = 1; z < 12; z++) { + SurfaceColumn column = compiled.field().column(pinned, z); + assertNotNull(column); + if (column.role() != SurfaceRole.CHANNEL) { + shoreZ = z; + break; + } + } + assertTrue(shoreZ > 0); + SurfaceColumn shore = compiled.field().column(pinned, shoreZ); + assertEquals(SurfaceRole.SHORE, shore.role()); + assertEquals(SEA_LEVEL, shore.height()); + assertTrue(coast.sample(pinned, shoreZ).naturalHeight() > SEA_LEVEL + 10); + int previous = SEA_LEVEL; + int banks = 0; + int lastHeight = SEA_LEVEL; + for (int z = shoreZ + 1; z < 60; z++) { + SurfaceColumn column = compiled.field().column(pinned, z); + if (column == null) { + break; + } + assertTrue(column.role() == SurfaceRole.SHORE || column.role() == SurfaceRole.BANK); + assertTrue(column.height() >= previous); + assertTrue(column.height() - previous <= 1); + previous = column.height(); + lastHeight = column.height(); + banks++; + } + assertTrue(banks > 8); + assertTrue(lastHeight >= coast.sample(pinned, 0).naturalHeight() - 1); + } + + private static HydrologyPlannerSettings.Surface withBanks(HydrologyPlannerSettings.Surface surface, HydrologyPlannerSettings.Banks banks) { + return new HydrologyPlannerSettings.Surface( + surface.enabled(), surface.sources(), surface.minimumWidth(), surface.maximumWidth(), + surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), banks); + } + @Test public void bankColumnsBeyondTheBlendAreNotWritten() { Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); @@ -154,7 +290,16 @@ public class ErosionFieldCompilerTest { } private static Compiled compile(HydrologyTerrainSampler sampler, int stations, SurfaceTerminal terminal, int terminalHead) { - HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(); + return compile(zeroRoughnessSurface(), sampler, stations, terminal, terminalHead); + } + + private static Compiled compile( + HydrologyPlannerSettings.Surface surface, + HydrologyTerrainSampler sampler, + int stations, + SurfaceTerminal terminal, + int terminalHead + ) { List path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); SurfaceCenterline centerline = SurfaceCenterline.densify(path); ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, CONSTANT_GEOMETRY) @@ -168,12 +313,26 @@ public class ErosionFieldCompilerTest { } private static HydrologyPlannerSettings.Surface zeroRoughnessSurface() { + return zeroRoughnessSurface(0, HydrologyPlannerSettings.Erosion.defaults()); + } + + private static HydrologyPlannerSettings.Surface zeroRoughnessSurface(int sink, HydrologyPlannerSettings.Erosion erosion) { + return zeroRoughnessSurface(sink, erosion, new HydrologyPlannerSettings.Ponds( + new HydrologyPlannerSettings.Pond(false, 5, 9, 3), + new HydrologyPlannerSettings.Pond(false, 4, 7, 3))); + } + + private static HydrologyPlannerSettings.Surface zeroRoughnessSurface( + int sink, + HydrologyPlannerSettings.Erosion erosion, + HydrologyPlannerSettings.Ponds ponds + ) { HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); return new HydrologyPlannerSettings.Surface( true, defaults.sources(), 4, 8, 2, 4, 10, 1.5D, - new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0D, 16, 2, 6, 1.6D, 2.5D, 24, true) + new HydrologyPlannerSettings.Banks(sink, 3D, 4, 32, 0D, 16, 2, 6, 1.6D, HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, erosion, ponds) ); } @@ -210,14 +369,14 @@ public class ErosionFieldCompilerTest { @Test public void bedBowlIsLevelAcrossTheThalwegAndOneBlockAtTheEdge() { - assertEquals(1D, ErosionFieldCompiler.bowl(0D), 1e-9); - assertEquals(1D, ErosionFieldCompiler.bowl(0.45D), 1e-9); - assertTrue(ErosionFieldCompiler.bowl(0.6D) > 0.8D); - assertTrue(ErosionFieldCompiler.bowl(0.8D) > 0.3D && ErosionFieldCompiler.bowl(0.8D) < 0.7D); - assertEquals(0D, ErosionFieldCompiler.bowl(1D), 1e-9); + assertEquals(1D, ErosionFieldCompiler.bowl(0D, 0.45D), 1e-9); + assertEquals(1D, ErosionFieldCompiler.bowl(0.45D, 0.45D), 1e-9); + assertTrue(ErosionFieldCompiler.bowl(0.6D, 0.45D) > 0.8D); + assertTrue(ErosionFieldCompiler.bowl(0.8D, 0.45D) > 0.3D && ErosionFieldCompiler.bowl(0.8D, 0.45D) < 0.7D); + assertEquals(0D, ErosionFieldCompiler.bowl(1D, 0.45D), 1e-9); double previous = 1D; for (double normalized = 0D; normalized <= 1D; normalized += 0.05D) { - double value = ErosionFieldCompiler.bowl(normalized); + double value = ErosionFieldCompiler.bowl(normalized, 0.45D); assertTrue(value <= previous + 1e-9); previous = value; } @@ -226,7 +385,7 @@ public class ErosionFieldCompilerTest { @Test public void everyCellTouchingWaterKeepsTheLipAcrossHeadSteps() { Compiled compiled = compile(400, (x, z) -> 120 - x / 8, SurfaceTerminal.SINKHOLE, 40); - int freeboard = zeroRoughnessSurface().banks().freeboard(); + int sink = zeroRoughnessSurface().banks().sink(); int checked = 0; for (SurfaceColumn column : compiled.field().columns().values()) { @@ -239,7 +398,7 @@ public class ErosionFieldCompilerTest { if (neighbour == null || neighbour.role() == SurfaceRole.CHANNEL) { continue; } - int required = Math.min(neighbour.terrain().naturalHeight(), column.headY() + freeboard); + int required = Math.min(neighbour.terrain().naturalHeight(), column.headY() + sink); assertTrue(neighbour.x() + "," + neighbour.z() + " height " + neighbour.height() + " < " + required, neighbour.height() >= required); checked++; @@ -250,16 +409,34 @@ public class ErosionFieldCompilerTest { } @Test - public void aLowCellBesideTheChannelLowersTheHeadSoTheLipSurvives() { + public void aDipOneBlockBelowTheWaterAnywhereBesideTheChannelIsHeldByALip() { + // Whatever the solver sampled, no water cell may border a lower dry cell: a one-block dip + // beside the channel is either answered by a lower head or by a one-block lip on the dip. + for (int dipZ = 2; dipZ <= 6; dipZ++) { + for (int dipX = 148; dipX <= 152; dipX++) { + int finalZ = dipZ; + int finalX = dipX; + Compiled compiled = compile(300, (x, z) -> x == finalX && z == finalZ ? 79 : 80, SurfaceTerminal.SINKHOLE, 40); + assertEquals("dip at " + finalX + "," + finalZ, 0, compiled.field().uncontainedWetCells()); + SurfaceColumn dip = compiled.field().column(finalX, finalZ); + if (dip != null) { + assertTrue("lip taller than one block at " + finalX + "," + finalZ, dip.height() <= 80); + } + } + } + } + + @Test + public void aLowCellBesideTheChannelLowersTheHeadSoTheBankStillHoldsIt() { Compiled compiled = compile(300, (x, z) -> x == 150 && z == 4 ? 79 : 80, SurfaceTerminal.SINKHOLE, 40); SurfaceColumn center = compiled.field().column(150, 0); assertNotNull(center); - assertTrue("head " + center.headY(), center.headY() <= 78); + assertTrue("head " + center.headY(), center.headY() <= 79); SurfaceColumn low = compiled.field().column(150, 4); assertEquals(SurfaceRole.SHORE, low.role()); assertNotNull(low); - assertTrue(low.height() >= center.headY() + 1); + assertTrue(low.height() >= center.headY()); assertEquals(0, compiled.field().uncontainedWetCells()); } @@ -292,4 +469,145 @@ public class ErosionFieldCompilerTest { assertEquals(200, firstApron); assertEquals(202, lastApron); } + @Test + public void policyShoreBiomeWidthWidensTheShoreRoleOverUntouchedGround() { + Compiled compiled = compile((int x, int z) -> land(80, 12D), 300, SurfaceTerminal.SINKHOLE, 40); + + SurfaceColumn geometricShore = compiled.field().column(150, 4); + assertNotNull(geometricShore); + assertEquals(SurfaceRole.SHORE, geometricShore.role()); + SurfaceColumn band = compiled.field().column(150, 12); + assertNotNull(band); + assertEquals(SurfaceRole.SHORE, band.role()); + assertEquals(80, band.height()); + assertNull(compiled.field().column(150, 17)); + assertNoWriteAboveNatural(compiled); + assertChannelContained(compiled); + } + + @Test + public void zeroShoreBiomeWidthLeavesTheGeometricShoreWithTheBankRole() { + Compiled compiled = compile((int x, int z) -> land(80, 0D), 300, SurfaceTerminal.SINKHOLE, 40); + + SurfaceColumn geometricShore = compiled.field().column(150, 4); + assertNotNull(geometricShore); + assertEquals(SurfaceRole.BANK, geometricShore.role()); + assertEquals(80, geometricShore.height()); + assertNull(compiled.field().column(150, 6)); + assertChannelContained(compiled); + } + + private static HydrologyTerrainSample land(int height, double shoreBiomeWidth) { + HydrologyTerrainSample open = HydrologyTerrainSample.openLand(height, 0D, "land"); + return new HydrologyTerrainSample( + open.naturalHeight(), open.slope(), open.ocean(), open.caveAvailable(), open.caveFloorY(), open.caveFluidY(), + open.transitAllowed(), open.outletAllowed(), open.surfaceSourceAllowed(), open.surfaceSourceRequired(), + open.undergroundSourceAllowed(), open.undergroundSourceRequired(), open.routingCost(), + open.surfaceSourceWeight(), open.undergroundSourceWeight(), open.widthMultiplier(), open.depthMultiplier(), + open.incisionMultiplier(), open.routingMultiplier(), open.bankMultiplier(), open.parentBiomeKey(), + open.surfaceBiomeKey(), open.mouthBiomeKey(), open.shoreBiomeKey(), open.bankBiomeKey(), + open.floodedCaveBiomeKey(), open.preferredProfileKeys(), open.surfacePoolKeys(), shoreBiomeWidth, null + ); + } + @Test + public void aSourcePondOpensAroundTheHeadwaterAtTheSourceHead() { + HydrologyTerrainSampler flat = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + HydrologyPlannerSettings.Ponds ponds = new HydrologyPlannerSettings.Ponds( + new HydrologyPlannerSettings.Pond(true, 10, 10, 3), + new HydrologyPlannerSettings.Pond(false, 4, 7, 3)); + Compiled compiled = compile(zeroRoughnessSurface(0, HydrologyPlannerSettings.Erosion.defaults(), ponds), flat, 300, SurfaceTerminal.SINKHOLE, 40); + Compiled plain = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(0, compiled.field().uncontainedWetCells()); + assertChannelContained(compiled); + assertNoWriteAboveNatural(compiled); + SurfaceColumn centre = compiled.field().column(0, 0); + assertEquals(SurfaceRole.CHANNEL, centre.role()); + assertEquals(80, centre.headY()); + assertTrue(centre.height() <= 77); + SurfaceColumn behind = compiled.field().column(-9, 0); + assertNotNull(behind); + assertEquals(SurfaceRole.CHANNEL, behind.role()); + assertEquals(80, behind.headY()); + SurfaceColumn beside = compiled.field().column(0, 9); + assertEquals(SurfaceRole.CHANNEL, beside.role()); + assertEquals(0, behind.station()); + SurfaceColumn plainBehind = plain.field().column(-9, 0); + assertTrue(plainBehind == null || plainBehind.role() != SurfaceRole.CHANNEL); + assertEquals(SurfaceRole.SHORE, compiled.field().column(-11, 0).role()); + assertNotNull(compiled.field().column(150, 0)); + assertEquals(SurfaceRole.CHANNEL, compiled.field().column(150, 0).role()); + assertTrue(compiled.field().column(150, 4).role() == SurfaceRole.SHORE); + } + + @Test + public void aTerminalPondOpensAtAnInlandEndButNotAtAnOceanMouth() { + HydrologyTerrainSampler flat = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + HydrologyPlannerSettings.Ponds ponds = new HydrologyPlannerSettings.Ponds( + new HydrologyPlannerSettings.Pond(false, 5, 9, 3), + new HydrologyPlannerSettings.Pond(true, 5, 5, 2)); + Compiled inland = compile(zeroRoughnessSurface(0, HydrologyPlannerSettings.Erosion.defaults(), ponds), flat, 300, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(0, inland.field().uncontainedWetCells()); + assertChannelContained(inland); + SurfaceColumn end = inland.field().column(299, 0); + assertEquals(SurfaceRole.CHANNEL, end.role()); + assertTrue(end.height() <= 78); + SurfaceColumn beyond = inland.field().column(303, 0); + assertNotNull(beyond); + assertEquals(SurfaceRole.CHANNEL, beyond.role()); + assertEquals(299, beyond.station()); + SurfaceColumn plainBeyond = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40).field().column(303, 0); + assertTrue(plainBeyond == null || plainBeyond.role() != SurfaceRole.CHANNEL); + + HydrologyTerrainSampler coast = (int x, int z) -> x >= 240 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(100 - x / 6, 0D, "land"); + Compiled mouth = compile(zeroRoughnessSurface(0, HydrologyPlannerSettings.Erosion.defaults(), ponds), coast, 300, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + int wetBeyondTheShore = 0; + for (SurfaceColumn column : mouth.field().columns().values()) { + if (column.role() == SurfaceRole.CHANNEL && !column.apron() && column.x() >= 236 && Math.abs(column.z()) > 4) { + wetBeyondTheShore++; + } + } + assertEquals(0, wetBeyondTheShore); + } + + @Test + public void aPondShrinksWhereTheGroundFallsAwayAroundItsRim() { + HydrologyTerrainSampler ridge = (int x, int z) -> HydrologyTerrainSample.openLand(z > 7 || z < -7 ? 70 : 80, 0D, "land"); + HydrologyPlannerSettings.Ponds ponds = new HydrologyPlannerSettings.Ponds( + new HydrologyPlannerSettings.Pond(true, 4, 12, 3), + new HydrologyPlannerSettings.Pond(false, 4, 7, 3)); + Compiled compiled = compile(zeroRoughnessSurface(0, HydrologyPlannerSettings.Erosion.defaults(), ponds), ridge, 300, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(0, compiled.field().uncontainedWetCells()); + assertChannelContained(compiled); + assertNoWriteAboveNatural(compiled); + assertEquals(80, compiled.field().column(0, 0).headY()); + SurfaceColumn nearRim = compiled.field().column(0, 4); + assertEquals(SurfaceRole.CHANNEL, nearRim.role()); + for (SurfaceColumn column : compiled.field().columns().values()) { + if (column.role() == SurfaceRole.CHANNEL) { + assertTrue(Math.abs(column.z()) <= 7); + } + } + } + + @Test + public void pondsAreDeterministicForACourseSeed() { + HydrologyTerrainSampler flat = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(0, HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.defaults()); + Compiled first = compile(surface, flat, 300, SurfaceTerminal.SINKHOLE, 40); + Compiled second = compile(surface, flat, 300, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(first.field().columns().size(), second.field().columns().size()); + for (SurfaceColumn column : first.field().columns().values()) { + SurfaceColumn same = second.field().column(column.x(), column.z()); + assertNotNull(same); + assertEquals(column.height(), same.height()); + assertEquals(column.role(), same.role()); + } + assertEquals(0, first.field().uncontainedWetCells()); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java index 85c6192a5..bbac910f1 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java @@ -79,7 +79,7 @@ public class SurfaceCourseBuilderTest { assertNull(result.rejection()); assertEquals(1, result.segments().size()); assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().getFirst().type()); - assertEquals(79, result.lastHead()); + assertEquals(80, result.lastHead()); assertEquals(201, result.segments().getFirst().centerline().size()); } @@ -135,7 +135,42 @@ public class SurfaceCourseBuilderTest { assertTrue(result.segments().isEmpty()); } + @Test + public void aCourseWithAnInletEndsAtSeaLevelWithoutACoastalDropStation() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 240 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(100 - x / 6, 0D, "land"); + List path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(241, 0, 0)); + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet(); + SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + SurfaceCourseResult plain = builder(defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none()), sampler) + .build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertNull(result.rejection()); + assertEquals(SEA_LEVEL, result.lastHead()); + assertEquals(239, result.pathEnd().x()); + HydraulicSegment last = result.segments().getLast(); + assertEquals(HydrologyFeatureType.SURFACE_POOL, last.type()); + assertEquals(SEA_LEVEL, last.upstreamHeadY()); + assertEquals(SEA_LEVEL, last.downstreamHeadY()); + assertEquals(inlet.length(), last.centerline().size()); + assertEquals(240 - inlet.length(), last.start().x()); + assertNull(plain.rejection()); + assertEquals(SEA_LEVEL, plain.lastHead()); + assertEquals(240, plain.pathEnd().x()); + assertTrue(plain.segments().getLast().upstreamHeadY() > SEA_LEVEL); + } + private static SurfaceCourseBuilder builder(HydrologyTerrainSampler sampler) { return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64); } + + private static SurfaceCourseBuilder builder(HydrologyPlannerSettings.Banks banks, HydrologyTerrainSampler sampler) { + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + defaults.enabled(), defaults.sources(), defaults.minimumWidth(), defaults.maximumWidth(), + defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(), banks); + return new SurfaceCourseBuilder(surface, sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java index b39bad0e1..daeb8432d 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java @@ -114,6 +114,58 @@ public class SurfaceFootprintCompilerTest { assertNull(column(footprint, 111, 0)); } + @Test + public void inletColumnsPublishSeaLevelFluidOverALoweredBed() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 100 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(70, 0D, "land"); + HydrologyPlannerSettings.Inlet inlet = HydrologyPlannerSettings.defaults().surface().banks().inlet(); + ArrayList ramp = new ArrayList<>(); + for (int x = 30; x <= 39; x++) { + ramp.add(new HydrologyPoint(x, 69 - (x - 30), 0)); + } + RiverCourse course = course(List.of( + segment(1L, HydrologyFeatureType.SURFACE_POOL, 69, 69, points(0, 30, 69)), + segment(2L, HydrologyFeatureType.CASCADE, 69, SEA_LEVEL, List.copyOf(ramp)), + segment(3L, HydrologyFeatureType.SURFACE_POOL, SEA_LEVEL, SEA_LEVEL, points(39, 99, SEA_LEVEL)), + segment(4L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL, points(99, 101, SEA_LEVEL)) + )); + SurfaceFootprint footprint = compiler(sampler).compile(course); + + assertEquals(0, footprint.uncontainedWetCells()); + assertEquals(3, inlet.depth()); + for (int x = 40; x < 100; x++) { + SurfaceLayerColumn center = column(footprint, x, 0); + assertNotNull("x=" + x, center); + HydrologyColumnLayer layer = center.layer(); + assertTrue(layer.channel()); + assertTrue(layer.fluidOwned()); + assertEquals(SEA_LEVEL, layer.fluidHeadY()); + assertTrue("x=" + x + " bed " + layer.bedY(), layer.bedY() <= SEA_LEVEL - inlet.depth()); + assertEquals(sampler.sample(x, 0).mouthBiomeKey(), layer.mouthBiomeKey()); + } + int shoreZ = -1; + for (int z = 1; z < 16; z++) { + SurfaceLayerColumn column = column(footprint, 90, z); + assertNotNull(column); + if (!column.layer().channel()) { + shoreZ = z; + break; + } + } + assertTrue(shoreZ > 3); + SurfaceLayerColumn shore = column(footprint, 90, shoreZ); + assertTrue(shore.layer().shore()); + assertEquals(SEA_LEVEL, shore.layer().bedY()); + assertEquals(SEA_LEVEL, shore.layer().fluidHeadY()); + for (SurfaceLayerColumn column : footprint.columns()) { + if (column.terrain().ocean()) { + assertTrue(column.layer().oceanApron()); + assertFalse(column.layer().terrainOwned()); + } + } + } + private static SurfaceLayerColumn column(SurfaceFootprint footprint, int x, int z) { for (SurfaceLayerColumn column : footprint.columns()) { if (column.x() == x && column.z() == z) { diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java index ca12b1b42..5e4ee020a 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java @@ -24,18 +24,29 @@ public class ValleyProfileSolverTest { }; @Test - public void flatTerrainHoldsTheHeadOneInsetBelowTheBanks() { + public void flatTerrainHoldsTheHeadAtTheLowestBankByDefault() { ValleyProfile valley = solve(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); assertNull(valley.rejection()); assertEquals(300, valley.exposedStations()); for (int station = 0; station < valley.exposedStations(); station++) { - assertEquals(79, valley.head()[station]); + assertEquals(80, valley.head()[station]); assertEquals(80, valley.crossMin()[station]); assertEquals(80, valley.centerNatural()[station]); } } + @Test + public void aConfiguredSinkHoldsTheHeadThatFarBelowTheLowestBank() { + HydrologyTerrainSampler flat = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + ValleyProfile valley = solver(flat, 2).solve(straight(300), channel(300, flat), SurfaceTerminal.SINKHOLE, 40); + + assertNull(valley.rejection()); + for (int station = 0; station < valley.exposedStations(); station++) { + assertEquals(78, valley.head()[station]); + } + } + @Test public void gentleSlopeProducesOneBlockNonRisingSteps() { ValleyProfile valley = solve(400, (x, z) -> 120 - x / 8, SurfaceTerminal.SINKHOLE, 40); @@ -66,9 +77,9 @@ public class ValleyProfileSolverTest { ValleyProfile valley = solve(300, (x, z) -> x == 150 && z == 6 ? 74 : 80, SurfaceTerminal.SINKHOLE, 40); assertNull(valley.rejection()); - assertEquals(79, valley.head()[100]); - assertTrue(valley.head()[150] <= 73); - assertTrue(valley.head()[299] <= 73); + assertEquals(80, valley.head()[100]); + assertTrue(valley.head()[150] <= 74); + assertTrue(valley.head()[299] <= 74); assertContained(valley); } @@ -87,7 +98,8 @@ public class ValleyProfileSolverTest { assertNull(valley.rejection()); assertTrue(valley.exposedStations() > 200); assertTrue(valley.exposedStations() <= 240); - assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations() - 1]); + assertTrue(valley.head()[valley.exposedStations() - 1] >= SEA_LEVEL); + assertTrue(valley.head()[valley.exposedStations() - 1] <= SEA_LEVEL + 1); assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations()]); assertEquals(SEA_LEVEL, valley.head()[299]); for (int station = 0; station < valley.exposedStations(); station++) { @@ -103,7 +115,8 @@ public class ValleyProfileSolverTest { assertNull(valley.rejection()); assertTrue(valley.exposedStations() <= 200); assertTrue(valley.exposedStations() >= 190); - assertEquals(89, valley.head()[valley.exposedStations() - 1]); + assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations() - 1]); + assertEquals(90, valley.head()[valley.exposedStations() - 1 - HydrologyPlannerSettings.Inlet.defaults().length() * 3 / 2]); assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations()]); } @@ -117,8 +130,8 @@ public class ValleyProfileSolverTest { largest = Math.max(largest, valley.head()[station - 1] - valley.head()[station]); } assertEquals(10, largest); - assertEquals(99, valley.head()[100]); - assertEquals(89, valley.head()[200]); + assertEquals(100, valley.head()[100]); + assertEquals(90, valley.head()[200]); } @Test @@ -142,9 +155,140 @@ public class ValleyProfileSolverTest { assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, valley.rejection()); } + @Test + public void anInletHoldsTheLastStationsAtSeaLevelAndMayCutDeeperThanTheChannelCap() { + HydrologyTerrainSampler coast = risingCoast(); + HydrologyPlannerSettings.Inlet inlet = new HydrologyPlannerSettings.Inlet(32, 3, 32); + + ValleyProfile valley = solver(coast, inlet).solve(straight(300), channel(300, coast), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + ValleyProfile plain = solver(coast, HydrologyPlannerSettings.Inlet.none()) + .solve(straight(300), channel(300, coast), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertNull(valley.rejection()); + assertEquals(240, valley.exposedStations()); + for (int station = 240 - inlet.length(); station < 240; station++) { + assertEquals(SEA_LEVEL, valley.head()[station]); + } + int deepestCut = 0; + for (int station = 0; station < valley.exposedStations(); station++) { + deepestCut = Math.max(deepestCut, valley.centerNatural()[station] - valley.head()[station]); + } + assertTrue("deepest " + deepestCut, deepestCut > 10); + assertContained(valley); + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, plain.rejection()); + assertTrue(plain.rejectionDetail() > 10); + } + + @Test + public void theInletApproachGradesDownOneBlockPerStation() { + HydrologyTerrainSampler plateau = (int x, int z) -> x >= 240 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(88, 0D, "land"); + + ValleyProfile valley = solver(plateau, new HydrologyPlannerSettings.Inlet(32, 3, 32)) + .solve(straight(300), channel(300, plateau), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + ValleyProfile shallow = solver(plateau, new HydrologyPlannerSettings.Inlet(32, 3, 24)) + .solve(straight(300), channel(300, plateau), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertNull(valley.rejection()); + int inletStart = 240 - 32; + assertEquals(88, valley.head()[inletStart - 17]); + for (int station = inletStart - 16; station < inletStart; station++) { + assertEquals(SEA_LEVEL + (inletStart - station), valley.head()[station]); + } + assertEquals(SEA_LEVEL, valley.head()[inletStart]); + assertContained(valley); + // A cap the plateau cannot be cut through keeps the plain crossing: no inlet, no ramp, the + // river still reaches the sea and drops into it at the coast. + assertNull(shallow.rejection()); + assertEquals(88, valley.head()[0]); + for (int station = 0; station < shallow.exposedStations(); station++) { + assertEquals(88, shallow.head()[station]); + } + assertEquals(SEA_LEVEL, shallow.head()[shallow.exposedStations()]); + } + + @Test + public void theInletStopsWhereTheCoastRisesBeyondItsCap() { + HydrologyTerrainSampler bluff = (int x, int z) -> x >= 240 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(x >= 220 ? 66 + (x - 220) / 5 : 110, 0D, "land"); + + ValleyProfile valley = solver(bluff, new HydrologyPlannerSettings.Inlet(64, 3, 32)) + .solve(straight(300), channel(300, bluff), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertNull(valley.rejection()); + assertEquals(240, valley.exposedStations()); + for (int station = 220; station < 240; station++) { + assertEquals(SEA_LEVEL, valley.head()[station]); + } + assertEquals(110, valley.head()[219]); + assertEquals(110, valley.head()[100]); + assertContained(valley); + } + + @Test + public void upstreamStationsKeepTheChannelCap() { + HydrologyTerrainSampler ridge = (int x, int z) -> x >= 240 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(x >= 30 && x < 50 ? 94 : 80, 0D, "land"); + + ValleyProfile valley = solver(ridge, new HydrologyPlannerSettings.Inlet(32, 3, 32)) + .solve(straight(300), channel(300, ridge), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, valley.rejection()); + assertTrue(valley.rejectionDetail() > 10); + } + + @Test + public void theInletNeverTakesMoreThanHalfOfAShortCourse() { + HydrologyTerrainSampler coast = (int x, int z) -> x >= 80 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(70, 0D, "land"); + + ValleyProfile valley = solver(coast, new HydrologyPlannerSettings.Inlet(64, 3, 32)) + .solve(straight(100), channel(100, coast), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertNull(valley.rejection()); + assertEquals(80, valley.exposedStations()); + for (int station = 40; station < 80; station++) { + assertEquals(SEA_LEVEL, valley.head()[station]); + } + assertEquals(SEA_LEVEL + 1, valley.head()[39]); + assertEquals(SEA_LEVEL + 10, valley.head()[30]); + for (int station = 0; station < 20; station++) { + assertEquals(70, valley.head()[station]); + } + assertContained(valley); + } + + /** Land falling gently to 66 at x=200, then a coast rising to 83 over the last forty blocks before the sea at x=240. */ + private static HydrologyTerrainSampler risingCoast() { + return (int x, int z) -> { + if (x >= 240) { + return HydrologyTerrainSample.ocean(50, "ocean"); + } + int height = x < 200 ? 100 - x / 6 : 66 + (x - 200) * 18 / 40; + return HydrologyTerrainSample.openLand(height, 0D, "land"); + }; + } + + private static ValleyProfileSolver solver(HydrologyTerrainSampler sampler, HydrologyPlannerSettings.Inlet inlet) { + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Banks banks = defaults.banks(); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + defaults.enabled(), defaults.sources(), defaults.minimumWidth(), defaults.maximumWidth(), + defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(), + new HydrologyPlannerSettings.Banks(banks.sink(), banks.blendSlope(), banks.minimumBlendWidth(), banks.maximumBlendWidth(), + banks.roughness(), banks.roughnessWavelength(), banks.cascadeRun(), banks.waterfallMinimumDrop(), + banks.mouthFlareRatio(), inlet, banks.springWidthRatio(), banks.springLength(), banks.exposeCutStrata(), + banks.erosion(), banks.ponds())); + return new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64); + } + private static void assertContained(ValleyProfile valley) { for (int station = 0; station < valley.exposedStations(); station++) { - assertTrue(valley.head()[station] <= valley.crossMin()[station] - 1); + assertTrue(valley.head()[station] <= valley.crossMin()[station]); if (station > 0) { assertTrue(valley.head()[station] <= valley.head()[station - 1]); } @@ -160,6 +304,19 @@ public class ValleyProfileSolverTest { return new ValleyProfileSolver(HydrologyPlannerSettings.defaults().surface(), sampler, SEA_LEVEL, 64); } + private static ValleyProfileSolver solver(HydrologyTerrainSampler sampler, int sink) { + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + HydrologyPlannerSettings.Banks banks = defaults.banks(); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + defaults.enabled(), defaults.sources(), defaults.minimumWidth(), defaults.maximumWidth(), + defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(), + new HydrologyPlannerSettings.Banks(sink, banks.blendSlope(), banks.minimumBlendWidth(), banks.maximumBlendWidth(), + banks.roughness(), banks.roughnessWavelength(), banks.cascadeRun(), banks.waterfallMinimumDrop(), + banks.mouthFlareRatio(), HydrologyPlannerSettings.Inlet.none(), banks.springWidthRatio(), banks.springLength(), banks.exposeCutStrata(), + banks.erosion(), banks.ponds())); + return new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64); + } + private static ChannelProfile channel(int stations, HydrologyTerrainSampler sampler) { return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY) .build(straight(stations), "water", false); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java index 06aac6374..65ec5b232 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java @@ -1553,7 +1553,8 @@ public class MantleHydrologyComponentTest { 1, 2, 2, - false + false, + 0 ), new HydrologyPlannerSettings.Outlets( true, @@ -1563,10 +1564,13 @@ public class MantleHydrologyComponentTest { 32, 16, 1, + 8, 8 ), HydrologyPlannerSettings.Geometry.defaults(), - List.of(), List.of() + List.of(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -1603,7 +1607,9 @@ public class MantleHydrologyComponentTest { "shore", "dry", "flooded", - List.of("default"), List.of() + List.of("default"), List.of(), + Double.NaN, + null ); } diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java index 9b3b7c057..0cf51287f 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java @@ -50,11 +50,10 @@ public class IrisRiverConfigurationTest { assertEquals(8D, surface.getChannel().getWidth().getMax(), 0D); assertEquals(2D, surface.getChannel().getDepth().getMin(), 0D); assertEquals(4D, surface.getChannel().getDepth().getMax(), 0D); - assertEquals(1, surface.getChannel().getInset()); + assertEquals(0, surface.getChannel().getSink()); assertEquals(10, surface.getChannel().getMaximumIncision()); assertEquals(0.25D, surface.getChannel().getRoughness(), 0D); assertEquals(16, surface.getChannel().getRoughnessWavelength()); - assertEquals(1, surface.getBanks().getFreeboard()); assertEquals(1.5D, surface.getBanks().getShoreWidth(), 0D); assertEquals(3D, surface.getBanks().getBlendSlope(), 0D); assertEquals(4, surface.getBanks().getMinimumBlendWidth()); @@ -154,13 +153,12 @@ public class IrisRiverConfigurationTest { "channel": { "width": {"min": 5, "max": 24}, "depth": {"min": 2, "max": 6}, - "inset": 2, + "sink": 2, "maximumIncision": 14, "roughness": 0.4, "roughnessWavelength": 24 }, "banks": { - "freeboard": 2, "shoreWidth": 2.5, "blendSlope": 4, "minimumBlendWidth": 6, @@ -262,11 +260,10 @@ public class IrisRiverConfigurationTest { assertEquals(0.4D, rivers.getRouting().getConfluenceAttraction(), 0D); assertEquals(new KList<>(IrisRiverInlandOutlet.SINKHOLE_GROTTO), rivers.getRouting().getInlandOutlets()); assertEquals(24D, rivers.getSurface().getChannel().getWidth().getMax(), 0D); - assertEquals(2, rivers.getSurface().getChannel().getInset()); + assertEquals(2, rivers.getSurface().getChannel().getSink()); assertEquals(14, rivers.getSurface().getChannel().getMaximumIncision()); assertEquals(0.4D, rivers.getSurface().getChannel().getRoughness(), 0D); assertEquals(24, rivers.getSurface().getChannel().getRoughnessWavelength()); - assertEquals(2, rivers.getSurface().getBanks().getFreeboard()); assertEquals(2.5D, rivers.getSurface().getBanks().getShoreWidth(), 0D); assertEquals(4D, rivers.getSurface().getBanks().getBlendSlope(), 0D); assertEquals(6, rivers.getSurface().getBanks().getMinimumBlendWidth()); diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java index e4e1a5288..5d5b26b06 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java @@ -1705,7 +1705,7 @@ public final class HydrologyPackProbe { List incisions = channelIncisions( tile.footprint(), scopedCourseIds, - settings.surface().banks().inset(), + settings.surface().banks().sink(), settings.surface().maximumIncision() ); for (ChannelIncision incision : incisions) { @@ -2698,7 +2698,9 @@ public final class HydrologyPackProbe { int deepCourses, int features, int coveragePairs, - long engineReadyNanos + long engineReadyNanos, + int surfaceJunctions, + int undergroundJunctions ) { } @@ -2896,7 +2898,7 @@ public final class HydrologyPackProbe { Locale.ROOT, "IRIS_HYDROLOGY_PACK_SEED seed=%d hydrology_seed=%d tiles=%d courses=%d " + "surface_courses=%d underground_courses=%d deep_courses=%d features=%d " - + "pairs=%d engine_ready_ms=%.3f%n", + + "pairs=%d engine_ready_ms=%.3f surface_junctions=%d underground_junctions=%d%n", summary.seed(), summary.hydrologySeed(), summary.tiles(), @@ -2906,7 +2908,9 @@ public final class HydrologyPackProbe { summary.deepCourses(), summary.features(), summary.coveragePairs(), - summary.engineReadyNanos() / 1_000_000D + summary.engineReadyNanos() / 1_000_000D, + summary.surfaceJunctions(), + summary.undergroundJunctions() ); } shapeMetrics.requireOrganicSurfaceNetwork(); @@ -2976,6 +2980,8 @@ public final class HydrologyPackProbe { int undergroundCourses = 0; int deepCourses = 0; int features = 0; + int surfaceJunctions = 0; + int undergroundJunctions = 0; long hydrologySeed; long engineReadyNanos; Set seedCoverage = new HashSet<>(); @@ -3008,10 +3014,57 @@ public final class HydrologyPackProbe { shapeMetrics.observe(tile, runtime.settings()); List orderedColumns = null; courses += tile.courses().size(); + List tileJunctions = junctions(tile); + int tileSurfaceCourses = 0; + int tileUndergroundCourses = 0; + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.SURFACE) { + tileSurfaceCourses++; + } else if (course.type() == RiverCourseType.UNDERGROUND) { + tileUndergroundCourses++; + } + } + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_TILE seed=%d tile_x=%d tile_z=%d courses=%d surface_courses=%d " + + "underground_courses=%d junctions=%d%n", + seed, + tileKey.tileX(), + tileKey.tileZ(), + tile.courses().size(), + tileSurfaceCourses, + tileUndergroundCourses, + tileJunctions.size() + ); + for (Junction junction : tileJunctions) { + if (junction.type() == RiverCourseType.SURFACE) { + surfaceJunctions++; + } else { + undergroundJunctions++; + } + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_JUNCTION seed=%d tile_x=%d tile_z=%d type=%s x=%d y=%d z=%d " + + "tributary=%016x stem=%016x tributary_stations=%d%n", + seed, + tileKey.tileX(), + tileKey.tileZ(), + junction.type().name(), + junction.point().x(), + junction.point().y(), + junction.point().z(), + junction.tributaryId(), + junction.stemId(), + junction.tributaryStations() + ); + } HashMap profilesByCourse = new HashMap<>(tile.courses().size()); for (RiverCourse course : tile.courses()) { profilesByCourse.put(course.id(), course.profileKey()); - observedConfiguredCoverage.add(courseCoverage(course)); + ConfiguredCoverage courseCoverage = courseCoverage(course); + if (courseCoverage != null) { + observedConfiguredCoverage.add(courseCoverage); + } if (course.type() == RiverCourseType.SURFACE) { surfaceCourses++; } else if (course.type() == RiverCourseType.UNDERGROUND) { @@ -3140,10 +3193,61 @@ public final class HydrologyPackProbe { deepCourses, features, seedCoverage.size(), - engineReadyNanos + engineReadyNanos, + surfaceJunctions, + undergroundJunctions )); } + record Junction( + RiverCourseType type, + HydrologyPoint point, + long tributaryId, + long stemId, + int tributaryStations + ) { + } + + /** + * A junction is a course whose last centerline point lies on, or within a channel width of, a + * station of another course of the same type: the tributary ends on its stem. + */ + static List junctions(HydrologyTile tile) { + ArrayList junctions = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.segments().isEmpty()) { + continue; + } + HydrologyPoint end = course.segments().getLast().end(); + int stations = 0; + for (HydraulicSegment segment : course.segments()) { + stations += segment.centerline().size(); + } + Long stemId = null; + double stemDistance = Double.POSITIVE_INFINITY; + for (RiverCourse stem : tile.courses()) { + if (stem.id() == course.id() || stem.type() != course.type()) { + continue; + } + for (HydraulicSegment segment : stem.segments()) { + double reach = segment.width() / 2D + 1D; + for (HydrologyPoint point : segment.centerline()) { + double distance = point.distanceSquared2D(end); + if (distance <= reach * reach && distance < stemDistance) { + stemId = stem.id(); + stemDistance = distance; + } + } + } + } + if (stemId != null) { + junctions.add(new Junction(course.type(), end, course.id(), stemId, stations)); + } + } + junctions.sort(Comparator.comparingLong(Junction::tributaryId)); + return List.copyOf(junctions); + } + // No public production equivalent: HydrologyPlanner.routeTurnDegrees is private. static double turnDegrees(HydrologyPoint previous, HydrologyPoint point, HydrologyPoint next) { double incomingX = point.x() - previous.x(); @@ -4082,8 +4186,10 @@ public final class HydrologyPackProbe { case UNDERGROUND -> CoverageFamily.UNDERGROUND; case DEEP_FLUID -> CoverageFamily.DEEP; case SURFACE_POOL -> CoverageFamily.POOL; + // A sea cave is not a source-budgeted course family; its coverage is the coastal grotto feature. + case SEA_CAVE -> null; }; - return new ConfiguredCoverage(family, course.profileKey()); + return family == null ? null : new ConfiguredCoverage(family, course.profileKey()); } private static void validateTileBlockBounds( diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java index 4ba6b8810..d0f8f5496 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java @@ -228,8 +228,10 @@ public final class HydrologyPlannerProbe { 0.5D, 12D, 0.5D, - 0.1D - , 1D, 0), + 0.1D, + 1D, + 0 + ), new HydrologyPlannerSettings.Surface( true, surfaceSources, @@ -253,7 +255,8 @@ public final class HydrologyPlannerProbe { 4, 5, 9, - true + true, + 0 ), new HydrologyPlannerSettings.Outlets( false, @@ -263,10 +266,13 @@ public final class HydrologyPlannerProbe { 12, 32, 2, + 4, 4 ), HydrologyPlannerSettings.Geometry.defaults(), - List.of(), List.of() + List.of(), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -306,7 +312,9 @@ public final class HydrologyPlannerProbe { "dry", "flooded", List.of("water"), List.of() - ); + , + Double.NaN, + null); }; } @@ -357,7 +365,9 @@ public final class HydrologyPlannerProbe { "bank", "flooded", List.of("water"), List.of() - ); + , + Double.NaN, + null); }; } @@ -439,8 +449,10 @@ public final class HydrologyPlannerProbe { 0.5D, 12D, 0.5D, - 0.2D - , 1D, 0), + 0.2D, + 1D, + 0 + ), new HydrologyPlannerSettings.Surface( true, surfaceSources, @@ -464,7 +476,8 @@ public final class HydrologyPlannerProbe { 5, 6, 12, - true + true, + 0 ), new HydrologyPlannerSettings.Outlets( true, @@ -474,10 +487,13 @@ public final class HydrologyPlannerProbe { 12, 48, 2, + 8, 8 ), HydrologyPlannerSettings.Geometry.defaults(), - List.of(deepFluid), List.of() + List.of(deepFluid), List.of(), + 0D, + HydrologyPlannerSettings.SeaCaves.disabled() ); } @@ -522,7 +538,9 @@ public final class HydrologyPlannerProbe { "shore", "dry", "flooded", - List.of("water"), List.of() + List.of("water"), List.of(), + Double.NaN, + null ); }; } @@ -556,7 +574,9 @@ public final class HydrologyPlannerProbe { "dry", "flooded", List.of("water"), List.of() - ); + , + Double.NaN, + null); } private static long[] parseSeeds(String[] arguments) { diff --git a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java index 559a8fe74..321009560 100644 --- a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java @@ -28,6 +28,7 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -179,6 +180,18 @@ public final class RiverTransectProbe { System.out.println(String.format(Locale.ROOT, "%s tile=%d,%d seed=%d courses=%d planMs=%.1f", PREFIX, key.tileX(), key.tileZ(), configuration.seed(), tile.courses().size(), planMillis)); + for (RiverCourse course : tile.courses()) { + if (course.type() != RiverCourseType.SEA_CAVE) { + continue; + } + HydraulicSegment chamber = course.segments().getFirst(); + System.out.println(String.format(Locale.ROOT, + "%s seaCave id=%016x inner=%d,%d,%d mouth=%d,%d,%d cavePlan=%s", + PREFIX, course.id(), + chamber.start().x(), chamber.start().y(), chamber.start().z(), + chamber.end().x(), chamber.end().y(), chamber.end().z(), + tile.cavePlan(course.id()).map((plan) -> plan.accepted() ? "accepted" : "rejected").orElse("none"))); + } for (String line : rejectionLines(tile)) { System.out.println(PREFIX + " " + line); @@ -457,8 +470,28 @@ public final class RiverTransectProbe { line(image, (from.naturalPoint().x() - minimumX) / scale, (from.naturalPoint().z() - minimumZ) / scale, (to.naturalPoint().x() - minimumX) / scale, (to.naturalPoint().z() - minimumZ) / scale, 0x20C020); } + // Sea-cave chambers: every column the chamber owns in dark blue, so the cave shows on the coast with + // its open side to the sea; the centerline on top in white like a surface course. + HashSet seaCaveIds = new HashSet<>(); for (RiverCourse course : tile.courses()) { - int rgb = course.type() == RiverCourseType.SURFACE ? 0xFFFFFF : 0xFF00FF; + if (course.type() == RiverCourseType.SEA_CAVE) { + seaCaveIds.add(course.id()); + } + } + if (!seaCaveIds.isEmpty()) { + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (seaCaveIds.contains(layer.feature().courseId()) && layer.terrainOwned() && layer.channel()) { + plot(image, (column.x() - minimumX) / scale, (column.z() - minimumZ) / scale, 0x000080, 0); + break; + } + } + } + } + for (RiverCourse course : tile.courses()) { + int rgb = course.type() == RiverCourseType.SURFACE || course.type() == RiverCourseType.SEA_CAVE + ? 0xFFFFFF + : 0xFF00FF; for (HydraulicSegment segment : course.segments()) { for (HydrologyPoint point : segment.centerline()) { plot(image, (point.x() - minimumX) / scale, (point.z() - minimumZ) / scale, rgb, 1);