From 30c533feaf41f29cc1e3524020dfcf7aedeaf739 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 22 Sep 2026 17:04:56 -0400 Subject: [PATCH] Tunabelly --- .../world/IrisWorldGeneratorResolver.java | 4 +- .../ModdedGenerationHistoryStorage.java | 4 +- .../iris/modded/ModdedWorldEngines.java | 1 + .../hydrology/FootprintLayerShape.java | 3 +- .../hydrology/FootprintMutableColumn.java | 2 +- .../hydrology/HydrologyCacheBudget.java | 5 +- .../hydrology/HydrologyCacheWeights.java | 7 +- .../hydrology/HydrologyCaveCourseFilter.java | 6 +- .../hydrology/HydrologyChannelGeometry.java | 19 +- .../hydrology/HydrologyDrainageGeometry.java | 280 +++++++++ .../hydrology/HydrologyDrainageGraph.java | 50 ++ .../hydrology/HydrologyFootprintCompiler.java | 3 + .../hydrology/HydrologyPlanner.java | 3 - .../hydrology/HydrologyPlannerSettings.java | 6 +- .../hydrology/HydrologyRegionalGraph.java | 49 +- .../HydrologyRegionalHydraulics.java | 125 ---- .../hydrology/HydrologyRegionalPlanner.java | 9 +- .../hydrology/HydrologyRegionalRoute.java | 73 +-- .../hydrology/HydrologyRegionalTerrain.java | 66 ++ .../HydrologyRegionalTerrainRefiner.java | 514 ---------------- .../hydrology/HydrologyRouteGeometry.java | 564 +----------------- .../hydrology/HydrologyRoutePath.java | 13 - .../hydrology/HydrologyRoutingPlan.java | 3 +- .../hydrology/HydrologySampledGrid.java | 81 ++- .../hydrology/HydrologySourcePlanner.java | 179 +++--- .../HydrologySurfaceCoursePlanner.java | 506 +--------------- .../hydrology/HydrologySurfaceDropRaster.java | 7 +- .../hydrology/HydrologyTileCache.java | 159 +++-- .../hydrology/HydrologyTributaryPlanner.java | 8 - .../hydrology/IrisRiverExcavationConfig.java | 6 +- .../hydrology/IrisSurfaceRiverBankConfig.java | 4 +- .../IrisSurfaceRiverChannelConfig.java | 4 +- ...e.java => PreparedHydrologyTileStore.java} | 23 +- .../generation/hydrology/RefinedEdgeKey.java | 3 +- .../hydrology/RouteTerrainEstimate.java | 4 - .../generation/hydrology/SurfaceRouteKey.java | 6 - .../runtime/IrisHydrologyRuntime.java | 4 +- .../surface/ErosionFieldCompiler.java | 138 +++-- .../hydrology/surface/RiverProfileSolver.java | 169 ++++++ .../surface/SurfaceCourseBuilder.java | 70 ++- .../surface/SurfaceFootprintCompiler.java | 35 +- .../hydrology/surface/SurfaceInletRange.java | 104 ++++ .../mantle/MantleObjectComponent.java | 22 +- .../mantle/ObjectDestinationTransaction.java | 4 +- .../iris/generation/runtime/Engine.java | 11 +- .../generation/runtime/EngineHotloader.java | 3 + .../runtime/EngineRuntimeBuilder.java | 25 +- .../iris/generation/runtime/IrisComplex.java | 10 +- .../iris/generation/runtime/IrisEngine.java | 48 +- .../PreparedHydrologyCacheIdentity.java | 118 ++++ .../terrain/HydrologyBankTerrainRuntime.java | 3 +- .../terrain/IrisDimensionCarvingResolver.java | 65 +- .../generation/terrain/Terrain3DRuntime.java | 7 +- .../iris/pack/PackValidationRegistry.java | 39 +- .../arcane/iris/pack/loading/IrisData.java | 4 + .../generation/BukkitChunkGenerator.java | 4 +- .../art/arcane/iris/studio/StudioSVC.java | 13 +- .../iris/world/pregen/AsyncPregenMethod.java | 21 +- .../arcane/iris/world/pregen/PregenTask.java | 20 +- .../hydrology/HydrologyCacheBudgetTest.java | 6 +- .../HydrologyCoastalFallAdmissionTest.java | 66 ++ .../HydrologyDrainageGeometryTest.java | 191 ++++++ .../HydrologyFallbackDiagnosticsTest.java | 2 +- .../HydrologyFallbackParallelismTest.java | 2 +- .../HydrologyFootprintCompilerTest.java | 25 +- .../hydrology/HydrologyModelTest.java | 24 + .../hydrology/HydrologyPlanHashTest.java | 2 +- .../hydrology/HydrologyPlannerTest.java | 103 ++-- .../HydrologyRegionalBankSupportTest.java | 71 +-- .../hydrology/HydrologyRegionalCacheTest.java | 59 +- ...drologyRegionalCandidateSelectionTest.java | 11 +- .../hydrology/HydrologyRegionalFallsTest.java | 2 +- .../HydrologyRegionalMouthRefinementTest.java | 10 +- .../HydrologyRegionalSamplingTest.java | 179 +----- .../HydrologyRegionalSkippedWaypointTest.java | 169 ------ ...ydrologyRegionalSourceParallelismTest.java | 2 +- .../HydrologyRegionalSourceTrialsTest.java | 2 +- .../HydrologyRegionalSurfacePolicyTest.java | 2 +- .../HydrologyRegionalTerrainRefinerTest.java | 541 ----------------- .../HydrologyRegionalTerrainTest.java | 192 ++++++ .../HydrologyRegionalWidthSupportTest.java | 130 +--- .../HydrologyRouteHeightProbeTest.java | 22 - .../HydrologyRouteTurnCacheTest.java | 254 -------- .../hydrology/HydrologySourceSpacingTest.java | 2 +- .../HydrologyStyledGeometryTest.java | 1 + .../HydrologySurfaceBankProbeTest.java | 145 ----- .../HydrologySurfaceDropRasterTest.java | 71 ++- .../HydrologySurfaceRouteGeometryTest.java | 89 +-- .../hydrology/HydrologyTileCacheTest.java | 178 +++++- ...va => PreparedHydrologyTileStoreTest.java} | 31 +- .../surface/ErosionFieldCompilerTest.java | 202 ++++++- .../surface/RiverFirstTerrainTest.java | 120 ++++ .../surface/RiverProfileSolverTest.java | 112 ++++ .../surface/SurfaceBendProfileTest.java | 9 +- .../surface/SurfaceCourseBuilderTest.java | 71 ++- .../surface/SurfaceFootprintCompilerTest.java | 142 ++++- .../surface/ValleyProfileSolverTest.java | 13 +- ...ntleObjectComponentBoundaryRadiusTest.java | 12 +- ...jectComponentPlacementDeterminismTest.java | 207 +++++++ .../ObjectDestinationTransactionTest.java | 29 + .../IrisComplexBoundsBiomeCacheTest.java | 67 +++ .../IrisEngineGenerationRuntimeScopeTest.java | 4 +- .../PreparedHydrologyCacheIdentityTest.java | 171 ++++++ .../HydrologyBankTerrainRuntimeTest.java | 44 ++ ...risDimensionCarvingResolverParityTest.java | 52 ++ .../terrain/Terrain3DRuntimeTest.java | 30 + .../iris/pack/PackValidationRegistryTest.java | 87 ++- .../schema/SchemaBuilderIdentityTest.java | 2 +- ...ukkitChunkGeneratorEntryMulticoreTest.java | 156 +++++ ...BukkitChunkGeneratorHistoryTargetTest.java | 33 + .../studio/StudioSVCWorldPackPublishTest.java | 63 +- .../pregen/PregenTaskBoundsOverflowTest.java | 59 +- .../iris/probe/HydrologyBlockProbe.java | 12 +- .../iris/probe/HydrologyBlockProbeTest.java | 48 ++ 114 files changed, 4071 insertions(+), 3994 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGeometry.java create mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGraph.java create mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrain.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java rename core/src/main/java/art/arcane/iris/generation/hydrology/{StudioHydrologyTileStore.java => PreparedHydrologyTileStore.java} (94%) delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/RouteTerrainEstimate.java delete mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/SurfaceRouteKey.java create mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/surface/RiverProfileSolver.java create mode 100644 core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceInletRange.java create mode 100644 core/src/main/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentity.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCoastalFallAdmissionTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGeometryTest.java delete mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSkippedWaypointTest.java delete mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefinerTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainTest.java delete mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteTurnCacheTest.java delete mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java rename core/src/test/java/art/arcane/iris/generation/hydrology/{StudioHydrologyTileStoreTest.java => PreparedHydrologyTileStoreTest.java} (89%) create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/surface/RiverFirstTerrainTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/hydrology/surface/RiverProfileSolverTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentPlacementDeterminismTest.java create mode 100644 core/src/test/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentityTest.java create mode 100644 core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorEntryMulticoreTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java index 992576779..19d1a2a3d 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/world/IrisWorldGeneratorResolver.java @@ -210,7 +210,7 @@ public final class IrisWorldGeneratorResolver { packDirs = validation.packDirs(); contentSnapshot = validation.contentSnapshot(); results = validation.results(); - if (validation.stable()) { + if (validation.stable() && contextFingerprint.equals(PackValidationCache.contextFingerprint())) { try { PackValidationCache.save( cacheFile, @@ -229,7 +229,7 @@ public final class IrisWorldGeneratorResolver { String packFingerprint = packFingerprints.get(result.getPackName()); File packDirectory = PackDirectoryResolver.resolveExisting(packsRoot, result.getPackName()); if (packDirectory != null && packFingerprint != null && !packFingerprint.isBlank()) { - PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint); + PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint, contextFingerprint); } String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; String compatSummary = PackValidator.compatSummary(result, minecraftVersion); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java index 175efa222..41bb69213 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedGenerationHistoryStorage.java @@ -6,6 +6,7 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.pack.BrokenPackException; +import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidator; @@ -184,8 +185,9 @@ final class ModdedGenerationHistoryStorage { } private static void validatePack(Path packRoot, String fingerprint) { + String contextFingerprint = PackValidationCache.contextFingerprint(); PackValidationResult validation = PackValidator.validate(packRoot.toFile()); - PackValidationRegistry.publish(packRoot, validation, fingerprint); + PackValidationRegistry.publish(packRoot, validation, fingerprint, contextFingerprint); if (!validation.isLoadable()) { throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index fa7fcf731..30bd305a4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -266,6 +266,7 @@ public final class ModdedWorldEngines { TransitionGenerationPlan transitionPlan = activation.isInitial() ? null : history.transitionPlan(activation.activationId()); + data.bindGenerationRegistryContract(epoch.registryContract()); IrisEngine engine = new IrisEngine( new EngineTarget(world, dimension, data), IrisEngine.InitializationMode.RUNTIME, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/FootprintLayerShape.java b/core/src/main/java/art/arcane/iris/generation/hydrology/FootprintLayerShape.java index 1fb87c383..10893843b 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/FootprintLayerShape.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/FootprintLayerShape.java @@ -11,7 +11,8 @@ record FootprintLayerShape( boolean archedChannel, boolean roundedSurfaceBed, boolean organicBoundary, - boolean fallingThroat + boolean fallingThroat, + boolean inlet ) { double totalRadius() { return channelRadius + shoreWidth + gradingWidth; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/FootprintMutableColumn.java b/core/src/main/java/art/arcane/iris/generation/hydrology/FootprintMutableColumn.java index 93ee43991..beeee8e1d 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/FootprintMutableColumn.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/FootprintMutableColumn.java @@ -159,7 +159,7 @@ final class FootprintMutableColumn { first.connectedFluid() || second.connectedFluid(), first.fallingFluid() || second.fallingFluid(), first.receivingPool() || second.receivingPool(), - (first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY < naturalHeight), + (first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY != naturalHeight), first.fluidOwned() || second.fluidOwned(), first.oceanApron() || second.oceanApron(), first.profileKey(), diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudget.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudget.java index bcc4d1abc..04f0c7997 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudget.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudget.java @@ -46,7 +46,7 @@ final class HydrologyCacheBudget { } long routingBytes() { - return regionalEnabled ? plannerBytes / 8L : plannerBytes * 3L / 16L; + return plannerBytes * 3L / 16L; } long ownerBytes() { @@ -69,7 +69,4 @@ final class HydrologyCacheBudget { return regionalEnabled ? plannerBytes / 4L : 0L; } - long regionalReachBytes() { - return regionalEnabled ? plannerBytes / 16L : 0L; - } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheWeights.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheWeights.java index 69674efdb..3202b868a 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheWeights.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCacheWeights.java @@ -69,7 +69,7 @@ final class HydrologyCacheWeights { static int regionalCoarse(HydrologyRegionalCoarsePlan plan) { HydrologyRegionalGraph.Tree tree = plan.tree(); long treeBytes = tree == null ? 0L : 96L + (long) tree.selected().length * 8L - + (long) tree.accumulation().length * 4L + tree.retainedLabelCount() * 96L; + + ((long) tree.accumulation().length + tree.heads().length) * 4L + tree.retainedLabelCount() * 96L; return saturated(ENTRY_BYTES + grid(plan.grid()) + treeBytes + (long) plan.outlets().size() * 224L + (long) plan.sources().size() * 32L + (long) plan.envelopes().size() * 64L); } @@ -79,7 +79,7 @@ final class HydrologyCacheWeights { } private static long grid(HydrologySampledGrid grid) { - return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES); + return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES + 104L); } private static long routingPlan(HydrologyRoutingPlan plan) { @@ -88,7 +88,8 @@ final class HydrologyCacheWeights { } return 128L + (long) plan.potential().length * 8L + ((long) plan.parent().length + plan.outletIndex().length + plan.routeLengths().length) * 4L - + (long) plan.outlets().size() * 224L; + + (long) plan.outlets().size() * 224L + + (plan.surfaceDrainage() == null ? 0L : plan.surfaceDrainage().maximumRetainedBytes()); } private static long network(List nodes, List edges, List outlets, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCaveCourseFilter.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCaveCourseFilter.java index d44d8abb9..8f94648ac 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCaveCourseFilter.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyCaveCourseFilter.java @@ -1194,8 +1194,12 @@ final class HydrologyCaveCourseFilter { HydraulicSegment segment = segments.get(index); if (segment.type().isSurface() && segment.fallingFluid()) { int radius = Math.max(1, segment.width() / 2) + 1; + HydraulicSegment receiving = index + 1 < segments.size() ? segments.get(index + 1) : null; + boolean coastal = receiving != null && receiving.type() == HydrologyFeatureType.MOUTH + && receiving.upstreamHeadY() == segment.downstreamHeadY() + && receiving.start().equals(segment.end()); openings.add(CaveSurfaceOpening.create(segment, true, (long) radius * radius, - segment.downstreamHeadY() + 2, true)); + segment.downstreamHeadY() + (coastal ? 0 : 2), true)); continue; } if (!segment.type().isUnderground()) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChannelGeometry.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChannelGeometry.java index c9ca51c8f..f30bdbd62 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChannelGeometry.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyChannelGeometry.java @@ -1,9 +1,12 @@ package art.arcane.iris.generation.hydrology; import art.arcane.iris.generation.hydrology.surface.SurfaceNoise; +import art.arcane.iris.generation.hydrology.surface.SurfaceInletRange; final class HydrologyChannelGeometry { private final HydrologyFootprintCompiler compiler; + private RiverCourse inletCourse; + private SurfaceInletRange inletRange; HydrologyChannelGeometry(HydrologyFootprintCompiler compiler) { this.compiler = compiler; @@ -141,10 +144,24 @@ final class HydrologyChannelGeometry { archedChannel, roundedSurfaceBed, organicBoundary, - falling + falling, + inletStation(course, segment, point) ); } + private boolean inletStation(RiverCourse course, HydraulicSegment segment, HydrologyPoint point) { + if (course.type() != RiverCourseType.SURFACE || !segment.type().isSurface() || !segment.fallingFluid()) { + return false; + } + if (inletCourse != course) { + HydrologyTerrainSampler receiving = HydrologyOceanReceiver.forCourse( + compiler.settings, compiler::sampleTerrainBasis, course); + inletRange = SurfaceInletRange.forCourse(compiler.settings, course, receiving); + inletCourse = course; + } + return inletRange.allowsFallingStation(segment.id(), point.x(), point.z()); + } + int organicVerticalVariation( long seed, int x, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGeometry.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGeometry.java new file mode 100644 index 000000000..a6fb31b20 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGeometry.java @@ -0,0 +1,280 @@ +package art.arcane.iris.generation.hydrology; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReferenceArray; + +final class HydrologyDrainageGeometry { + private final HydrologyPlanner planner; + private final Basin basin; + private final int[] heads; + private final AtomicReferenceArray anchors; + private final AtomicReferenceArray>> edges; + + HydrologyDrainageGeometry(HydrologyPlanner planner, Basin basin) { + this.planner = planner; + this.basin = basin; + this.heads = new int[basin.grid().nodes().size()]; + this.anchors = new AtomicReferenceArray<>(heads.length); + this.edges = new AtomicReferenceArray<>(heads.length); + Arrays.fill(heads, Integer.MIN_VALUE); + planHeads(); + } + + int head(int index) { + return heads[index]; + } + + HydrologyPoint anchor(int index) { + HydrologyPoint existing = anchors.get(index); + if (existing != null) { + return existing; + } + HydrologyGridNode node = basin.grid().node(index); + HydrologyPoint point; + if (basin.parent()[index] < 0 && basin.outletIndex()[index] >= 0) { + HydrologyPoint landward = basin.outlets().get(basin.outletIndex()[index]).outlet().landwardPoint(); + point = new HydrologyPoint(landward.x(), heads[index], landward.z()); + } else { + point = displacedAnchor(node); + } + anchors.compareAndSet(index, null, point); + return anchors.get(index); + } + + List edge(int index) { + DraftProfile profile = planner.currentDraftProfile(); + profile.routeCalls++; + CompletableFuture> future = edges.get(index); + if (future == null) { + CompletableFuture> owned = new CompletableFuture<>(); + CompletableFuture> existing = edges.compareAndExchange(index, null, owned); + if (existing == null) { + long started = System.nanoTime(); + profile.routeSolves++; + try { + List points = compile(index); + owned.complete(points); + return points; + } catch (Throwable failure) { + owned.completeExceptionally(failure); + edges.compareAndSet(index, owned, null); + throw HydrologyPlanner.propagateOwnerFailure(failure); + } finally { + profile.routeSolveNanos += System.nanoTime() - started; + } + } + future = existing; + } + try { + return future.join(); + } catch (CompletionException failure) { + throw HydrologyPlanner.propagateOwnerFailure(failure.getCause()); + } + } + + long maximumRetainedBytes() { + int stations = Math.ceilDiv(basin.grid().spacing() * 3, planner.settings.routing().refinementSpacing()) + 2; + return (long) heads.length * (112L + stations * 40L); + } + + private void planHeads() { + int[] path = new int[heads.length]; + int[] nominal = new int[heads.length]; + for (int index = 0; index < heads.length; index++) { + if (heads[index] != Integer.MIN_VALUE || basin.outletIndex()[index] < 0) { + continue; + } + int current = index; + int size = 0; + while (current >= 0 && heads[current] == Integer.MIN_VALUE) { + if (size == path.length) { + throw new IllegalStateException("Surface drainage must be acyclic."); + } + path[size++] = current; + current = basin.parent()[current]; + } + int downstream = current < 0 ? Integer.MIN_VALUE : heads[current]; + while (size > 0) { + int node = path[--size]; + HydrologyTerrainSample terrain = basin.grid().node(node).terrain(); + if (basin.parent()[node] < 0) { + HydrologyPoint landward = basin.outlets().get(basin.outletIndex()[node]).outlet().landwardPoint(); + HydrologyTerrainSample sampled = planner.sampleLandBasisWithoutSlope(landward.x(), landward.z()); + if (sampled != null) { + terrain = sampled; + } + } + int depth = (planner.settings.surface().minimumDepth() + planner.settings.surface().maximumDepth()) / 2; + nominal[node] = terrain.naturalHeight() - planner.settings.surface().banks().sink() - depth; + int required = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain) + depth; + heads[node] = Math.max(Math.max(planner.settings.seaLevel(), required), downstream); + downstream = heads[node]; + } + } + int[] required = heads.clone(); + int[] ceiling = new int[heads.length]; + Arrays.fill(ceiling, Integer.MAX_VALUE); + int[] upstream = new int[heads.length]; + for (int index = 0; index < heads.length; index++) { + if (heads[index] == Integer.MIN_VALUE) { + continue; + } + if (planner.sourcePlanner.rawSourceEligible(basin.grid().node(index).terrain(), + planner.settings.surface().sources(), true)) { + ceiling[index] = Math.max(required[index], nominal[index]); + } + int parent = basin.parent()[index]; + if (parent >= 0) { + upstream[parent]++; + } + } + int end = 0; + for (int index = 0; index < heads.length; index++) { + if (heads[index] != Integer.MIN_VALUE && upstream[index] == 0) { + path[end++] = index; + } + } + for (int position = 0; position < end; position++) { + int node = path[position]; + int parent = basin.parent()[node]; + if (parent >= 0) { + ceiling[parent] = Math.min(ceiling[parent], ceiling[node]); + if (--upstream[parent] == 0) { + path[end++] = parent; + } + } + } + for (int position = end - 1; position >= 0; position--) { + int node = path[position]; + int parent = basin.parent()[node]; + int downstream = parent < 0 ? Integer.MIN_VALUE : heads[parent]; + heads[node] = Math.max(Math.max(required[node], downstream), Math.min(nominal[node], ceiling[node])); + } + } + + private HydrologyPoint displacedAnchor(HydrologyGridNode node) { + double ratio = Math.min(HydrologyRouteGeometry.ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO, + planner.settings.geometry().meanders().maximumOffsetRatio() * 0.625D); + double wavelength = planner.settings.geometry().meanders().primaryWavelength() * 5D; + int x = node.x() + (int) StrictMath.round(basin.grid().spacing() * ratio + * planner.routeAnchorX.noiseSigned(node.x() / wavelength, node.z() / wavelength)); + int z = node.z() + (int) StrictMath.round(basin.grid().spacing() * ratio + * planner.routeAnchorZ.noiseSigned(node.x() / wavelength, node.z() / wavelength)); + if (!permittedAnchor(node, x, z)) { + x = node.x(); + z = node.z(); + } + if (nearCoast(node)) { + int clearance = (int) StrictMath.ceil(planner.settings.surface().maximumWidth() + * planner.settings.surface().banks().springWidthRatio() / 2D) + 1; + if (!landClearance(x, z, clearance)) { + int radius = Math.max(1, (int) StrictMath.ceil(basin.grid().spacing() + * HydrologyRouteGeometry.ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO)); + long nearest = Long.MAX_VALUE; + int selectedX = x; + int selectedZ = z; + for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) { + int candidateX = node.x() + offset.x() * radius; + int candidateZ = node.z() + offset.z() * radius; + long dx = (long) candidateX - x; + long dz = (long) candidateZ - z; + long distance = dx * dx + dz * dz; + if (distance < nearest && permittedAnchor(node, candidateX, candidateZ) + && landClearance(candidateX, candidateZ, clearance)) { + nearest = distance; + selectedX = candidateX; + selectedZ = candidateZ; + } + } + x = selectedX; + z = selectedZ; + } + } + return new HydrologyPoint(x, heads[node.index()], z); + } + + private boolean permittedAnchor(HydrologyGridNode node, int x, int z) { + HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(x, z); + return terrain != null && !terrain.ocean() && terrain.transitAllowed() + && node.terrain().drainsInto(terrain) && terrain.drainsInto(node.terrain()) + && HydrologySurfaceProfiles.sharesProfile(node.terrain(), terrain) + && node.terrain().surfacePolicy().areaKey().equals(terrain.surfacePolicy().areaKey()); + } + + private boolean nearCoast(HydrologyGridNode node) { + for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) { + HydrologyGridNode neighbor = basin.grid().nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z()); + if (neighbor != null && neighbor.terrain().ocean()) { + return true; + } + } + return false; + } + + private boolean landClearance(int x, int z, int radius) { + for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) { + HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(x + offset.x() * radius, + z + offset.z() * radius); + if (terrain == null || terrain.ocean()) { + return false; + } + } + return true; + } + + private List compile(int index) { + int downstream = basin.parent()[index]; + if (downstream < 0) { + return List.of(); + } + HydrologyPoint start = anchor(index); + HydrologyPoint end = anchor(downstream); + int continuation = basin.parent()[downstream]; + HydrologyPoint next = continuation < 0 ? basin.outlets().get(basin.outletIndex()[index]) + .outlet().connectionPoint() : anchor(continuation); + RouteDirection startTangent = planner.routePaths.direction(start.x(), start.z(), end.x(), end.z()); + RouteDirection endTangent = planner.routePaths.direction(end.x(), end.z(), next.x(), next.z()); + if (endTangent.x() == 0D && endTangent.z() == 0D) { + endTangent = startTangent; + } + double distance = StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + int steps = Math.max(1, (int) StrictMath.ceil(distance / planner.settings.routing().refinementSpacing())); + ArrayList points = new ArrayList<>(steps + 1); + for (int step = 0; step <= steps; step++) { + double progress = step / (double) steps; + RoutePosition position = planner.routeGeometry.routePosition(start, end, startTangent, endTangent, + distance * HydrologyRouteGeometry.ROUTE_TANGENT_SCALE, distance, progress, 1D); + HydrologyPoint point = step == 0 ? start : step == steps ? end + : new HydrologyPoint((int) StrictMath.round(position.x()), + (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress), + (int) StrictMath.round(position.z())); + if (points.isEmpty() || points.getLast().distanceSquared2D(point) > 0L) { + points.add(point); + } + } + return permitted(points) ? List.copyOf(points) : List.of(); + } + + private boolean permitted(List points) { + HydrologyTerrainSample previous = null; + for (int index = 1; index < points.size(); index++) { + for (HydrologyPoint point : planner.segments.rasterLine(points.get(index - 1), points.get(index))) { + HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(point.x(), point.z()); + if (terrain == null || terrain.ocean() || !terrain.transitAllowed() + || previous != null && (!previous.drainsInto(terrain) + || !HydrologySurfaceProfiles.sharesProfile(previous, terrain))) { + return false; + } + previous = terrain; + } + } + return points.size() >= 2; + } + + record Basin(HydrologySampledGrid grid, int[] parent, int[] outletIndex, List outlets) { + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGraph.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGraph.java new file mode 100644 index 000000000..ce37d76d9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGraph.java @@ -0,0 +1,50 @@ +package art.arcane.iris.generation.hydrology; + +final class HydrologyDrainageGraph { + private final HydrologyPlannerSettings settings; + private final int[] upstream; + private final double[] costs; + private final byte[] eligibility; + + HydrologyDrainageGraph(HydrologySampledGrid grid, HydrologySourcePlanner planner) { + settings = planner.settings(); + int directions = HydrologySourcePlanner.ROUTING_OFFSETS.size(); + int size = Math.multiplyExact(grid.nodes().size(), directions); + upstream = new int[size]; + costs = new double[size]; + eligibility = new byte[size]; + for (HydrologyGridNode downstream : grid.nodes()) { + int valleyFloor = planner.minimumNeighborHeight(grid, downstream); + for (int direction = 0; direction < directions; direction++) { + int edge = downstream.index() * directions + direction; + HydrologyGridOffset offset = HydrologySourcePlanner.ROUTING_OFFSETS.get(direction); + HydrologyGridNode source = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z()); + upstream[edge] = source == null ? -1 : source.index(); + if (source == null || source.terrain().ocean() || !source.terrain().transitAllowed() + || !source.terrain().drainsInto(downstream.terrain())) { + continue; + } + eligibility[edge] = 1; + if (HydrologySurfaceProfiles.sharesProfile(source.terrain(), downstream.terrain()) + && downstream.terrain().naturalHeight() - source.terrain().naturalHeight() + <= planner.maximumSurfaceEdgeRise(downstream.terrain())) { + eligibility[edge] |= 2; + } + costs[edge] = planner.routeCost(source, downstream, offset, valleyFloor); + } + } + } + + HydrologyPlannerSettings settings() { + return settings; + } + + int upstream(int downstream, int direction, boolean surface) { + int edge = downstream * HydrologySourcePlanner.ROUTING_OFFSETS.size() + direction; + return (eligibility[edge] & (surface ? 2 : 1)) == 0 ? -1 : upstream[edge]; + } + + double cost(int downstream, int direction) { + return costs[downstream * HydrologySourcePlanner.ROUTING_OFFSETS.size() + direction]; + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java index 9b8752c0f..9733eacfa 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompiler.java @@ -565,6 +565,9 @@ final class HydrologyFootprintCompiler { boolean surfaceDrop = segment.type().isSurface() && segment.fallingFluid(); if (surfaceDrop && channel && !falling) { int maximumIncision = terrain.surfacePolicy().maximumIncision(settings.surface().maximumIncision()); + if (shape.inlet()) { + maximumIncision = Math.max(maximumIncision, settings.surface().banks().inlet().maximumIncision()); + } int allowedIncision = Math.min(maximumIncision, (int) StrictMath.floor(maximumIncision * terrain.incisionMultiplier())); bed = Math.max(bed, Math.min(shape.fluidHead(), terrain.naturalHeight() - allowedIncision)); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java index 61c8dd76b..f500cc9da 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlanner.java @@ -17,7 +17,6 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; -import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -701,7 +700,6 @@ public final class HydrologyPlanner { final HashMap undergroundSegmentCaps; final HashMap routeAnchors; final HashMap surfaceRouteAnchors; - final IdentityHashMap>> surfaceRoutes; final HydrologyCaveCourseFilter.CandidateCache caveCandidates; final HydrologyCaveContainmentPlanner.ValidationCache caveValidations; @@ -723,7 +721,6 @@ public final class HydrologyPlanner { this.undergroundSegmentCaps = new HashMap<>(); this.routeAnchors = new HashMap<>(); this.surfaceRouteAnchors = new HashMap<>(); - this.surfaceRoutes = new IdentityHashMap<>(); this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache(); this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache(); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSettings.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSettings.java index 2d29ed6bf..6c4b3ed9c 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSettings.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyPlannerSettings.java @@ -18,7 +18,7 @@ public record HydrologyPlannerSettings( SeaCaves seaCaves, SurfacePolicyBounds surfacePolicyBounds ) { - private static final long PLAN_FORMAT_REVISION = 8L; + private static final long PLAN_FORMAT_REVISION = 9L; private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4; public HydrologyPlannerSettings { @@ -345,8 +345,8 @@ 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. + * {@code sink} is how many blocks the water surface sits below the shaped bank top; + * zero keeps the water flush with the bank. * {@code channel} shapes the wet outline and {@code flow} the waterfalls and plunge basins. */ public record Banks( diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalGraph.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalGraph.java index 88b25815a..0677035aa 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalGraph.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalGraph.java @@ -1,6 +1,7 @@ package art.arcane.iris.generation.hydrology; import java.util.ArrayList; +import java.util.Arrays; import java.util.BitSet; import java.util.Comparator; import java.util.List; @@ -94,7 +95,42 @@ final class HydrologyRegionalGraph { accumulation[label.downstream.state.id()] += accumulation[label.state.id()]; } } - return new Tree(selected, accumulation, List.copyOf(outlets)); + int[] heads = new int[labels.size()]; + int[] required = new int[labels.size()]; + int[] nominal = new int[labels.size()]; + int[] ceiling = new int[labels.size()]; + Arrays.fill(ceiling, Integer.MAX_VALUE); + for (Label label : labels) { + HydrologyTerrainSample terrain = grid.node(label.state.index()).terrain(); + if (label.downstream == null) { + HydrologyPoint landward = outlets.get(label.state.root()).outlet().landwardPoint(); + terrain = planner.sampleLandBasisWithoutSlope(landward.x(), landward.z()); + } + int index = label.state.id(); + int depth = (planner.settings.surface().minimumDepth() + planner.settings.surface().maximumDepth()) / 2; + nominal[index] = terrain.naturalHeight() - planner.settings.surface().banks().sink() - depth; + int minimum = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain) + depth; + required[index] = coastal ? planner.settings.seaLevel() : Math.max(planner.settings.seaLevel(), + Math.max(minimum, label.downstream == null ? Integer.MIN_VALUE : required[label.downstream.state.id()])); + if (selected[label.state.index()] == label && planner.sourcePlanner.rawSourceEligible( + grid.node(label.state.index()).terrain(), planner.settings.surface().sources(), true)) { + ceiling[index] = Math.max(nominal[index], required[index]); + } + } + for (int index = labels.size() - 1; index >= 0; index--) { + Label label = labels.get(index); + if (label.downstream != null) { + int parent = label.downstream.state.id(); + ceiling[parent] = Math.min(ceiling[parent], ceiling[index]); + } + } + for (Label label : labels) { + int index = label.state.id(); + int downstream = label.downstream == null ? Integer.MIN_VALUE : heads[label.downstream.state.id()]; + heads[index] = coastal ? planner.settings.seaLevel() + : Math.max(Math.max(required[index], downstream), Math.min(nominal[index], ceiling[index])); + } + return new Tree(selected, accumulation, List.copyOf(outlets), heads); } List path(Tree tree, int source) { @@ -160,7 +196,7 @@ final class HydrologyRegionalGraph { + first.routingCost() + second.routingCost()) * policy); } - record Tree(Label[] selected, int[] accumulation, List outlets) { + record Tree(Label[] selected, int[] accumulation, List outlets, int[] heads) { int retainedLabelCount() { BitSet retained = new BitSet(accumulation.length); for (Label label : selected) { @@ -187,6 +223,15 @@ final class HydrologyRegionalGraph { return selected[source] == null ? 0D : selected[source].state.length(); } + List centerline(HydrologySampledGrid grid, int source) { + ArrayList points = new ArrayList<>(); + for (Label label = selected[source]; label != null; label = label.downstream) { + HydrologyGridNode node = grid.node(label.state.index()); + points.add(new HydrologyPoint(node.x(), heads[label.state.id()], node.z())); + } + return List.copyOf(points); + } + int[] contributions(int source) { int count = 0; for (Label label = selected[source]; label != null; label = label.downstream) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalHydraulics.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalHydraulics.java index b497ce2b3..0c37f39a1 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalHydraulics.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalHydraulics.java @@ -1,31 +1,10 @@ package art.arcane.iris.generation.hydrology; -import art.arcane.iris.generation.hydrology.surface.SurfaceBankSupport; - -import java.util.Arrays; - final class HydrologyRegionalHydraulics { - private static final int MAXIMUM_WIDTH_CANDIDATES = 8; - private static final int MAXIMUM_ADDITIONAL_SAMPLES = 2048; - private static final int MAXIMUM_BANK_RADIUS = 96; - private final HydrologyPlannerSettings settings; - private final SurfaceBankSupport bankSupport; - private final double minimumHalfWidth; - private final double maximumProbedHalfWidth; - private final double[] candidateHalfWidths; HydrologyRegionalHydraulics(HydrologyPlannerSettings settings) { this.settings = settings; - this.bankSupport = new SurfaceBankSupport(settings.surface(), settings.seaLevel()); - HydrologyPlannerSettings.Surface surface = settings.surface(); - this.minimumHalfWidth = surface.minimumWidth() / 2D; - double authored = surface.maximumWidth() / 2D; - double expanded = surface.maximumWidth(); - this.maximumProbedHalfWidth = Math.min(expanded, Math.max(minimumHalfWidth, - (MAXIMUM_BANK_RADIUS - 2D) / (1D + surface.banks().roughness()))); - this.candidateHalfWidths = candidates(authored, midpoint(minimumHalfWidth, authored), - maximumProbedHalfWidth, midpoint(minimumHalfWidth, maximumProbedHalfWidth)); } int minimumHead(HydrologyTerrainSample terrain) { @@ -41,90 +20,6 @@ final class HydrologyRegionalHydraulics { return Math.max(settings.seaLevel(), terrain.naturalHeight() - settings.surface().banks().sink()); } - int supportedHead(HeadStation station, HydrologyTerrainSampler sampler) { - int centerHead = maximumHead(station.terrain()); - if (centerHead >= station.incomingHead()) { - return station.incomingHead(); - } - int supported = support(station, sampler, minimumHalfWidth, centerHead); - if (supported >= station.incomingHead()) { - return station.incomingHead(); - } - SampleBudget budget = new SampleBudget(sampler); - double localMinimum = effectiveHalfWidth(settings.surface().minimumWidth(), station.terrain().widthMultiplier()); - double localMaximum = effectiveHalfWidth(settings.surface().maximumWidth(), station.terrain().widthMultiplier()); - if (additional(localMaximum)) { - supported = support(station, budget, localMaximum, supported); - } - if (supported < station.incomingHead() && localMinimum != localMaximum && additional(localMinimum)) { - supported = support(station, budget, localMinimum, supported); - } - double localMiddle = midpoint(localMinimum, localMaximum); - if (supported < station.incomingHead() && localMiddle != localMinimum && localMiddle != localMaximum && additional(localMiddle)) { - supported = support(station, budget, localMiddle, supported); - } - for (double halfWidth : candidateHalfWidths) { - if (supported >= station.incomingHead() || budget.remaining == 0) { - break; - } - supported = support(station, budget, halfWidth, supported); - } - return Math.min(station.incomingHead(), supported); - } - - private int support(HeadStation station, HydrologyTerrainSampler sampler, double halfWidth, int supportedHead) { - SurfaceBankSupport.Station crossSection = new SurfaceBankSupport.Station(station.x(), station.z(), - station.tangentX(), station.tangentZ(), halfWidth); - SurfaceBankSupport.CrossSection support = bankSupport.crossSection(sampler, crossSection, true); - if (support.blocked() || support.minimum() - settings.surface().banks().sink() <= supportedHead) { - return supportedHead; - } - SurfaceBankSupport.Perimeter perimeter = bankSupport.perimeter(sampler, crossSection, support.minimum()); - if (!perimeter.complete()) { - return supportedHead; - } - int supported = perimeter.minimum() - settings.surface().banks().sink(); - return Math.max(supportedHead, supported); - } - - private double effectiveHalfWidth(int width, double multiplier) { - return Math.max(settings.surface().minimumWidth(), Math.min(settings.surface().maximumWidth() * 2D, width * multiplier)) / 2D; - } - - private boolean additional(double halfWidth) { - if (halfWidth <= minimumHalfWidth || halfWidth > maximumProbedHalfWidth) { - return false; - } - for (double candidate : candidateHalfWidths) { - if (candidate == halfWidth) { - return false; - } - } - return true; - } - - private double[] candidates(double... widths) { - double[] distinct = new double[MAXIMUM_WIDTH_CANDIDATES - 4]; - int size = 0; - for (double width : widths) { - if (!Double.isFinite(width) || width <= minimumHalfWidth || width > maximumProbedHalfWidth) { - continue; - } - boolean duplicate = false; - for (int index = 0; index < size; index++) { - duplicate |= distinct[index] == width; - } - if (!duplicate && size < distinct.length) { - distinct[size++] = width; - } - } - return Arrays.copyOf(distinct, size); - } - - private static double midpoint(double first, double second) { - return first + (second - first) / 2D; - } - private int minimumHead(HydrologyTerrainSample terrain, int incision) { int permitted = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier())); int depth = (int) StrictMath.round(Math.max(1D, Math.min(settings.surface().maximumDepth() * 2D, @@ -132,24 +27,4 @@ final class HydrologyRegionalHydraulics { return Math.max(settings.seaLevel(), terrain.naturalHeight() - permitted + depth); } - record HeadStation(int x, int z, double tangentX, double tangentZ, HydrologyTerrainSample terrain, int incomingHead) { - } - - private static final class SampleBudget implements HydrologyTerrainSampler { - private final HydrologyTerrainSampler sampler; - private int remaining = MAXIMUM_ADDITIONAL_SAMPLES; - - private SampleBudget(HydrologyTerrainSampler sampler) { - this.sampler = sampler; - } - - @Override - public HydrologyTerrainSample sample(int blockX, int blockZ) { - if (remaining == 0) { - return null; - } - remaining--; - return sampler.sample(blockX, blockZ); - } - } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java index fce51fed6..247e982a4 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalPlanner.java @@ -475,24 +475,25 @@ final class HydrologyRegionalPlanner { List indices = graph.path(tree, source); OutletCandidate destination = tree.outlets().get(tree.root(source)); RiverOutlet outlet = destination.outlet(); - ArrayList guide = new ArrayList<>(indices.size() + 1); + ArrayList guide = new ArrayList<>(tree.centerline(grid, source)); ArrayList profiles = new ArrayList<>(grid.node(source).terrain().preferredProfileKeys()); for (int index : indices) { HydrologyGridNode node = grid.node(index); - guide.add(node.naturalPoint()); profiles.retainAll(node.terrain().preferredProfileKeys()); } profiles.retainAll(sample(outlet.connectionPoint().x(), outlet.connectionPoint().z()).preferredProfileKeys()); if (origin != null) { profiles.retainAll(sample(origin.outlet().connectionPoint().x(), origin.outlet().connectionPoint().z()).preferredProfileKeys()); - guide.set(0, origin.outlet().landwardPoint()); + HydrologyPoint start = origin.outlet().landwardPoint(); + guide.set(0, new HydrologyPoint(start.x(), planner.settings.seaLevel(), start.z())); } if (profiles.isEmpty() || guide.size() < 2) { diagnostic(diagnostics, grid.node(source).id(), grid.node(source).naturalPoint(), origin != null, HydrologyCandidateRejection.POLICY_EXCLUDED, guide.size()); return HydrologyRegionalNetwork.EMPTY; } - guide.set(guide.size() - 1, outlet.landwardPoint()); + HydrologyPoint end = outlet.landwardPoint(); + guide.set(guide.size() - 1, new HydrologyPoint(end.x(), guide.getLast().y(), end.z())); String profile = profiles.get(HydrologyHash.between(HydrologyHash.mix(basinSeed, source), 0, profiles.size() - 1)); BuildContext context = new BuildContext(grid.node(source), origin, basinSeed, outlet, profile, new HydrologyRegionalFlow(guide, tree.contributions(source)), diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalRoute.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalRoute.java index 6190856a2..9dfd8aa19 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalRoute.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalRoute.java @@ -18,41 +18,32 @@ final class HydrologyRegionalRoute { private static final int MAXIMUM_CORRIDOR_WORKERS = 8; private final HydrologyPlanner planner; - private final HydrologyRegionalTerrainRefiner terrainRefiner; - private final HydrologyRegionalHydraulics hydraulics; + private final HydrologyRegionalTerrain terrain; private final SimplexNoise bends; private final SimplexNoise details; HydrologyRegionalRoute(HydrologyPlanner planner) { this.planner = planner; - this.terrainRefiner = new HydrologyRegionalTerrainRefiner(planner); - this.hydraulics = new HydrologyRegionalHydraulics(planner.settings); + this.terrain = new HydrologyRegionalTerrain(planner); this.bends = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x52454742454e4453L)); this.details = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x5245474445544149L)); } void clear() { - terrainRefiner.clear(); + terrain.clear(); } Attempt select(List guide, String profile, boolean coastal, HydrologyTerrainSampler receiver, CandidateAdmission admission) { - HydrologyRegionalMorphology morphology = HydrologyRegionalMorphology.sample(guide, terrainRefiner::sample, planner.settings); + HydrologyRegionalMorphology morphology = HydrologyRegionalMorphology.sample(guide, this.terrain::sample, planner.settings); Refinement topology = validateCorridor(guide, profile, coastal); OceanEntry resolved = topology.rejection() == null ? topology.oceanEntry() : null; if (resolved != null) { guide = topology.points(); - receiver = HydrologyOceanReceiver.forConnection(planner.settings, terrainRefiner::sample, + receiver = HydrologyOceanReceiver.forConnection(planner.settings, this.terrain::sample, resolved.landward(), resolved.receiving()); } CandidateSelection selection = new CandidateSelection(admission, resolved); - List repaired = terrainRefiner.refine(guide, profile, coastal, receiver); - if (!repaired.isEmpty() && !repaired.equals(guide)) { - Attempt accepted = selectCurve(repaired, profile, coastal, receiver, selection, morphology); - if (accepted != null) { - return accepted; - } - } Attempt accepted = selectCurve(guide, profile, coastal, receiver, selection, morphology); return accepted == null ? selection.failure : accepted; } @@ -396,7 +387,7 @@ final class HydrologyRegionalRoute { terrain.naturalHeight() - planner.settings.seaLevel() + planner.settings.surface().minimumDepth(), null); } previousTerrain = terrain; - previousPoint = failure; + previousPoint = new HydrologyPoint(x, (int) StrictMath.round(first.y() + (second.y() - first.y()) * progress), z); } length += StrictMath.sqrt(first.distanceSquared2D(second)); } @@ -419,7 +410,7 @@ final class HydrologyRegionalRoute { if (++samples > MAXIMUM_RECEIVER_SAMPLES) { return new Refinement(List.of(), previousPoint, HydrologyCandidateRejection.ROUTE_LIMIT, samples, null); } - HydrologyTerrainSample terrain = terrainRefiner.sample(x, z); + HydrologyTerrainSample terrain = this.terrain.sample(x, z); HydrologyPoint point = new HydrologyPoint(x, terrain == null ? first.y() : terrain.naturalHeight(), z); if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel()) { return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, 1, null); @@ -453,7 +444,7 @@ final class HydrologyRegionalRoute { private boolean wetBridge(int x, int z, HydrologyTerrainSample previous, HydrologyTerrainSample next, String profile, boolean coastal) { - HydrologyTerrainSample bridge = terrainRefiner.sample(x, z); + HydrologyTerrainSample bridge = this.terrain.sample(x, z); return bridge != null && bridge.naturalHeight() < planner.settings.seaLevel() && bridge.preferredProfileKeys().contains(profile) && previous.drainsInto(bridge) && bridge.drainsInto(next) && (!coastal || next.drainsInto(bridge) && bridge.drainsInto(previous)); @@ -465,7 +456,7 @@ final class HydrologyRegionalRoute { HydrologyPoint landward = new HydrologyPoint(shore.point().x(), seaLevel, shore.point().z()); receiving = new HydrologyPoint(receiving.x(), seaLevel, receiving.z()); HydrologyTerrainSampler proof = HydrologyOceanReceiver.forConnection(planner.settings, - terrainRefiner::sample, landward, receiving); + this.terrain::sample, landward, receiving); if (!shore.terrain().outletAllowed() || !proof.receivingWater(firstWet.x(), firstWet.z(), seaLevel) || !receivingChordAllowed(shore, receiving, profile, coastal)) { return new Refinement(List.of(), firstWet, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, null); @@ -489,7 +480,7 @@ final class HydrologyRegionalRoute { double progress = step / (double) steps; int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress); int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress); - HydrologyTerrainSample terrain = terrainRefiner.sample(x, z); + HydrologyTerrainSample terrain = this.terrain.sample(x, z); if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel() || !terrain.preferredProfileKeys().contains(profile) || !previous.drainsInto(terrain) || coastal && !terrain.drainsInto(previous)) { @@ -517,30 +508,32 @@ final class HydrologyRegionalRoute { Math.abs(points.get(index).z() - points.get(index - 1).z())); } HydrologyPlannerSettings.Inlet inlet = planner.settings.surface().banks().inlet(); - boolean oceanMouth = corridor.oceanEntry() != null || terrainRefiner.receivingTerminal(points.getLast(), profile, false, receiver); - int reach = oceanMouth ? Math.min(inlet.length(), (int) StrictMath.floor(count * inlet.courseFraction())) : 0; + boolean ocean = corridor.oceanEntry() != null || terrain.receivingTerminal(points.getLast(), profile, false, receiver); + int reach = ocean ? Math.min(inlet.length(), (int) StrictMath.floor(count * inlet.courseFraction())) : 0; int rampStart = count - reach - reach / 2; - int availableHead = Integer.MAX_VALUE; int station = 0; - HydrologyTerrainSampler bankSampler = terrainRefiner::sample; for (int index = 0; index + 1 < points.size(); index++) { HydrologyPoint first = points.get(index); HydrologyPoint second = points.get(index + 1); - int steps = Math.max(Math.abs(second.x() - first.x()), Math.abs(second.z() - first.z())); - double distance = StrictMath.sqrt(first.distanceSquared2D(second)); - double tangentX = distance == 0D ? 1D : (second.x() - first.x()) / distance; - double tangentZ = distance == 0D ? 0D : (second.z() - first.z()) / distance; - for (int step = index == 0 ? 0 : 1; step <= steps; step++) { - double progress = steps == 0 ? 0D : step / (double) steps; - int x = (int) StrictMath.round(first.x() + (second.x() - first.x()) * progress); - int z = (int) StrictMath.round(first.z() + (second.z() - first.z()) * progress); - HydrologyTerrainSample terrain = terrainRefiner.sample(x, z); - availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation( - x, z, tangentX, tangentZ, terrain, availableHead), bankSampler); - int requiredHead = station >= rampStart ? hydraulics.inletMinimumHead(terrain) : hydraulics.minimumHead(terrain); - if (requiredHead > availableHead) { - return new Refinement(List.of(), new HydrologyPoint(x, terrain.naturalHeight(), z), - HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, requiredHead - availableHead, null); + if (first.y() < second.y()) { + return new Refinement(List.of(), second, HydrologyCandidateRejection.SURFACE_HEAD_RANGE, + second.y() - first.y(), null); + } + List raster = planner.segments.rasterLine(first, second); + for (int step = index == 0 ? 0 : 1; step < raster.size(); step++) { + HydrologyPoint sampled = raster.get(step); + double progress = raster.size() < 2 ? 0D : step / (double) (raster.size() - 1); + HydrologyPoint point = new HydrologyPoint(sampled.x(), + (int) StrictMath.round(first.y() + (second.y() - first.y()) * progress), sampled.z()); + HydrologyTerrainSample terrain = this.terrain.sample(point.x(), point.z()); + int incision = terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()); + if (station >= rampStart) { + incision = Math.max(incision, inlet.maximumIncision()); + } + int permitted = HydrologySourcePlanner.permittedSurfaceIncision(incision, terrain.incisionMultiplier()); + int cut = terrain.naturalHeight() - point.y() + planner.settings.surface().minimumDepth(); + if (cut > permitted) { + return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, cut, null); } station++; } @@ -596,7 +589,7 @@ final class HydrologyRegionalRoute { private HydrologyTerrainSample next(int segment, int step, int x, int z) { if (workers == 1) { - return terrainRefiner.sample(x, z); + return HydrologyRegionalRoute.this.terrain.sample(x, z); } if (cursor == count) { fill(segment, step); @@ -643,7 +636,7 @@ final class HydrologyRegionalRoute { private void sampleRange(int start, int end) { for (int index = start; index < end; index++) { try { - terrain[index] = terrainRefiner.sample(xs[index], zs[index]); + terrain[index] = HydrologyRegionalRoute.this.terrain.sample(xs[index], zs[index]); } catch (RuntimeException failure) { failures[index] = failure; } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrain.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrain.java new file mode 100644 index 000000000..9b9472395 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrain.java @@ -0,0 +1,66 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.volmlib.util.cache.CacheKey; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +final class HydrologyRegionalTerrain implements HydrologyTerrainSampler { + private static final int MAXIMUM_CACHED_SAMPLES = 4194304; + private static final int[][] CARDINAL = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}}; + private final HydrologyPlanner planner; + private final HydrologyCacheBudget budget; + private volatile Cache terrain; + + HydrologyRegionalTerrain(HydrologyPlanner planner) { + this.planner = planner; + this.budget = HydrologyCacheBudget.runtime(planner.settings.routing().regional().enabled()); + this.terrain = cache(); + } + + void clear() { + terrain = cache(); + } + + @Override + public HydrologyTerrainSample sample(int x, int z) { + Cache active = terrain; + long key = CacheKey.mix(RiverFootprint.pack(x, z)); + Terrain cached = active.getIfPresent(key); + if (cached != null) { + return cached.sample(); + } + Terrain sampled = new Terrain(planner.naturalSampler == null + ? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z)); + Terrain previous = active.asMap().putIfAbsent(key, sampled); + return (previous == null ? sampled : previous).sample(); + } + + boolean receivingTerminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) { + HydrologyTerrainSample land = sample(point.x(), point.z()); + if (land == null || land.ocean() || land.naturalHeight() < planner.settings.seaLevel() || !land.outletAllowed()) { + return false; + } + for (int index = 0; index < 4; index++) { + int[] offset = CARDINAL[index]; + HydrologyTerrainSample ocean = sample(point.x() + offset[0], point.z() + offset[1]); + if (ocean != null && receiver.receivingWater(point.x() + offset[0], point.z() + offset[1], planner.settings.seaLevel()) + && ocean.preferredProfileKeys().contains(profile) && land.drainsInto(ocean) + && (!coastal || ocean.drainsInto(land))) { + return true; + } + } + return false; + } + + private Cache cache() { + return Caffeine.newBuilder() + .maximumWeight(budget.regionalTerrainBytes()) + .weigher((Long key, Terrain value) -> HydrologyCacheWeights.bounded( + HydrologyCacheWeights.terrain(value.sample()), budget.regionalTerrainBytes(), + (int) Math.max(65536L, Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L)))) + .build(); + } + + private record Terrain(HydrologyTerrainSample sample) { + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java deleted file mode 100644 index e34408ce9..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefiner.java +++ /dev/null @@ -1,514 +0,0 @@ -package art.arcane.iris.generation.hydrology; - -import art.arcane.volmlib.util.cache.CacheKey; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; -import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.PriorityQueue; -import java.util.concurrent.ConcurrentHashMap; - -final class HydrologyRegionalTerrainRefiner { - private static final int GRID_SPACING = 16; - private static final int MAXIMUM_EXPANSIONS = 4096; - private static final int MAXIMUM_SAMPLES = 65536; - private static final int MAXIMUM_CACHED_SAMPLES = 4194304; - private static final int MAXIMUM_SKIPPED_WAYPOINTS = 2; - private static final int[][] NEIGHBOURS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}, {1, 1}, {-1, 1}, {-1, -1}, {1, -1}}; - - private final HydrologyPlanner planner; - private final HydrologyRegionalHydraulics hydraulics; - private final HydrologyCacheBudget cacheBudget; - private volatile CacheState caches; - - HydrologyRegionalTerrainRefiner(HydrologyPlanner planner) { - this.planner = planner; - this.hydraulics = new HydrologyRegionalHydraulics(planner.settings); - this.cacheBudget = HydrologyCacheBudget.runtime(planner.settings.routing().regional().enabled()); - this.caches = new CacheState(cacheBudget); - } - - void clear() { - caches = new CacheState(cacheBudget); - } - - HydrologyTerrainSample sample(int x, int z) { - return sample(caches.terrain, x, z); - } - - private HydrologyTerrainSample sample(Cache cache, int x, int z) { - long key = CacheKey.mix(RiverFootprint.pack(x, z)); - Terrain cached = cache.getIfPresent(key); - if (cached != null) { - return cached.sample(); - } - Terrain sampled = new Terrain(planner.naturalSampler == null - ? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z)); - Terrain existing = cache.asMap().putIfAbsent(key, sampled); - return (existing == null ? sampled : existing).sample(); - } - - List refine(List guide, String profile, boolean coastal, HydrologyTerrainSampler receiver) { - if (guide.size() < 2) { - return List.of(); - } - double maximumLength = planner.settings.routing().maximumRouteLength(); - double remaining = 0D; - for (int index = 1; index < guide.size(); index++) { - remaining += distance(guide.get(index - 1), guide.get(index)); - if (remaining > maximumLength) { - return List.of(); - } - } - Terminal terminal = terminal(guide.getLast(), profile, coastal, receiver); - int[] requiredHead = requiredHeads(guide, terminal, MAXIMUM_SKIPPED_WAYPOINTS + 1); - if (requiredHead.length == 0) { - return List.of(); - } - int availableHead = Integer.MAX_VALUE; - double length = 0D; - ArrayList refined = new ArrayList<>(); - refined.add(guide.getFirst()); - Long2IntOpenHashMap stations = new Long2IntOpenHashMap(); - stations.defaultReturnValue(-1); - stations.put(RiverFootprint.pack(guide.getFirst().x(), guide.getFirst().z()), 0); - for (int index = 1; index < guide.size(); index++) { - int start = index - 1; - Reach reach = null; - int selected = index; - for (int candidate = index; candidate < guide.size() && candidate <= index + MAXIMUM_SKIPPED_WAYPOINTS; candidate++) { - Edge edge = new Edge(guide.get(start), guide.get(candidate), profile, coastal, - requiredHead[candidate], availableHead, terminal); - reach = edge(edge); - if (!reach.points().isEmpty()) { - selected = candidate; - break; - } - } - if (reach.points().isEmpty()) { - return List.of(); - } - List points = reach.points(); - availableHead = reach.availableHead(); - for (int skipped = start; skipped < selected; skipped++) { - remaining -= distance(guide.get(skipped), guide.get(skipped + 1)); - } - index = selected; - for (int point = 1; point < points.size(); point++) { - HydrologyPoint next = points.get(point); - long key = RiverFootprint.pack(next.x(), next.z()); - int previous = stations.get(key); - if (previous >= 0) { - while (refined.size() > previous + 1) { - HydrologyPoint removed = refined.removeLast(); - stations.remove(RiverFootprint.pack(removed.x(), removed.z())); - length -= distance(refined.getLast(), removed); - } - } else { - length += distance(refined.getLast(), next); - stations.put(key, refined.size()); - refined.add(next); - } - if (length + Math.max(0D, remaining) > maximumLength) { - return List.of(); - } - } - } - return simplify(refined, profile, coastal, terminal); - } - - boolean receivingTerminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) { - HydrologyTerrainSample land = sample(point.x(), point.z()); - if (land == null || land.ocean() || land.naturalHeight() < planner.settings.seaLevel() || !land.outletAllowed()) { - return false; - } - for (int index = 0; index < 4; index++) { - int[] offset = NEIGHBOURS[index]; - HydrologyTerrainSample ocean = sample(point.x() + offset[0], point.z() + offset[1]); - if (ocean != null && receiver.receivingWater(point.x() + offset[0], point.z() + offset[1], planner.settings.seaLevel()) - && ocean.preferredProfileKeys().contains(profile) && land.drainsInto(ocean) - && (!coastal || ocean.drainsInto(land))) { - return true; - } - } - return false; - } - - private Terminal terminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) { - int inlet = receivingTerminal(point, profile, coastal, receiver) ? planner.settings.surface().banks().inlet().length() : 0; - return new Terminal(point, inlet + inlet / 2); - } - - private int minimumHead(HydrologyTerrainSample terrain, int x, int z, Terminal terminal) { - boolean inlet = terminal.possibleStations() > 0 - && Math.max(Math.abs(x - terminal.landward().x()), Math.abs(z - terminal.landward().z())) < terminal.possibleStations(); - return inlet ? hydraulics.inletMinimumHead(terrain) : hydraulics.minimumHead(terrain); - } - - private int[] requiredHeads(List points, Terminal terminal, int maximumAdvance) { - int[] heads = new int[points.size()]; - for (int index = points.size() - 1; index >= 0; index--) { - HydrologyPoint point = points.get(index); - HydrologyTerrainSample terrain = sample(point.x(), point.z()); - if (terrain == null) { - return new int[0]; - } - int required = planner.settings.seaLevel(); - if (index + 1 < points.size()) { - required = Integer.MAX_VALUE; - for (int downstream = index + 1; downstream < points.size() && downstream <= index + maximumAdvance; downstream++) { - required = Math.min(required, heads[downstream]); - } - } - heads[index] = Math.max(required, minimumHead(terrain, point.x(), point.z(), terminal)); - } - return heads; - } - - private List simplify(List points, String profile, boolean coastal, Terminal terminal) { - double maximumReach = Math.min(512D, planner.settings.routing().regional().sampleSpacing() * 2D); - Samples samples = new Samples(); - ArrayList simplified = new ArrayList<>(); - int current = 0; - int[] requiredHead = requiredHeads(points, terminal, 1); - if (requiredHead.length == 0) { - return List.of(); - } - int availableHead = Integer.MAX_VALUE; - simplified.add(points.getFirst()); - while (current + 1 < points.size()) { - int farthest = current + 1; - double reach = distance(points.get(current), points.get(farthest)); - while (farthest + 1 < points.size()) { - double next = distance(points.get(farthest), points.get(farthest + 1)); - if (reach + next > maximumReach) { - break; - } - reach += next; - farthest++; - } - int selected = current + 1; - Traversal selectedTraversal = traverse(points.get(current), points.get(selected), - new Edge(points.get(current), points.get(selected), profile, coastal, requiredHead[selected], availableHead, terminal), - samples, availableHead); - for (int candidate = farthest; candidate > current + 1; candidate--) { - Edge shortcut = new Edge(points.get(current), points.get(candidate), profile, coastal, - requiredHead[candidate], availableHead, terminal); - Traversal traversal = traverse(shortcut.start(), shortcut.end(), shortcut, samples, availableHead); - if (traversal.cost() == 0D) { - selected = candidate; - selectedTraversal = traversal; - break; - } - } - if (!Double.isFinite(selectedTraversal.cost())) { - return List.copyOf(points); - } - availableHead = selectedTraversal.availableHead(); - current = selected; - simplified.add(points.get(current)); - } - return List.copyOf(simplified); - } - - private Reach route(Edge edge) { - Samples samples = new Samples(); - Traversal direct = traverse(edge.start(), edge.end(), edge, samples, edge.maximumHead()); - if (direct.cost() == 0D) { - return new Reach(List.of(edge.start(), edge.end()), direct.availableHead()); - } - int margin = planner.settings.routing().regional().sampleSpacing(); - int minimumX = Math.min(edge.start().x(), edge.end().x()) - margin; - int maximumX = Math.max(edge.start().x(), edge.end().x()) + margin; - int minimumZ = Math.min(edge.start().z(), edge.end().z()) - margin; - int maximumZ = Math.max(edge.start().z(), edge.end().z()) + margin; - Long2ObjectOpenHashMap> visited = new Long2ObjectOpenHashMap<>(); - PriorityQueue pending = new PriorityQueue<>(Comparator.comparingDouble(Pending::estimate) - .thenComparingLong(Pending::key) - .thenComparing(Comparator.comparingInt(Pending::availableHead).reversed())); - long firstKey = RiverFootprint.pack(edge.start().x(), edge.start().z()); - long lastKey = RiverFootprint.pack(edge.end().x(), edge.end().z()); - Search search = new Search(edge, samples, visited, pending); - Node first = new Node(edge.start(), 0D, edge.maximumHead(), null); - ArrayList firstLabels = new ArrayList<>(1); - firstLabels.add(first); - visited.put(firstKey, firstLabels); - pending.add(new Pending(firstKey, first, distance(edge.start(), edge.end()))); - for (int expanded = 0; !pending.isEmpty() && expanded < MAXIMUM_EXPANSIONS; expanded++) { - Pending next = pending.remove(); - Node current = next.node(); - if (!retained(visited.get(next.key()), current)) { - continue; - } - if (next.key() == lastKey) { - return new Reach(path(current), current.availableHead()); - } - if (distance(current.point(), edge.end()) <= GRID_SPACING * StrictMath.sqrt(2D)) { - offer(search, current, edge.end()); - } - for (int[] offset : NEIGHBOURS) { - int x = current.point().x() + offset[0] * GRID_SPACING; - int z = current.point().z() + offset[1] * GRID_SPACING; - if (x < minimumX || x > maximumX || z < minimumZ || z > maximumZ) { - continue; - } - offer(search, current, new HydrologyPoint(x, current.point().y(), z)); - } - } - return new Reach(List.of(), edge.maximumHead()); - } - - private Reach edge(Edge edge) { - CacheState state = caches; - Reach cached = state.edges.getIfPresent(edge); - if (cached != null) { - return cached; - } - HydrologyForkJoin.Task task = new HydrologyForkJoin.Task<>(() -> { - try { - Reach present = state.edges.getIfPresent(edge); - Reach result = present == null ? route(edge) : present; - state.edges.put(edge, result); - return result; - } finally { - state.loading.remove(edge); - } - }); - HydrologyForkJoin.Task existing = state.loading.putIfAbsent(edge, task); - return (existing == null ? task : existing).await(); - } - - private void offer(Search search, Node current, HydrologyPoint target) { - applyOffer(search, current, evaluateOffer(search, current, target)); - } - - private Offer evaluateOffer(Search search, Node current, HydrologyPoint target) { - Samples samples = search.samples(); - HydrologyTerrainSample terrain = samples.sample(target.x(), target.z()); - if (!allowed(terrain, search.edge())) { - return null; - } - HydrologyPoint point = new HydrologyPoint(target.x(), terrain.naturalHeight(), target.z()); - double cost = current.cost() + distance(current.point(), point) - + Math.max(0D, point.y() - current.point().y()) * planner.settings.routing().uphillPenalty() - + Math.max(0D, terrain.routingCost() * terrain.routingMultiplier()); - long key = RiverFootprint.pack(point.x(), point.z()); - List labels = search.visited().get(key); - if (dominated(labels, cost, current.availableHead())) { - return null; - } - Traversal traversal = traverse(current.point(), point, search.edge(), samples, current.availableHead()); - cost += traversal.cost(); - if (!Double.isFinite(cost) || dominated(labels, cost, traversal.availableHead())) { - return null; - } - cost += clearanceCost(point, search.edge(), samples); - if (dominated(labels, cost, traversal.availableHead())) { - return null; - } - return new Offer(point, cost, traversal.availableHead()); - } - - private void applyOffer(Search search, Node current, Offer offer) { - if (offer == null) { - return; - } - long key = RiverFootprint.pack(offer.point().x(), offer.point().z()); - List labels = search.visited().get(key); - if (labels == null) { - labels = new ArrayList<>(2); - search.visited().put(key, labels); - } - for (int index = labels.size() - 1; index >= 0; index--) { - Node previous = labels.get(index); - if (offer.cost() <= previous.cost() && offer.availableHead() >= previous.availableHead()) { - labels.remove(index); - } - } - Node retained = new Node(offer.point(), offer.cost(), offer.availableHead(), current); - labels.add(retained); - search.pending().add(new Pending(key, retained, offer.cost() + distance(offer.point(), search.edge().end()))); - } - - private static boolean dominated(List labels, double cost, int availableHead) { - if (labels == null) { - return false; - } - for (Node previous : labels) { - if (previous.cost() <= cost && previous.availableHead() >= availableHead) { - return true; - } - } - return false; - } - - private static boolean retained(List labels, Node current) { - for (Node retained : labels) { - if (retained == current) { - return true; - } - } - return false; - } - - private Traversal traverse(HydrologyPoint start, HydrologyPoint end, Edge edge, - Samples samples, int availableHead) { - int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); - HydrologyTerrainSample previous = null; - double distance = distance(start, end); - double tangentX = distance == 0D ? 1D : (end.x() - start.x()) / distance; - double tangentZ = distance == 0D ? 0D : (end.z() - start.z()) / distance; - double cost = 0D; - for (int step = 0; step <= steps; step++) { - double progress = steps == 0 ? 0D : step / (double) steps; - int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress); - int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress); - HydrologyTerrainSample terrain = samples.sample(x, z); - if (!allowed(terrain, edge) || previous != null && (!previous.drainsInto(terrain) - || edge.coastal() && !terrain.drainsInto(previous))) { - return new Traversal(Double.POSITIVE_INFINITY, availableHead); - } - if (!edge.coastal()) { - availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation( - x, z, tangentX, tangentZ, terrain, availableHead), samples); - if (availableHead < Math.max(edge.minimumHead(), minimumHead(terrain, x, z, edge.terminal()))) { - return new Traversal(Double.POSITIVE_INFINITY, availableHead); - } - } - cost += ridgeCost(x, z, terrain, edge); - previous = terrain; - } - return new Traversal(cost, availableHead); - } - - private double ridgeCost(int x, int z, HydrologyTerrainSample terrain, Edge edge) { - if (edge.coastal()) { - return 0D; - } - double deltaX = edge.end().x() - edge.start().x(); - double deltaZ = edge.end().z() - edge.start().z(); - double squared = deltaX * deltaX + deltaZ * deltaZ; - double progress = squared == 0D ? 0D : Math.max(0D, Math.min(1D, - ((x - edge.start().x()) * deltaX + (z - edge.start().z()) * deltaZ) / squared)); - double envelope = edge.start().y() + (edge.end().y() - edge.start().y()) * progress; - int incision = terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()); - double permitted = Math.min(incision, StrictMath.floor(incision * terrain.incisionMultiplier())); - double excess = terrain.naturalHeight() - envelope + planner.settings.surface().minimumDepth() - + planner.settings.surface().banks().sink() - permitted; - return Math.max(0D, excess) * Math.max(1D, planner.settings.routing().uphillPenalty()); - } - - private double clearanceCost(HydrologyPoint point, Edge edge, Samples samples) { - double radius = planner.settings.surface().maximumWidth() / 2D - * (1D + planner.settings.surface().banks().roughness()) + 1D; - int blocked = 0; - for (int[] offset : NEIGHBOURS) { - double scale = offset[0] != 0 && offset[1] != 0 ? radius / StrictMath.sqrt(2D) : radius; - int x = point.x() + (int) StrictMath.round(offset[0] * scale); - int z = point.z() + (int) StrictMath.round(offset[1] * scale); - if (!allowed(samples.sample(x, z), edge)) { - blocked++; - } - } - return blocked * GRID_SPACING / 2D; - } - - private boolean allowed(HydrologyTerrainSample terrain, Edge edge) { - return terrain != null && !terrain.ocean() && terrain.naturalHeight() >= planner.settings.seaLevel() && terrain.transitAllowed() - && terrain.preferredProfileKeys().contains(edge.profile()) - && (!edge.coastal() || terrain.naturalHeight() - planner.settings.seaLevel() - + planner.settings.surface().minimumDepth() - <= planner.settings.routing().regional().maximumCoastalIncision()); - } - - private static List path(Node current) { - ArrayList points = new ArrayList<>(); - while (current != null) { - points.add(current.point()); - current = current.parent(); - } - Collections.reverse(points); - return List.copyOf(points); - } - - private static double distance(HydrologyPoint first, HydrologyPoint second) { - return StrictMath.sqrt(first.distanceSquared2D(second)); - } - - final class Samples implements HydrologyTerrainSampler { - private final Long2ObjectOpenHashMap values = new Long2ObjectOpenHashMap<>(); - private final Cache terrainCache = caches.terrain; - - @Override - public HydrologyTerrainSample sample(int x, int z) { - long key = RiverFootprint.pack(x, z); - HydrologyTerrainSample sampled = values.get(key); - if (sampled != null || values.containsKey(key)) { - return sampled; - } - if (values.size() >= MAXIMUM_SAMPLES) { - return null; - } - sampled = HydrologyRegionalTerrainRefiner.this.sample(terrainCache, x, z); - values.put(key, sampled); - return sampled; - } - } - - private static final class CacheState { - private final Cache edges; - private final ConcurrentHashMap> loading = new ConcurrentHashMap<>(); - private final Cache terrain; - - private CacheState(HydrologyCacheBudget budget) { - edges = Caffeine.newBuilder() - .maximumWeight(budget.regionalReachBytes()) - .weigher((Edge edge, Reach reach) -> HydrologyCacheWeights.bounded( - HydrologyCacheWeights.points(reach.points()), budget.regionalReachBytes(), 128)) - .build(); - terrain = Caffeine.newBuilder() - .maximumWeight(budget.regionalTerrainBytes()) - .weigher((Long key, Terrain terrain) -> HydrologyCacheWeights.bounded( - HydrologyCacheWeights.terrain(terrain.sample()), budget.regionalTerrainBytes(), - (int) Math.max(MAXIMUM_SAMPLES, Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L)))) - .build(); - } - } - - private record Offer(HydrologyPoint point, double cost, int availableHead) { - } - - private record Edge(HydrologyPoint start, HydrologyPoint end, String profile, boolean coastal, - int minimumHead, int maximumHead, Terminal terminal) { - } - - private record Terminal(HydrologyPoint landward, int possibleStations) { - } - - private record Reach(List points, int availableHead) { - } - - private record Traversal(double cost, int availableHead) { - } - - private record Terrain(HydrologyTerrainSample sample) { - } - - private record Search(Edge edge, Samples samples, Long2ObjectOpenHashMap> visited, - PriorityQueue pending) { - } - - private record Node(HydrologyPoint point, double cost, int availableHead, Node parent) { - } - - private record Pending(long key, Node node, double estimate) { - private int availableHead() { - return node.availableHead(); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java index fc1e96c48..c9a2d8779 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRouteGeometry.java @@ -15,7 +15,6 @@ final class HydrologyRouteGeometry { this.planner = planner; } - static final double CROSS_DROP_WEIGHT = 3D; static final long ROUTE_ANCHOR_X_SALT = 0x52414e434858L; static final long ROUTE_ANCHOR_Z_SALT = 0x52414e43485aL; static final long ROUTE_WORM_PRIMARY_SALT = 0x52575052494dL; @@ -24,22 +23,17 @@ final class HydrologyRouteGeometry { static final double ROUTE_TANGENT_SCALE = 0.68D; static final int ROUTE_TRANSVERSE_CANDIDATES = 2; static final int ROUTE_FALLBACK_TRANSVERSE_CANDIDATES = 4; - static final int SURFACE_COURSE_TRANSVERSE_CANDIDATES = 2; - static final double SURFACE_COURSE_MAXIMUM_TURN_DEGREES = 50D; - static final double SURFACE_COURSE_TURN_COST = 2.5D; - static final int SURFACE_MINIMUM_NATURAL_BANK_RISE = 1; static final int MAXIMUM_SURFACE_BORE_STEP = 4; HydrologyPoint edgeContinuation( HydrologySampledGrid grid, HydrologyRoutingPlan routing, HydrologyGridNode downstream, - RiverOutlet outlet, - boolean surface + RiverOutlet outlet ) { int continuationIndex = routing.parent()[downstream.index()]; if (continuationIndex >= 0) { - return routeAnchor(grid.node(continuationIndex), surface); + return routeAnchor(grid.node(continuationIndex), false); } HydrologyPoint landward = outlet.landwardPoint(); if (landward.x() != downstream.x() || landward.z() != downstream.z()) { @@ -177,17 +171,15 @@ final class HydrologyRouteGeometry { return score + HydrologyHash.unit(tie) * 1.0E-6D; } - List refineEdge( + List refineUndergroundEdge( long upstreamId, long downstreamId, HydrologyPoint upstream, HydrologyPoint downstream, HydrologyPoint continuation, - int transverseCandidates, - boolean surface + int transverseCandidates ) { int refinement = planner.settings.routing().refinementSpacing(); - int effectiveTransverseCandidates = transverseCandidates; double deltaX = downstream.x() - upstream.x(); double deltaZ = downstream.z() - upstream.z(); double distance = StrictMath.hypot(deltaX, deltaZ); @@ -240,7 +232,7 @@ final class HydrologyRouteGeometry { nominal, progress, refinement, - elevationTransition && !surface ? 0 : effectiveTransverseCandidates, + elevationTransition ? 0 : transverseCandidates, upstreamTerrain == null ? null : upstreamTerrain.confinesKey() ); if (candidates.isEmpty()) { @@ -531,144 +523,6 @@ final class HydrologyRouteGeometry { return List.copyOf(candidates.values()); } - List unresolvedSurfaceRouteCandidates( - HydrologySampledGrid grid, - long upstreamId, - long downstreamId, - RoutePosition nominal, - double progress, - int refinement, - int transverseCandidates - ) { - double searchEnvelope = StrictMath.sin(StrictMath.PI * progress); - LinkedHashMap candidates = new LinkedHashMap<>(); - for (int offsetIndex = -transverseCandidates; - offsetIndex <= transverseCandidates; - offsetIndex++) { - double offset = offsetIndex * refinement * searchEnvelope; - int x = (int) StrictMath.round(nominal.x() - nominal.tangent().z() * offset); - int z = (int) StrictMath.round(nominal.z() + nominal.tangent().x() * offset); - long packed = RiverFootprint.pack(x, z); - RouteTerrainEstimate estimate = routeTerrainEstimate(grid, x, z); - double localScore = estimate.terrainScore() - + StrictMath.abs(offset) * 0.18D - + HydrologyHash.unit(HydrologyHash.mix( - planner.worldSeed, - upstreamId, - downstreamId, - x, - z - )) * 1.0E-6D; - RouteCandidate candidate = new RouteCandidate( - new HydrologyPoint(x, estimate.height(), z), - nominal.x() - nominal.tangent().z() * offset, - nominal.z() + nominal.tangent().x() * offset, - offset, - localScore, - estimate.terrainScore(), - nominal.tangent(), - false - ); - RouteCandidate current = candidates.get(packed); - if (current == null || candidate.localScore() < current.localScore()) { - candidates.put(packed, candidate); - } - } - return List.copyOf(candidates.values()); - } - - RouteTerrainEstimate routeTerrainEstimate(HydrologySampledGrid grid, int x, int z) { - double gridX = (x - grid.minimumX()) / (double) grid.spacing(); - double gridZ = (z - grid.minimumZ()) / (double) grid.spacing(); - int minimumGridX = Math.max(0, Math.min(grid.width() - 1, (int) StrictMath.floor(gridX))); - int minimumGridZ = Math.max(0, Math.min(grid.width() - 1, (int) StrictMath.floor(gridZ))); - int maximumGridX = Math.min(grid.width() - 1, minimumGridX + 1); - int maximumGridZ = Math.min(grid.width() - 1, minimumGridZ + 1); - double progressX = Math.max(0D, Math.min(1D, gridX - minimumGridX)); - double progressZ = Math.max(0D, Math.min(1D, gridZ - minimumGridZ)); - HydrologyTerrainSample northwest = grid.nodeAt(minimumGridX, minimumGridZ).terrain(); - HydrologyTerrainSample northeast = grid.nodeAt(maximumGridX, minimumGridZ).terrain(); - HydrologyTerrainSample southwest = grid.nodeAt(minimumGridX, maximumGridZ).terrain(); - HydrologyTerrainSample southeast = grid.nodeAt(maximumGridX, maximumGridZ).terrain(); - double height = planner.routePaths.bilinear( - northwest.naturalHeight(), - northeast.naturalHeight(), - southwest.naturalHeight(), - southeast.naturalHeight(), - progressX, - progressZ - ); - double terrainScore = planner.routePaths.bilinear( - routeTerrainScore(northwest), - routeTerrainScore(northeast), - routeTerrainScore(southwest), - routeTerrainScore(southeast), - progressX, - progressZ - ); - return new RouteTerrainEstimate((int) StrictMath.round(height), terrainScore); - } - - RouteCandidate resolveRouteCandidate(RouteCandidate candidate) { - if (candidate.terrainResolved()) { - return candidate; - } - HydrologyPoint point = candidate.point(); - HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z()); - if (terrain == null || !terrain.transitAllowed()) { - return new RouteCandidate( - point, - candidate.continuousX(), - candidate.continuousZ(), - candidate.offset(), - Double.POSITIVE_INFINITY, - Double.POSITIVE_INFINITY, - candidate.tangent(), - true - ); - } - double terrainScore = routeTerrainScore(terrain) + crossDropPenalty(point, candidate.tangent(), terrain); - double exactLocalScore = candidate.localScore() - candidate.terrainScore() + terrainScore; - return new RouteCandidate( - new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()), - candidate.continuousX(), - candidate.continuousZ(), - candidate.offset(), - exactLocalScore, - terrainScore, - candidate.tangent(), - true - ); - } - - /** - * Ground that falls away beside a candidate forces the valley solver to cut the channel down to the - * lower bank; costing that drop keeps refined routes on valley floors and contour lines instead of - * traversing hillsides that the incision cap would later reject. - */ - double crossDropPenalty(HydrologyPoint point, RouteDirection tangent, HydrologyTerrainSample center) { - double length = StrictMath.hypot(tangent.x(), tangent.z()); - if (length <= 0D) { - return 0D; - } - double normalX = -tangent.z() / length; - double normalZ = tangent.x() / length; - double reach = planner.settings.surface().maximumWidth() / 2D + planner.settings.surface().shoreWidth() + 2D; - int lowest = center.naturalHeight(); - for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) { - for (double direction = -1D; direction <= 1D; direction += 2D) { - double side = planner.sampleLandHeight( - (int) StrictMath.round(point.x() + normalX * distance * direction), - (int) StrictMath.round(point.z() + normalZ * distance * direction)); - if (!Double.isNaN(side)) { - lowest = Math.min(lowest, (int) side); - } - } - } - double drop = center.naturalHeight() - lowest; - return drop * planner.settings.routing().valleyPreference() * CROSS_DROP_WEIGHT; - } - double routeTerrainScore(HydrologyTerrainSample terrain) { return terrain.naturalHeight() * planner.settings.routing().valleyPreference() + terrain.slope() * planner.settings.routing().slopePenalty() @@ -791,414 +645,6 @@ final class HydrologyRouteGeometry { return null; } - RouteCandidate[] selectCurvatureAwareTerrainRoute( - List> layers, - double targetStepLength, - double maximumTurn, - double turnCost - ) { - int layerCount = layers.size(); - if (layerCount < 2) { - return new RouteCandidate[0]; - } - double[][] localPenalties = new double[layerCount][]; - double[][][] transitionCosts = new double[layerCount][][]; - double[][][][] turnCosts = new double[layerCount][][][]; - for (int layerIndex = 0; layerIndex < layerCount; layerIndex++) { - int layerSize = layers.get(layerIndex).size(); - localPenalties[layerIndex] = new double[layerSize]; - if (layerIndex > 0 && layerIndex < layerCount - 1) { - Arrays.fill(localPenalties[layerIndex], Double.NaN); - } - if (layerIndex == 0) { - continue; - } - int previousSize = layers.get(layerIndex - 1).size(); - transitionCosts[layerIndex] = new double[previousSize][layerSize]; - for (double[] row : transitionCosts[layerIndex]) { - Arrays.fill(row, Double.NaN); - } - } - CurvatureWorkspace workspace = new CurvatureWorkspace(layers); - while (true) { - CurvatureRouteSelection selection = selectCurvatureAwareTerrainRoute( - layers, - targetStepLength, - maximumTurn, - turnCost, - localPenalties, - transitionCosts, - turnCosts, - workspace - ); - if (selection.route().length == 0) { - return selection.route(); - } - boolean evaluated = evaluateSelectedSurfaceCosts( - selection, - layers, - localPenalties, - transitionCosts, - targetStepLength - ); - if (!evaluated) { - return selection.route(); - } - } - } - - CurvatureRouteSelection selectCurvatureAwareTerrainRoute( - List> layers, - double targetStepLength, - double maximumTurn, - double turnCost, - double[][] localPenalties, - double[][][] transitionCosts, - double[][][][] turnCosts, - CurvatureWorkspace workspace - ) { - int layerCount = layers.size(); - double[][] previousCosts = workspace.firstCosts; - double[][] currentCosts = workspace.secondCosts; - int[][][] predecessors = workspace.predecessors; - List firstLayer = layers.getFirst(); - List secondLayer = layers.get(1); - for (int firstIndex = 0; firstIndex < firstLayer.size(); firstIndex++) { - Arrays.fill(previousCosts[firstIndex], Double.POSITIVE_INFINITY); - Arrays.fill(predecessors[1][firstIndex], -1); - for (int secondIndex = 0; secondIndex < secondLayer.size(); secondIndex++) { - RouteCandidate first = firstLayer.get(firstIndex); - RouteCandidate second = secondLayer.get(secondIndex); - double transition = resolvedTransitionCost( - first, - second, - targetStepLength, - transitionCosts[1][firstIndex][secondIndex], - workspace, - 1, - firstIndex, - secondIndex - ); - if (Double.isFinite(transition)) { - previousCosts[firstIndex][secondIndex] = first.localScore() - + second.localScore() - + resolvedLocalPenalty(localPenalties[1][secondIndex]) - + transition; - } - } - } - for (int layerIndex = 2; layerIndex < layerCount; layerIndex++) { - List beforeLayer = layers.get(layerIndex - 2); - List previousLayer = layers.get(layerIndex - 1); - List currentLayer = layers.get(layerIndex); - if (turnCosts[layerIndex] == null) { - turnCosts[layerIndex] = new double[previousLayer.size()][currentLayer.size()][]; - } - for (int previousIndex = 0; previousIndex < previousLayer.size(); previousIndex++) { - Arrays.fill(currentCosts[previousIndex], Double.POSITIVE_INFINITY); - Arrays.fill(predecessors[layerIndex][previousIndex], -1); - RouteCandidate previous = previousLayer.get(previousIndex); - for (int currentIndex = 0; currentIndex < currentLayer.size(); currentIndex++) { - RouteCandidate current = currentLayer.get(currentIndex); - double transition = resolvedTransitionCost( - previous, - current, - targetStepLength, - transitionCosts[layerIndex][previousIndex][currentIndex], - workspace, - layerIndex, - previousIndex, - currentIndex - ); - if (!Double.isFinite(transition)) { - continue; - } - double localPenalty = resolvedLocalPenalty(localPenalties[layerIndex][currentIndex]); - for (int beforeIndex = 0; beforeIndex < beforeLayer.size(); beforeIndex++) { - double previousCost = previousCosts[beforeIndex][previousIndex]; - if (!Double.isFinite(previousCost)) { - continue; - } - double[] candidateTurns = turnCosts[layerIndex][previousIndex][currentIndex]; - if (candidateTurns == null) { - candidateTurns = new double[beforeLayer.size()]; - Arrays.fill(candidateTurns, Double.NaN); - turnCosts[layerIndex][previousIndex][currentIndex] = candidateTurns; - } - double turnPenalty = candidateTurns[beforeIndex]; - if (Double.isNaN(turnPenalty)) { - double turn = continuousTurnDegrees( - beforeLayer.get(beforeIndex), - previous, - current - ); - turnPenalty = turn > maximumTurn - ? Double.POSITIVE_INFINITY - : curvatureCost(turn, maximumTurn, turnCost); - candidateTurns[beforeIndex] = turnPenalty; - } - if (turnPenalty == Double.POSITIVE_INFINITY) { - continue; - } - double cost = previousCost - + current.localScore() - + localPenalty - + transition - + turnPenalty; - if (cost < currentCosts[previousIndex][currentIndex]) { - currentCosts[previousIndex][currentIndex] = cost; - predecessors[layerIndex][previousIndex][currentIndex] = beforeIndex; - } - } - } - } - double[][] completedCosts = currentCosts; - currentCosts = previousCosts; - previousCosts = completedCosts; - } - int lastLayerIndex = layerCount - 1; - int selectedPreviousIndex = -1; - int selectedCurrentIndex = -1; - double selectedCost = Double.POSITIVE_INFINITY; - for (int previousIndex = 0; previousIndex < layers.get(lastLayerIndex - 1).size(); previousIndex++) { - for (int currentIndex = 0; - currentIndex < layers.get(lastLayerIndex).size(); - currentIndex++) { - if (previousCosts[previousIndex][currentIndex] < selectedCost) { - selectedCost = previousCosts[previousIndex][currentIndex]; - selectedPreviousIndex = previousIndex; - selectedCurrentIndex = currentIndex; - } - } - } - if (!Double.isFinite(selectedCost)) { - return CurvatureRouteSelection.empty(); - } - RouteCandidate[] route = new RouteCandidate[layerCount]; - int[] indices = new int[layerCount]; - route[lastLayerIndex] = layers.get(lastLayerIndex).get(selectedCurrentIndex); - route[lastLayerIndex - 1] = layers.get(lastLayerIndex - 1).get(selectedPreviousIndex); - indices[lastLayerIndex] = selectedCurrentIndex; - indices[lastLayerIndex - 1] = selectedPreviousIndex; - for (int layerIndex = lastLayerIndex; layerIndex >= 2; layerIndex--) { - int beforeIndex = predecessors[layerIndex][selectedPreviousIndex][selectedCurrentIndex]; - if (beforeIndex < 0) { - return CurvatureRouteSelection.empty(); - } - route[layerIndex - 2] = layers.get(layerIndex - 2).get(beforeIndex); - indices[layerIndex - 2] = beforeIndex; - selectedCurrentIndex = selectedPreviousIndex; - selectedPreviousIndex = beforeIndex; - } - return new CurvatureRouteSelection(route, indices); - } - - boolean evaluateSelectedSurfaceCosts( - CurvatureRouteSelection selection, - List> layers, - double[][] localPenalties, - double[][][] transitionCosts, - double targetStepLength - ) { - boolean evaluated = false; - RouteCandidate[] route = selection.route(); - int[] indices = selection.indices(); - for (int layerIndex = 1; layerIndex < route.length - 1; layerIndex++) { - RouteCandidate candidate = route[layerIndex]; - if (candidate.terrainResolved()) { - continue; - } - RouteCandidate resolved = resolveRouteCandidate(candidate); - ArrayList updatedLayer = new ArrayList<>(layers.get(layerIndex)); - updatedLayer.set(indices[layerIndex], resolved); - layers.set(layerIndex, List.copyOf(updatedLayer)); - route[layerIndex] = resolved; - evaluated = true; - } - for (int layerIndex = 1; layerIndex < route.length - 1; layerIndex++) { - int candidateIndex = indices[layerIndex]; - if (!Double.isNaN(localPenalties[layerIndex][candidateIndex])) { - continue; - } - if (!Double.isFinite(route[layerIndex].localScore())) { - localPenalties[layerIndex][candidateIndex] = 0D; - continue; - } - localPenalties[layerIndex][candidateIndex] = planner.surfaceCourses.surfaceRouteCandidateBankPenalty( - route[layerIndex] - ); - evaluated = true; - } - for (int layerIndex = 1; layerIndex < route.length; layerIndex++) { - int previousIndex = indices[layerIndex - 1]; - int currentIndex = indices[layerIndex]; - if (!Double.isNaN(transitionCosts[layerIndex][previousIndex][currentIndex])) { - continue; - } - RouteCandidate previous = route[layerIndex - 1]; - RouteCandidate current = route[layerIndex]; - if (!Double.isFinite(previous.localScore()) || !Double.isFinite(current.localScore())) { - transitionCosts[layerIndex][previousIndex][currentIndex] = Double.POSITIVE_INFINITY; - continue; - } - double transition = planner.routePaths.routeTransitionCost(previous, current, targetStepLength); - if (Double.isFinite(transition)) { - transition += surfaceRouteTransitionPenalty(previous, current); - } - transitionCosts[layerIndex][previousIndex][currentIndex] = transition; - evaluated = true; - } - return evaluated; - } - - double resolvedLocalPenalty(double penalty) { - return Double.isNaN(penalty) ? 0D : penalty; - } - - double resolvedTransitionCost( - RouteCandidate previous, - RouteCandidate current, - double targetStepLength, - double exactCost, - CurvatureWorkspace workspace, - int layerIndex, - int previousIndex, - int currentIndex - ) { - if (!Double.isNaN(exactCost)) { - return exactCost; - } - long heights = ((long) previous.point().y() << 32) | (current.point().y() & 0xffffffffL); - if (!workspace.geometryKnown[layerIndex][previousIndex][currentIndex] - || workspace.geometryHeights[layerIndex][previousIndex][currentIndex] != heights) { - workspace.geometryCosts[layerIndex][previousIndex][currentIndex] = - planner.routePaths.routeTransitionGeometryCost(previous, current, targetStepLength); - workspace.geometryHeights[layerIndex][previousIndex][currentIndex] = heights; - workspace.geometryKnown[layerIndex][previousIndex][currentIndex] = true; - } - return workspace.geometryCosts[layerIndex][previousIndex][currentIndex]; - } - - double curvatureCost(double turn, double maximumTurn, double turnCost) { - double excess = Math.max(0D, turn - 8D); - return excess * excess * turnCost / maximumTurn; - } - - double continuousTurnDegrees(RouteCandidate before, RouteCandidate center, RouteCandidate after) { - double incomingX = center.continuousX() - before.continuousX(); - double incomingZ = center.continuousZ() - before.continuousZ(); - double outgoingX = after.continuousX() - center.continuousX(); - double outgoingZ = after.continuousZ() - center.continuousZ(); - double length = StrictMath.hypot(incomingX, incomingZ) * StrictMath.hypot(outgoingX, outgoingZ); - if (length <= 0D) { - return 0D; - } - double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) / length; - return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine)))); - } - - double surfaceRouteTransitionPenalty( - RouteCandidate start, - RouteCandidate end - ) { - int maximumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().maximumWidth()); - RouteDirection tangent = planner.routePaths.direction(start.point().x(), start.point().z(), end.point().x(), end.point().z()); - int endpointCeiling = Math.max(start.point().y(), end.point().y()); - double penalty = 0D; - List raster = planner.segments.rasterLine(start.point(), end.point()); - int sampleStride = Math.max(1, planner.settings.routing().refinementSpacing() / 4); - for (int pointIndex = 0; pointIndex < raster.size(); pointIndex += sampleStride) { - HydrologyPoint point = raster.get(pointIndex); - HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z()); - if (terrain == null) { - return 1.0E12D; - } - int permittedIncision = planner.sourcePlanner.permittedSurfaceIncision(terrain); - int ridgeExcess = Math.max( - 0, - terrain.naturalHeight() - endpointCeiling - permittedIncision - ); - penalty += (double) ridgeExcess * ridgeExcess * 65536D; - if (terrain.slope() >= planner.settings.hydraulics().waterfallMinimumDrop()) { - continue; - } - int head = Math.subtractExact( - terrain.naturalHeight(), - planner.settings.surface().banks().sink() - ); - penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty( - point, - tangent, - maximumBankDistance, - head, - planner.surfaceCourses.maximumSurfaceBankRise(terrain) - ); - } - if ((raster.size() - 1) % sampleStride != 0) { - HydrologyPoint point = raster.getLast(); - HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z()); - if (terrain == null) { - return 1.0E12D; - } - int permittedIncision = planner.sourcePlanner.permittedSurfaceIncision(terrain); - int ridgeExcess = Math.max( - 0, - terrain.naturalHeight() - endpointCeiling - permittedIncision - ); - penalty += (double) ridgeExcess * ridgeExcess * 65536D; - if (terrain.slope() < planner.settings.hydraulics().waterfallMinimumDrop()) { - int head = Math.subtractExact( - terrain.naturalHeight(), - planner.settings.surface().banks().sink() - ); - penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty( - point, - tangent, - maximumBankDistance, - head, - planner.surfaceCourses.maximumSurfaceBankRise(terrain) - ); - } - } - return penalty; - } - - static final class CurvatureWorkspace { - private final double[][] firstCosts; - private final double[][] secondCosts; - private final int[][][] predecessors; - private final double[][][] geometryCosts; - private final long[][][] geometryHeights; - private final boolean[][][] geometryKnown; - - CurvatureWorkspace(List> layers) { - int maximumCandidates = 0; - for (List layer : layers) { - maximumCandidates = Math.max(maximumCandidates, layer.size()); - } - firstCosts = new double[maximumCandidates][maximumCandidates]; - secondCosts = new double[maximumCandidates][maximumCandidates]; - predecessors = new int[layers.size()][][]; - geometryCosts = new double[layers.size()][][]; - geometryHeights = new long[layers.size()][][]; - geometryKnown = new boolean[layers.size()][][]; - for (int layer = 1; layer < layers.size(); layer++) { - int previousSize = layers.get(layer - 1).size(); - int currentSize = layers.get(layer).size(); - predecessors[layer] = new int[previousSize][currentSize]; - geometryCosts[layer] = new double[previousSize][currentSize]; - geometryHeights[layer] = new long[previousSize][currentSize]; - geometryKnown[layer] = new boolean[previousSize][currentSize]; - } - } - } - - record CurvatureRouteSelection(RouteCandidate[] route, int[] indices) { - private static CurvatureRouteSelection empty() { - return new CurvatureRouteSelection(new RouteCandidate[0], new int[0]); - } - } - record TerrainTransitionKey(HydrologyPoint start, HydrologyPoint end) { } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutePath.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutePath.java index 191d12597..06c1c7abf 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutePath.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutePath.java @@ -23,19 +23,6 @@ final class HydrologyRoutePath { return false; } - double bilinear( - double northwest, - double northeast, - double southwest, - double southeast, - double progressX, - double progressZ - ) { - double north = northwest + (northeast - northwest) * progressX; - double south = southwest + (southeast - southwest) * progressX; - return north + (south - north) * progressZ; - } - int removeTerrainPits(List> layers, RouteCandidate[] route) { int removed = 0; for (int routeIndex = 1; routeIndex < route.length - 1; routeIndex++) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutingPlan.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutingPlan.java index 398e5f0f2..604186587 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutingPlan.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyRoutingPlan.java @@ -8,6 +8,7 @@ record HydrologyRoutingPlan( int[] outletIndex, int[] routeLengths, List outlets, - boolean organicSurfaceRequired + boolean organicSurfaceRequired, + HydrologyDrainageGeometry surfaceDrainage ) { } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySampledGrid.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySampledGrid.java index 8d07ba2c1..3f778e85e 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySampledGrid.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySampledGrid.java @@ -2,16 +2,77 @@ package art.arcane.iris.generation.hydrology; import java.util.List; -record HydrologySampledGrid( - int minimumX, - int minimumZ, - int ownerMinimumX, - int ownerMinimumZ, - int ownerSize, - int width, - int spacing, - List nodes -) { +final class HydrologySampledGrid { + private final int minimumX; + private final int minimumZ; + private final int ownerMinimumX; + private final int ownerMinimumZ; + private final int ownerSize; + private final int width; + private final int spacing; + private final List nodes; + private volatile HydrologyDrainageGraph drainage; + + HydrologySampledGrid(int minimumX, int minimumZ, int ownerMinimumX, int ownerMinimumZ, + int ownerSize, int width, int spacing, List nodes) { + this.minimumX = minimumX; + this.minimumZ = minimumZ; + this.ownerMinimumX = ownerMinimumX; + this.ownerMinimumZ = ownerMinimumZ; + this.ownerSize = ownerSize; + this.width = width; + this.spacing = spacing; + this.nodes = List.copyOf(nodes); + } + + int minimumX() { + return minimumX; + } + + int minimumZ() { + return minimumZ; + } + + int ownerMinimumX() { + return ownerMinimumX; + } + + int ownerMinimumZ() { + return ownerMinimumZ; + } + + int ownerSize() { + return ownerSize; + } + + int width() { + return width; + } + + int spacing() { + return spacing; + } + + List nodes() { + return nodes; + } + + + HydrologyDrainageGraph drainage(HydrologySourcePlanner sourcePlanner) { + HydrologyDrainageGraph current = drainage; + if (current != null && current.settings().equals(sourcePlanner.settings())) { + return current; + } + synchronized (this) { + current = drainage; + if (current == null || !current.settings().equals(sourcePlanner.settings())) { + current = new HydrologyDrainageGraph(this, sourcePlanner); + drainage = current; + } + return current; + } + } + HydrologyGridNode node(int index) { return nodes.get(index); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java index c6d2ec123..34c0d3719 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySourcePlanner.java @@ -21,6 +21,10 @@ final class HydrologySourcePlanner { this.planner = planner; } + HydrologyPlannerSettings settings() { + return planner.settings; + } + static final long EDGE_SALT = 0x45444745L; static final long SURFACE_SOURCE_SALT = 0x53555246414345L; static final long UNDERGROUND_SOURCE_SALT = 0x554e444552L; @@ -70,10 +74,7 @@ final class HydrologySourcePlanner { double[] potential = new double[nodeCount]; int[] parent = new int[nodeCount]; int[] outletIndex = new int[nodeCount]; - int[] minimumNeighborHeights = new int[nodeCount]; - for (HydrologyGridNode node : grid.nodes()) { - minimumNeighborHeights[node.index()] = minimumNeighborHeight(grid, node); - } + HydrologyDrainageGraph drainage = grid.drainage(this); Arrays.fill(potential, Double.POSITIVE_INFINITY); Arrays.fill(parent, -1); Arrays.fill(outletIndex, -1); @@ -91,25 +92,13 @@ final class HydrologySourcePlanner { continue; } HydrologyGridNode downstream = grid.node(currentEntry.nodeIndex()); - for (HydrologyGridOffset offset : ROUTING_OFFSETS) { - HydrologyGridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z()); - if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed() - || !upstream.terrain().drainsInto(downstream.terrain()) - || surface && !HydrologySurfaceProfiles.sharesProfile(upstream.terrain(), 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. + for (int direction = 0; direction < ROUTING_OFFSETS.size(); direction++) { + int upstreamIndex = drainage.upstream(downstream.index(), direction, surface); + if (upstreamIndex < 0) { continue; } - if (surface && downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight() - > maximumSurfaceEdgeRise(downstream.terrain())) { - continue; - } - double edgeCost = routeCost( - upstream, - downstream, - offset, - minimumNeighborHeights[downstream.index()] - ); + HydrologyGridNode upstream = grid.node(upstreamIndex); + double edgeCost = drainage.cost(downstream.index(), direction); double candidatePotential = currentEntry.potential() + edgeCost; int existingParent = parent[upstream.index()]; boolean replace = candidatePotential < potential[upstream.index()] - 1.0E-9D; @@ -135,7 +124,9 @@ final class HydrologySourcePlanner { outletIndex, routeLengths(parent, grid.width(), planner.settings.routing()), List.copyOf(outlets), - false + false, + surface ? new HydrologyDrainageGeometry(planner, + new HydrologyDrainageGeometry.Basin(grid, parent, outletIndex, List.copyOf(outlets))) : null ); } @@ -160,7 +151,8 @@ final class HydrologySourcePlanner { routing.outletIndex(), routing.routeLengths(), routing.outlets(), - true + true, + routing.surfaceDrainage() ); } @@ -985,75 +977,75 @@ final class HydrologySourcePlanner { HydrologyGridNode downstream = grid.node(downstreamIndex); RiverOutlet outlet = routing.outlets().get(routing.outletIndex()[upstream.index()]).outlet(); long edgeId = HydrologyHash.mix(planner.worldSeed, EDGE_SALT, upstream.id(), downstream.id(), outlet.id()); - long refinementId = surface ? HydrologyHash.mix(edgeId, SURFACE_SOURCE_SALT) : edgeId; - List centerline = refinedEdges.get(refinementId); - int transverseCandidates = HydrologyRouteGeometry.ROUTE_TRANSVERSE_CANDIDATES; - if (centerline == null) { - HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface); - HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface); - HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface); - RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey( - upstream.id(), - downstream.id(), - upstreamAnchor.x(), - upstreamAnchor.z(), - downstreamAnchor.x(), - downstreamAnchor.z(), - continuation.x(), - continuation.z(), - transverseCandidates, - surface - ); - centerline = planner.refinedEdgeCache.get( - refinedEdgeKey, - ignored -> planner.routeGeometry.refineEdge( - upstream.id(), - downstream.id(), - upstreamAnchor, - downstreamAnchor, - continuation, - transverseCandidates, - surface - ) - ); - refinedEdges.put(refinementId, centerline); - } - if (centerline.isEmpty()) { - HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface); - HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface); - HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface); - RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey( - upstream.id(), - downstream.id(), - upstreamAnchor.x(), - upstreamAnchor.z(), - downstreamAnchor.x(), - downstreamAnchor.z(), - continuation.x(), - continuation.z(), - HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES, - surface - ); - centerline = planner.refinedEdgeCache.get( - refinedEdgeKey, - ignored -> planner.routeGeometry.refineEdge( - upstream.id(), - downstream.id(), - upstreamAnchor, - downstreamAnchor, - continuation, - HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES, - surface - ) - ); - refinedEdges.put(refinementId, centerline); - } - if (centerline.isEmpty()) { - centerline = planner.routeGeometry.constrainedTerrainFallbackEdge( - planner.routeGeometry.routeAnchor(upstream, surface), - planner.routeGeometry.routeAnchor(downstream, surface) - ); - refinedEdges.put(refinementId, centerline); + List centerline; + if (surface) { + centerline = routing.surfaceDrainage().edge(upstream.index()); + } else { + centerline = refinedEdges.get(edgeId); + int transverseCandidates = HydrologyRouteGeometry.ROUTE_TRANSVERSE_CANDIDATES; + if (centerline == null) { + HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, false); + HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, false); + HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet); + RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey( + upstream.id(), + downstream.id(), + upstreamAnchor.x(), + upstreamAnchor.z(), + downstreamAnchor.x(), + downstreamAnchor.z(), + continuation.x(), + continuation.z(), + transverseCandidates + ); + centerline = planner.refinedEdgeCache.get( + refinedEdgeKey, + ignored -> planner.routeGeometry.refineUndergroundEdge( + upstream.id(), + downstream.id(), + upstreamAnchor, + downstreamAnchor, + continuation, + transverseCandidates + ) + ); + refinedEdges.put(edgeId, centerline); + } + if (centerline.isEmpty()) { + HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, false); + HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, false); + HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet); + RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey( + upstream.id(), + downstream.id(), + upstreamAnchor.x(), + upstreamAnchor.z(), + downstreamAnchor.x(), + downstreamAnchor.z(), + continuation.x(), + continuation.z(), + HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES + ); + centerline = planner.refinedEdgeCache.get( + refinedEdgeKey, + ignored -> planner.routeGeometry.refineUndergroundEdge( + upstream.id(), + downstream.id(), + upstreamAnchor, + downstreamAnchor, + continuation, + HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES + ) + ); + refinedEdges.put(edgeId, centerline); + } + if (centerline.isEmpty()) { + centerline = planner.routeGeometry.constrainedTerrainFallbackEdge( + planner.routeGeometry.routeAnchor(upstream, false), + planner.routeGeometry.routeAnchor(downstream, false) + ); + refinedEdges.put(edgeId, centerline); + } } if (centerline.isEmpty()) { continue; @@ -1081,7 +1073,8 @@ final class HydrologySourcePlanner { } int outletIndex = routing.outletIndex()[node.index()]; RiverOutlet outlet = routing.outlets().get(outletIndex).outlet(); - HydrologyPoint anchor = planner.routeGeometry.routeAnchor(node, surface); + HydrologyPoint anchor = surface ? routing.surfaceDrainage().anchor(node.index()) + : planner.routeGeometry.routeAnchor(node, false); HydrologyTerrainSample terrain = Objects.requireNonNull( planner.sampleLandBasis(anchor.x(), anchor.z()), "Hydrology route anchor left natural land" diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java index 0e66ed068..90317fc11 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceCoursePlanner.java @@ -9,8 +9,8 @@ import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; -import java.util.Objects; import java.util.OptionalLong; +import java.util.Objects; final class HydrologySurfaceCoursePlanner { private final HydrologyPlanner planner; @@ -28,7 +28,6 @@ final class HydrologySurfaceCoursePlanner { static final double SURFACE_GRID_LOCK_TOLERANCE_DEGREES = 5D; static final double SURFACE_MAXIMUM_GRID_LOCKED_FRACTION = 0.5D; static final double SURFACE_MAXIMUM_GRID_LOCKED_RUN = 64D; - static final double SURFACE_MAXIMUM_WIDTH_SCALE = 1.18D; void compileSurfaceCourses( HydrologyTileKey key, @@ -407,224 +406,8 @@ final class HydrologySurfaceCoursePlanner { if (guide.size() < 2 || guideEdges.size() != guide.size() - 1) { return null; } - ArrayList routedGuide = new ArrayList<>(guide); - ArrayList routedGuideEdges = new ArrayList<>(guideEdges); - HydrologyPoint landward = outlet.landwardPoint(); - HydrologyPoint guideEnd = routedGuide.getLast(); - boolean terminateAtSinkhole = planner.segments.surfaceSinkhole(outlet) - && (guideEnd.x() != landward.x() || guideEnd.z() != landward.z()); - if (terminateAtSinkhole) { - routedGuide.set(routedGuide.size() - 1, landward); - } - List surfaceGuide = planner.routePaths.withoutDuplicateRoutePoints(routedGuide); - long geometryId = HydrologyHash.mix( - planner.worldSeed, - COURSE_SALT, - HydrologySourcePlanner.SURFACE_SOURCE_SALT, - edges.getFirst().upstreamNodeId(), - outlet.id() - ); - Objects.requireNonNull(grid, "Surface course routing grid is required"); - HydrologyPlanner.PlanningSamples samples = planner.planningSamples.get(); - DraftProfile profile = planner.currentDraftProfile(); - profile.routeCalls++; - List points; - if (samples == null) { - profile.routeSolves++; - long solveStarted = System.nanoTime(); - points = surfaceCourseCenterline(grid, surfaceGuide, geometryId); - profile.routeSolveNanos += System.nanoTime() - solveStarted; - } else { - HashMap> routes = samples.surfaceRoutes.computeIfAbsent( - grid, ignored -> new HashMap<>() - ); - SurfaceRouteKey routeKey = new SurfaceRouteKey(surfaceGuide, geometryId); - points = routes.computeIfAbsent(routeKey, ignored -> { - profile.routeSolves++; - long solveStarted = System.nanoTime(); - List solved = surfaceCourseCenterline(grid, surfaceGuide, geometryId); - profile.routeSolveNanos += System.nanoTime() - solveStarted; - return solved; - }); - } - if (points.size() < 2) { - return null; - } - List pairEdges = mapCoursePairs(points, routedGuide, routedGuideEdges); - if (pairEdges.size() != points.size() - 1) { - return null; - } - return new HydrologyCoursePath( - points, - pairEdges, - List.copyOf(edges), - outlet, - true, - routing.organicSurfaceRequired(), - null - ); - } - - List surfaceCourseCenterline( - HydrologySampledGrid grid, - List guide, - long geometryId - ) { - if (guide.size() < 2) { - return List.of(); - } - int refinement = planner.settings.routing().refinementSpacing(); - List base = smoothSurfaceGuide(resampleSurfaceGuide(guide, refinement)); - List nominal = meanderSurfaceGuide(base, geometryId, refinement); - if (nominal.size() < 2) { - return List.of(); - } - ArrayList> layers = new ArrayList<>(nominal.size()); - int transverseCandidates = HydrologyRouteGeometry.SURFACE_COURSE_TRANSVERSE_CANDIDATES; - for (int pointIndex = 0; pointIndex < nominal.size(); pointIndex++) { - SurfaceCurvePoint point = nominal.get(pointIndex); - RouteDirection tangent = surfaceCurveTangent(nominal, pointIndex); - if (pointIndex == 0 || pointIndex == nominal.size() - 1) { - HydrologyPoint endpoint = pointIndex == 0 ? guide.getFirst() : guide.getLast(); - layers.add(List.of(planner.routeGeometry.routeEndpoint(endpoint, tangent))); - continue; - } - double progress = pointIndex / (double) (nominal.size() - 1); - SurfaceCurvePoint basePoint = base.get(pointIndex); - RoutePosition position = new RoutePosition( - point.x(), - point.z(), - basePoint.x(), - basePoint.z(), - tangent - ); - long candidateId = HydrologyHash.mix(geometryId, pointIndex); - List candidates = planner.routeGeometry.unresolvedSurfaceRouteCandidates( - grid, - geometryId, - candidateId, - position, - progress, - refinement, - transverseCandidates - ); - if (candidates.isEmpty()) { - return List.of(); - } - layers.add(candidates); - } - ArrayList> availableLayers = new ArrayList<>(layers); - double maximumTurn = Math.min( - HydrologyRouteGeometry.SURFACE_COURSE_MAXIMUM_TURN_DEGREES, - planner.settings.geometry().meanders().maximumTurnDegrees() - ); - while (true) { - RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute( - availableLayers, - refinement, - maximumTurn, - HydrologyRouteGeometry.SURFACE_COURSE_TURN_COST - ); - if (route.length == 0) { - if (addExhaustedSurfaceRouteFallbacks( - grid, - availableLayers, - nominal, - base, - geometryId - )) { - continue; - } - return List.of(); - } - int pitRemoval = planner.routePaths.removeTerrainPits(availableLayers, route); - if (pitRemoval < 0) { - return List.of(); - } - if (pitRemoval > 0) { - continue; - } - ArrayList points = new ArrayList<>(route.length); - for (RouteCandidate candidate : route) { - HydrologyPoint point = candidate.point(); - if (points.isEmpty() - || point.x() != points.getLast().x() - || point.z() != points.getLast().z()) { - points.add(point); - } - } - if (points.size() < 2 || !planner.routePaths.traversableRoute(points)) { - return List.of(); - } - return planner.routePaths.smoothTerrainRoute(points); - } - } - - boolean addExhaustedSurfaceRouteFallbacks( - HydrologySampledGrid grid, - List> layers, - List nominal, - List base, - long geometryId - ) { - boolean added = false; - for (int layerIndex = 1; layerIndex < layers.size() - 1; layerIndex++) { - List layer = layers.get(layerIndex); - if (!exhaustedSurfaceRouteLayer(layer)) { - continue; - } - SurfaceCurvePoint nominalPoint = nominal.get(layerIndex); - SurfaceCurvePoint basePoint = base.get(layerIndex); - RouteDirection tangent = surfaceCurveTangent(nominal, layerIndex); - int fallbackX = (int) StrictMath.round(basePoint.x()); - int fallbackZ = (int) StrictMath.round(basePoint.z()); - long packed = RiverFootprint.pack(fallbackX, fallbackZ); - boolean present = false; - for (RouteCandidate candidate : layer) { - if (RiverFootprint.pack(candidate.point().x(), candidate.point().z()) == packed) { - present = true; - break; - } - } - if (present) { - continue; - } - RouteTerrainEstimate estimate = planner.routeGeometry.routeTerrainEstimate(grid, fallbackX, fallbackZ); - double offset = (basePoint.x() - nominalPoint.x()) * -tangent.z() - + (basePoint.z() - nominalPoint.z()) * tangent.x(); - double localScore = estimate.terrainScore() - + StrictMath.abs(offset) * 0.18D - + HydrologyHash.unit(HydrologyHash.mix( - planner.worldSeed, - geometryId, - HydrologyHash.mix(geometryId, layerIndex), - fallbackX, - fallbackZ - )) * 1.0E-6D; - ArrayList expanded = new ArrayList<>(layer); - expanded.add(new RouteCandidate( - new HydrologyPoint(fallbackX, estimate.height(), fallbackZ), - basePoint.x(), - basePoint.z(), - offset, - localScore, - estimate.terrainScore(), - tangent, - false - )); - layers.set(layerIndex, List.copyOf(expanded)); - added = true; - } - return added; - } - - boolean exhaustedSurfaceRouteLayer(List layer) { - for (RouteCandidate candidate : layer) { - if (!candidate.terrainResolved() || Double.isFinite(candidate.localScore())) { - return false; - } - } - return true; + return new HydrologyCoursePath(List.copyOf(guide), List.copyOf(guideEdges), List.copyOf(edges), + outlet, true, routing.organicSurfaceRequired(), null); } double routeTurnScore(List route) { @@ -652,289 +435,6 @@ final class HydrologySurfaceCoursePlanner { return percentile + maximum * 0.05D; } - double surfaceRouteCandidateBankPenalty(RouteCandidate candidate) { - int maximumBankDistance = surfaceBankDistance(planner.settings.surface().maximumWidth()); - HydrologyPoint point = candidate.point(); - HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z()); - if (terrain == null) { - return Double.POSITIVE_INFINITY; - } - if (terrain.slope() >= planner.settings.hydraulics().waterfallMinimumDrop()) { - return 0D; - } - int head = Math.subtractExact( - terrain.naturalHeight(), - planner.settings.surface().banks().sink() - ); - return surfaceRouteBankBandPenalty( - point, - candidate.tangent(), - maximumBankDistance, - head, - maximumSurfaceBankRise(terrain) - ); - } - - int maximumSurfaceBankRise(HydrologyTerrainSample terrain) { - return Math.addExact( - terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()), - (int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D) - ); - } - - double surfaceRouteBankBandPenalty( - HydrologyPoint point, - RouteDirection tangent, - int maximumDistance, - int head, - int maximumBankRise - ) { - int innerDistance = surfaceBankInnerDistance(); - double penalty = 0D; - for (int distance = innerDistance; distance <= maximumDistance; distance++) { - penalty += surfaceRouteBankPenalty(point, tangent, distance, head, maximumBankRise); - penalty += surfaceRouteBankPenalty(point, tangent, -distance, head, maximumBankRise); - } - return penalty; - } - - double surfaceRouteBankPenalty( - HydrologyPoint point, - RouteDirection tangent, - int signedDistance, - int head, - int maximumBankRise - ) { - int bankX = (int) StrictMath.round(point.x() - tangent.z() * signedDistance); - int bankZ = (int) StrictMath.round(point.z() + tangent.x() * signedDistance); - double bankHeight = planner.sampleLandHeight(bankX, bankZ); - if (Double.isNaN(bankHeight)) { - return 1.0E12D; - } - int rise = (int) bankHeight - head; - int shortfall = HydrologyRouteGeometry.SURFACE_MINIMUM_NATURAL_BANK_RISE - rise; - if (shortfall > 0) { - return (double) shortfall * shortfall * 4096D; - } - int excess = Math.max(0, rise - maximumBankRise); - return (double) excess * excess * 128D; - } - - /** - * Innermost probed ring, in blocks out from the centerline. It reads {@code surface.maximumWidth()} and - * nothing else - no {@code shoreWidth}, no candidate, no terrain - so raising the shore knob widens the - * probe band instead of sliding it. That is the whole guarantee. - * - *

The ring sits one block outside half the nominal maximum width, which is the widest channel the - * width sampler will draw, not the widest channel that gets cut: {@code ChannelProfileBuilder} clamps a - * station to {@code maximumWidth * 2} after the terrain width multiplier, then scales it again by - * {@code banks().springWidthRatio()} over the spring reach and {@code banks().mouthFlareRatio()} through - * the inlet flare. Routing runs before any profile exists, so no ring can promise to clear the channel. - */ - int surfaceBankInnerDistance() { - return (int) StrictMath.ceil(planner.settings.surface().maximumWidth() / 2D) + 1; - } - - /** - * Outermost probed ring: where the bank of the widest channel is expected once the shore is cut. It - * gains a ring per block of {@code shoreWidth}, which is what widens the band walked by the penalty. - */ - int surfaceBankDistance(int width) { - int channelRadius = (int) StrictMath.ceil(width / 2D); - return Math.max( - channelRadius + 1, - (int) StrictMath.ceil( - channelRadius * SURFACE_MAXIMUM_WIDTH_SCALE + planner.settings.surface().shoreWidth() - ) + 1 - ); - } - - List resampleSurfaceGuide(List guide, int spacing) { - double[] cumulative = new double[guide.size()]; - for (int pointIndex = 1; pointIndex < guide.size(); pointIndex++) { - HydrologyPoint previous = guide.get(pointIndex - 1); - HydrologyPoint point = guide.get(pointIndex); - cumulative[pointIndex] = cumulative[pointIndex - 1] - + StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); - } - double totalLength = cumulative[cumulative.length - 1]; - if (totalLength <= 0D) { - return List.of(); - } - int capacity = Math.addExact(2, (int) StrictMath.ceil(totalLength / spacing)); - ArrayList sampled = new ArrayList<>(capacity); - int segmentIndex = 0; - for (double distance = 0D; distance < totalLength; distance += spacing) { - while (segmentIndex < guide.size() - 2 - && cumulative[segmentIndex + 1] < distance) { - segmentIndex++; - } - HydrologyPoint start = guide.get(segmentIndex); - HydrologyPoint end = guide.get(segmentIndex + 1); - double segmentLength = cumulative[segmentIndex + 1] - cumulative[segmentIndex]; - double progress = segmentLength <= 0D - ? 0D - : (distance - cumulative[segmentIndex]) / segmentLength; - sampled.add(new SurfaceCurvePoint( - start.x() + (end.x() - start.x()) * progress, - start.z() + (end.z() - start.z()) * progress, - distance - )); - } - HydrologyPoint end = guide.getLast(); - sampled.add(new SurfaceCurvePoint(end.x(), end.z(), totalLength)); - return List.copyOf(sampled); - } - - List smoothSurfaceGuide(List guide) { - if (guide.size() < 3) { - return guide; - } - List current = guide; - int passes = Math.max(2, planner.settings.geometry().meanders().smoothingPasses() + 1); - for (int pass = 0; pass < passes; pass++) { - ArrayList smoothed = new ArrayList<>(current.size()); - smoothed.add(current.getFirst()); - for (int pointIndex = 1; pointIndex < current.size() - 1; pointIndex++) { - SurfaceCurvePoint previous = current.get(pointIndex - 1); - SurfaceCurvePoint point = current.get(pointIndex); - SurfaceCurvePoint next = current.get(pointIndex + 1); - smoothed.add(new SurfaceCurvePoint( - previous.x() * 0.25D + point.x() * 0.5D + next.x() * 0.25D, - previous.z() * 0.25D + point.z() * 0.5D + next.z() * 0.25D, - point.distance() - )); - } - smoothed.add(current.getLast()); - current = List.copyOf(smoothed); - } - return current; - } - - List meanderSurfaceGuide( - List guide, - long geometryId, - int refinement - ) { - HydrologyPlannerSettings.Meanders meanders = planner.settings.geometry().meanders(); - double detailWeight = meanders.detailStrength() * 0.08D; - double totalWeight = meanders.primaryStrength() + detailWeight; - if (guide.size() < 3 || totalWeight <= 0D || meanders.maximumOffsetRatio() <= 0D) { - return guide; - } - double maximumOffset = planner.settings.routing().sampleSpacing() * Math.min( - 0.16D, - meanders.maximumOffsetRatio() * 0.32D - ); - double primaryPhase = HydrologyHash.unit(HydrologyHash.mix(geometryId, HydrologyRouteGeometry.ROUTE_WORM_PRIMARY_SALT)) - * StrictMath.PI * 2D; - double detailPhase = HydrologyHash.unit(HydrologyHash.mix(geometryId, HydrologyRouteGeometry.ROUTE_WORM_DETAIL_SALT)) - * StrictMath.PI * 2D; - double totalLength = guide.getLast().distance(); - double taperLength = Math.max( - refinement * 8D, - Math.min(planner.settings.routing().sampleSpacing() * 0.75D, meanders.primaryWavelength() * 0.35D) - ); - ArrayList meandered = new ArrayList<>(guide.size()); - for (int pointIndex = 0; pointIndex < guide.size(); pointIndex++) { - SurfaceCurvePoint point = guide.get(pointIndex); - RouteDirection tangent = surfaceCurveTangent(guide, pointIndex); - double endpointDistance = Math.min(point.distance(), totalLength - point.distance()); - double taperProgress = Math.min(1D, endpointDistance / taperLength); - double taper = StrictMath.pow(StrictMath.sin(taperProgress * StrictMath.PI * 0.5D), 2D); - double primary = StrictMath.sin( - point.distance() * StrictMath.PI * 2D / meanders.primaryWavelength() + primaryPhase - ) * meanders.primaryStrength(); - double detail = StrictMath.sin( - point.distance() * StrictMath.PI * 2D / meanders.detailWavelength() + detailPhase - ) * detailWeight; - double offset = maximumOffset * taper * (primary + detail) / totalWeight; - meandered.add(new SurfaceCurvePoint( - point.x() - tangent.z() * offset, - point.z() + tangent.x() * offset, - point.distance() - )); - } - return List.copyOf(meandered); - } - - RouteDirection surfaceCurveTangent(List points, int pointIndex) { - SurfaceCurvePoint start = points.get(Math.max(0, pointIndex - 2)); - SurfaceCurvePoint end = points.get(Math.min(points.size() - 1, pointIndex + 2)); - double deltaX = end.x() - start.x(); - double deltaZ = end.z() - start.z(); - double length = StrictMath.hypot(deltaX, deltaZ); - return length <= 0D - ? new RouteDirection(1D, 0D) - : new RouteDirection(deltaX / length, deltaZ / length); - } - - List mapCoursePairs( - List points, - List guide, - List guideEdges - ) { - ArrayList mapped = new ArrayList<>(points.size() - 1); - int minimumGuideIndex = 0; - for (int pointIndex = 0; pointIndex < points.size() - 1; pointIndex++) { - HydrologyPoint first = points.get(pointIndex); - HydrologyPoint second = points.get(pointIndex + 1); - double x = (first.x() + second.x()) * 0.5D; - double z = (first.z() + second.z()) * 0.5D; - int selectedIndex = minimumGuideIndex; - double selectedDistance = Double.POSITIVE_INFINITY; - for (int guideIndex = minimumGuideIndex; guideIndex < guideEdges.size(); guideIndex++) { - double distance = pointToSegmentDistanceSquared( - x, - z, - guide.get(guideIndex), - guide.get(guideIndex + 1) - ); - if (distance < selectedDistance) { - selectedIndex = guideIndex; - selectedDistance = distance; - } - } - minimumGuideIndex = selectedIndex; - mapped.add(guideEdges.get(selectedIndex)); - } - return List.copyOf(mapped); - } - - double pointToSegmentDistanceSquared( - double x, - double z, - HydrologyPoint start, - HydrologyPoint end - ) { - double deltaX = end.x() - start.x(); - double deltaZ = end.z() - start.z(); - double lengthSquared = deltaX * deltaX + deltaZ * deltaZ; - if (lengthSquared == 0D) { - double pointX = x - start.x(); - double pointZ = z - start.z(); - return pointX * pointX + pointZ * pointZ; - } - double progress = ((x - start.x()) * deltaX + (z - start.z()) * deltaZ) / lengthSquared; - progress = Math.max(0D, Math.min(1D, progress)); - double nearestX = start.x() + deltaX * progress; - double nearestZ = start.z() + deltaZ * progress; - double pointX = x - nearestX; - double pointZ = z - nearestZ; - return pointX * pointX + pointZ * pointZ; - } - - HydrologyPoint surfaceBankPoint(HydrologyPoint point, RouteDirection tangent, int signedDistance) { - return new HydrologyPoint( - (int) StrictMath.round(point.x() - tangent.z() * signedDistance), - point.y(), - (int) StrictMath.round(point.z() + tangent.x() * signedDistance) - ); - } - - record SurfaceCurvePoint(double x, double z, double distance) { - } - record SurfaceCourseBuild( RiverCourse course, HydrologyCandidateRejection rejection, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRaster.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRaster.java index df27af042..1586a7cd8 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRaster.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRaster.java @@ -3,8 +3,8 @@ package art.arcane.iris.generation.hydrology; import art.arcane.iris.generation.hydrology.surface.SurfaceBounds; import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -14,16 +14,15 @@ public final class HydrologySurfaceDropRaster { private static final HydrologySurfaceDropRaster EMPTY = new HydrologySurfaceDropRaster(List.of(), Map.of()); private final List columns; - private final Map indexed; + private final Long2ObjectOpenHashMap indexed; private final Map oceanReceivers; private HydrologySurfaceDropRaster(List columns, Map oceanReceivers) { this.columns = List.copyOf(columns); - Map indexed = new HashMap<>(columns.size()); + this.indexed = new Long2ObjectOpenHashMap<>(columns.size()); for (HydrologyColumnSample sample : columns) { indexed.put(RiverFootprint.pack(sample.x(), sample.z()), sample); } - this.indexed = Map.copyOf(indexed); this.oceanReceivers = Map.copyOf(oceanReceivers); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java index 79838c84d..17118edc5 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTileCache.java @@ -65,9 +65,10 @@ public final class HydrologyTileCache implements AutoCloseable { private final Object publicationLock = new Object(); private final BooleanSupplier waitingForbidden; private volatile SharedCacheScope sharedCacheScope; - private volatile StudioHydrologyTileStore persistentStore; + private volatile PreparedHydrologyTileStore persistentStore; private volatile boolean neighbourPrefetchEnabled; private volatile Runnable terrainPreparation; + private PregenerationScope pregenerationScope; private int demandBatches; public HydrologyTileCache(HydrologyPlanner planner) { @@ -338,27 +339,31 @@ public final class HydrologyTileCache implements AutoCloseable { SharedTileKey sharedKey = sharedCacheScope == null ? null : new SharedTileKey(sharedCacheScope, key); HydrologyTile shared = sharedKey == null ? null : SHARED_TILES.getIfPresent(sharedKey); if (shared != null && validSharedTile(shared, sharedKey)) { + PreparedHydrologyTileStore store = persistentStore; + if (store != null && store.load(key).isEmpty()) { + persistTile(store, shared); + } planner.reuseResolvedTile(shared); - IrisLogging.debug("Reused shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + IrisLogging.debug("Reused shared prepared hydrology tile %d,%d", key.tileX(), key.tileZ()); return shared; } if (shared != null) { SHARED_TILES.invalidate(sharedKey); } - StudioHydrologyTileStore store = persistentStore; + PreparedHydrologyTileStore store = persistentStore; try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) { return planAdmitted(key, planningEpoch, sharedKey, store); } } private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch, - SharedTileKey sharedKey, StudioHydrologyTileStore store) { - if (store != null && persistentStudioKey(key)) { + SharedTileKey sharedKey, PreparedHydrologyTileStore store) { + if (store != null) { HydrologyTile persisted = store.load(key).orElse(null); if (persisted != null) { SHARED_TILES.put(sharedKey, persisted); planner.reuseResolvedTile(persisted); - IrisLogging.debug("Loaded persisted Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + IrisLogging.debug("Loaded persisted prepared hydrology tile %d,%d", key.tileX(), key.tileZ()); return persisted; } } @@ -369,18 +374,12 @@ public final class HydrologyTileCache implements AutoCloseable { if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) { HydrologyTile existing = SHARED_TILES.asMap().putIfAbsent(sharedKey, planned); if (existing == null) { - IrisLogging.debug("Published shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); + IrisLogging.debug("Published shared prepared hydrology tile %d,%d", key.tileX(), key.tileZ()); } } } - if (store != null && persistentStudioKey(key) && !closed.get() && planningEpoch == cacheEpoch.get()) { - try { - store.save(planned); - IrisLogging.debug("Persisted Studio hydrology tile %d,%d", key.tileX(), key.tileZ()); - } catch (IOException failure) { - IrisLogging.reportError("Failed to persist Studio hydrology tile " - + key.tileX() + "," + key.tileZ() + ".", failure); - } + if (store != null && !closed.get() && planningEpoch == cacheEpoch.get()) { + persistTile(store, planned); } return planned; } catch (RuntimeException failure) { @@ -394,6 +393,15 @@ public final class HydrologyTileCache implements AutoCloseable { } } + private void persistTile(PreparedHydrologyTileStore store, HydrologyTile tile) { + try { + store.save(tile); + } catch (IOException failure) { + IrisLogging.reportError("Failed to persist prepared hydrology tile " + + tile.key().tileX() + "," + tile.key().tileZ() + ".", failure); + } + } + private static boolean interrupted(Throwable failure) { if (Thread.currentThread().isInterrupted()) { return true; @@ -510,6 +518,9 @@ public final class HydrologyTileCache implements AutoCloseable { public void clear() { ArrayList> cancelledQueries = new ArrayList<>(); synchronized (prefetchQueue) { + if (pregenerationScope != null) { + pregenerationScope.close(); + } synchronized (publicationLock) { cacheEpoch.incrementAndGet(); planner.clearOwnerDrafts(); @@ -547,20 +558,29 @@ public final class HydrologyTileCache implements AutoCloseable { neighbourPrefetchEnabled = enabled; } - public void preparePregeneration(int centerBlockX, int centerBlockZ) { + public PregenerationScope preparePregeneration(PregenerationArea area) { + Objects.requireNonNull(area, "area"); int tileSize = planner.settings().routing().tileSize(); - int minimumBlockX = Math.subtractExact(centerBlockX, tileSize); - int minimumBlockZ = Math.subtractExact(centerBlockZ, tileSize); - int maximumBlockX = Math.addExact(centerBlockX, tileSize); - int maximumBlockZ = Math.addExact(centerBlockZ, tileSize); - enqueuePrefetchArea(prefetchAreaKeys( - minimumBlockX, - minimumBlockZ, - maximumBlockX, - maximumBlockZ, - centerBlockX, - centerBlockZ - ), true); + int publicationRadius = planner.settings().publicationRadius(); + TileBounds bounds = new TileBounds( + tileCoordinate(Math.subtractExact(area.minimumBlockX(), publicationRadius), tileSize), + tileCoordinate(Math.subtractExact(area.minimumBlockZ(), publicationRadius), tileSize), + tileCoordinate(Math.addExact(area.maximumBlockX(), publicationRadius), tileSize), + tileCoordinate(Math.addExact(area.maximumBlockZ(), publicationRadius), tileSize)); + List keys = prefetchAreaKeys( + Math.max(area.minimumBlockX(), (long) area.centerBlockX() - tileSize), + Math.max(area.minimumBlockZ(), (long) area.centerBlockZ() - tileSize), + Math.min(area.maximumBlockX(), (long) area.centerBlockX() + tileSize), + Math.min(area.maximumBlockZ(), (long) area.centerBlockZ() + tileSize), + area.centerBlockX(), area.centerBlockZ()); + PregenerationScope scope = new PregenerationScope(bounds); + try { + enqueuePrefetchArea(keys, scope); + return scope; + } catch (RuntimeException | Error failure) { + scope.close(); + throw failure; + } } public void enableSharedCache(SharedCacheScope scope, Path persistentRoot) { @@ -570,16 +590,7 @@ public final class HydrologyTileCache implements AutoCloseable { sharedCacheScope = Objects.requireNonNull(scope, "scope"); persistentStore = persistentRoot == null ? null - : new StudioHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize()); - } - - private boolean persistentStudioKey(HydrologyTileKey key) { - int tileSize = planner.settings().routing().tileSize(); - int publicationRadius = planner.settings().publicationRadius(); - int minimumTile = Math.subtractExact(tileCoordinate(-(long) publicationRadius, tileSize), 1); - int maximumTile = Math.addExact(tileCoordinate(CHUNK_SIZE - 1L + publicationRadius, tileSize), 1); - return key.tileX() >= minimumTile && key.tileX() <= maximumTile - && key.tileZ() >= minimumTile && key.tileZ() <= maximumTile; + : new PreparedHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize()); } @Override @@ -622,6 +633,11 @@ public final class HydrologyTileCache implements AutoCloseable { .join(); planning.clear(); diagnosing.clear(); + synchronized (prefetchQueue) { + if (pregenerationScope != null) { + pregenerationScope.close(); + } + } clear(); } @@ -833,11 +849,11 @@ public final class HydrologyTileCache implements AutoCloseable { return; } enqueuePrefetchArea(prefetchAreaKeys(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, - centreBlockX, centreBlockZ), false); + centreBlockX, centreBlockZ), null); } - private List prefetchAreaKeys(int minimumBlockX, int minimumBlockZ, int maximumBlockX, - int maximumBlockZ, int centreBlockX, int centreBlockZ) { + private List prefetchAreaKeys(long minimumBlockX, long minimumBlockZ, long maximumBlockX, + long maximumBlockZ, int centreBlockX, int centreBlockZ) { int tileSize = planner.settings().routing().tileSize(); int publicationRadius = planner.settings().publicationRadius(); int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize); @@ -858,11 +874,20 @@ public final class HydrologyTileCache implements AutoCloseable { return keys; } - private void enqueuePrefetchArea(List keys, boolean discardOutsideArea) { - Set area = discardOutsideArea ? new HashSet<>(keys) : Set.of(); + private void enqueuePrefetchArea(List keys, PregenerationScope scope) { + Set area = scope != null ? new HashSet<>(keys) : Set.of(); tiles.cleanUp(); synchronized (prefetchQueue) { - if (discardOutsideArea) { + if (closed.get()) { + if (scope != null) { + requireOpen(); + } + return; + } + if (scope != null) { + scope.previousNeighbourPrefetch = pregenerationScope == null + ? neighbourPrefetchEnabled : pregenerationScope.previousNeighbourPrefetch; + pregenerationScope = scope; prefetchQueue.removeIf(key -> { if (!area.contains(key)) { queuedPrefetches.remove(key); @@ -903,6 +928,7 @@ public final class HydrologyTileCache implements AutoCloseable { private boolean enqueuePrefetch(HydrologyTileKey key) { if (closed.get() + || pregenerationScope != null && !pregenerationScope.bounds.contains(key) || tiles.getIfPresent(key) != null || planning.containsKey(new CacheLoadKey<>(cacheEpoch.get(), key)) || queuedPrefetches.contains(key) @@ -1056,7 +1082,8 @@ public final class HydrologyTileCache implements AutoCloseable { if (!queuedPrefetches.remove(key)) { continue; } - if (tiles.getIfPresent(key) == null) { + if ((pregenerationScope == null || pregenerationScope.bounds.contains(key)) + && tiles.getIfPresent(key) == null) { return key; } } @@ -1322,6 +1349,48 @@ public final class HydrologyTileCache implements AutoCloseable { } } + public record PregenerationArea( + int centerBlockX, int centerBlockZ, + long minimumBlockX, long minimumBlockZ, + long maximumBlockX, long maximumBlockZ + ) { + public PregenerationArea { + if (minimumBlockX > centerBlockX || maximumBlockX < centerBlockX + || minimumBlockZ > centerBlockZ || maximumBlockZ < centerBlockZ) { + throw new IllegalArgumentException("Pregeneration bounds must contain their center."); + } + } + } + + public final class PregenerationScope implements AutoCloseable { + private final TileBounds bounds; + private boolean previousNeighbourPrefetch; + + private PregenerationScope(TileBounds bounds) { + this.bounds = bounds; + } + + @Override + public void close() { + synchronized (prefetchQueue) { + if (pregenerationScope != this) { + return; + } + pregenerationScope = null; + neighbourPrefetchEnabled = previousNeighbourPrefetch; + prefetchQueue.clear(); + queuedPrefetches.clear(); + } + } + } + + private record TileBounds(int minimumX, int minimumZ, int maximumX, int maximumZ) { + private boolean contains(HydrologyTileKey key) { + return key.tileX() >= minimumX && key.tileX() <= maximumX + && key.tileZ() >= minimumZ && key.tileZ() <= maximumZ; + } + } + private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) { private long estimatedBytes() { long bytes = 64L + 8L * columns.length; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTributaryPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTributaryPlanner.java index b7f1c063f..1cdde9634 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTributaryPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/HydrologyTributaryPlanner.java @@ -492,14 +492,6 @@ final class HydrologyTributaryPlanner { } } 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) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/IrisRiverExcavationConfig.java b/core/src/main/java/art/arcane/iris/generation/hydrology/IrisRiverExcavationConfig.java index f321b12cd..b9a630484 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/IrisRiverExcavationConfig.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/IrisRiverExcavationConfig.java @@ -10,16 +10,16 @@ import lombok.experimental.Accessors; @Data @NoArgsConstructor @Accessors(chain = true) -@Description("Bounds terrain removal outside the wet river channel.") +@Description("Bounds terrain shaping outside the wet river channel.") public class IrisRiverExcavationConfig { @MinNumber(0) @MaxNumber(64) - @Description("Maximum blocks removed vertically from a dry bank column. Zero preserves dry bank heights.") + @Description("Maximum blocks cut or filled vertically in a dry bank column. Fill is also limited by the local channel incision policy. Zero preserves dry bank heights.") private int maximumDepth = 8; @MinNumber(1) @MaxNumber(64) - @Description("Maximum horizontal distance from the channel outline affected by shore and bank excavation.") + @Description("Maximum horizontal distance from the channel outline affected by shore and bank shaping.") private int maximumWidth = 16; @MinNumber(0) diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverBankConfig.java b/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverBankConfig.java index 5d6cca9dc..974780745 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverBankConfig.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverBankConfig.java @@ -20,7 +20,7 @@ public class IrisSurfaceRiverBankConfig { @MinNumber(0.5) @MaxNumber(12) - @Description("Horizontal run in blocks of eroded bank per block of cut depth.") + @Description("Horizontal bank blend in blocks per block of terrain cut or fill.") private double blendSlope = 3D; @MinNumber(1) @@ -33,7 +33,7 @@ public class IrisSurfaceRiverBankConfig { @Description("Widest eroded band outside the shore, in blocks.") private int maximumBlendWidth = 32; - @Description("Limits dry bank cut depth, width, and excavated volume independently of the wet channel.") + @Description("Limits dry bank cut depth, fill height, width, and excavated volume independently of the wet channel. Fill also respects channel.maximumIncision and local river policy.") private IrisRiverExcavationConfig excavation = new IrisRiverExcavationConfig(); @Description("Show the biome's deeper layers on eroded banks instead of the surface layer.") diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverChannelConfig.java b/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverChannelConfig.java index 040d31ddc..de43ad06b 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverChannelConfig.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/IrisSurfaceRiverChannelConfig.java @@ -24,12 +24,12 @@ public class IrisSurfaceRiverChannelConfig { @MinNumber(0) @MaxNumber(3) - @Description("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.") + @Description("Blocks the water surface sinks below the shaped bank top; 0 keeps the water flush with its banks.") private int sink = 0; @MinNumber(1) @MaxNumber(32) - @Description("Maximum cut below natural terrain at the channel center before a course is rejected.") + @Description("Maximum channel cut below natural terrain. Also caps bank fill, together with banks.excavation.maximumDepth; courses exceeding containment or terrain limits are rejected.") private int maximumIncision = 10; @MinNumber(0) diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStore.java b/core/src/main/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStore.java similarity index 94% rename from core/src/main/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStore.java rename to core/src/main/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStore.java index 4b07e4682..960724b76 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStore.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStore.java @@ -41,8 +41,8 @@ import java.util.Set; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; -final class StudioHydrologyTileStore { - private static final int SCHEMA_VERSION = 5; +final class PreparedHydrologyTileStore { + private static final int SCHEMA_VERSION = 6; private static final long MAXIMUM_COMPRESSED_BYTES = 128L * 1024L * 1024L; private static final long MAXIMUM_DECOMPRESSED_BYTES = 512L * 1024L * 1024L; private static final Gson GSON = new GsonBuilder() @@ -51,19 +51,21 @@ final class StudioHydrologyTileStore { .create(); private final Path directory; + private final String scopeIdentity; private final HydrologyTileCache.SharedCacheScope scope; private final int expectedTileSize; - StudioHydrologyTileStore(Path root, HydrologyTileCache.SharedCacheScope scope, int expectedTileSize) { + PreparedHydrologyTileStore(Path root, HydrologyTileCache.SharedCacheScope scope, int expectedTileSize) { this.scope = Objects.requireNonNull(scope, "scope"); if (expectedTileSize < 1) { throw new IllegalArgumentException("Hydrology tile size must be positive."); } this.expectedTileSize = expectedTileSize; + this.scopeIdentity = scopeFingerprint(scope); this.directory = Objects.requireNonNull(root, "root") .toAbsolutePath() .normalize() - .resolve(scopeFingerprint(scope)); + .resolve(scopeIdentity); } Optional load(HydrologyTileKey key) { @@ -85,6 +87,7 @@ final class StudioHydrologyTileStore { PersistedTile persisted = GSON.fromJson(reader, PersistedTile.class); if (persisted == null || persisted.schemaVersion() != SCHEMA_VERSION + || !scopeIdentity.equals(persisted.scopeIdentity()) || !key.equals(persisted.key()) || persisted.worldSeed() != scope.worldSeed() || persisted.settingsFingerprint() != scope.settingsFingerprint()) { @@ -99,7 +102,7 @@ final class StudioHydrologyTileStore { void save(HydrologyTile tile) throws IOException { if (!valid(tile, tile.key())) { - throw new IOException("Refused to persist a hydrology tile outside the active Studio cache scope."); + throw new IOException("Refused to persist a hydrology tile outside the active prepared-plan scope."); } Files.createDirectories(directory); Path target = file(tile.key()); @@ -109,10 +112,10 @@ final class StudioHydrologyTileStore { OutputStream buffered = new BufferedOutputStream(raw); OutputStream compressed = new GZIPOutputStream(buffered); Writer writer = new OutputStreamWriter(compressed, StandardCharsets.UTF_8)) { - GSON.toJson(PersistedTile.from(tile), writer); + GSON.toJson(PersistedTile.from(tile, scopeIdentity), writer); } if (Files.size(staged) > MAXIMUM_COMPRESSED_BYTES) { - throw new IOException("Studio hydrology tile cache entry exceeds the size limit."); + throw new IOException("Prepared hydrology tile cache entry exceeds the size limit."); } try { Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); @@ -161,6 +164,7 @@ final class StudioHydrologyTileStore { private record PersistedTile( int schemaVersion, + String scopeIdentity, HydrologyTileKey key, long worldSeed, long settingsFingerprint, @@ -174,7 +178,7 @@ final class StudioHydrologyTileStore { List localDiagnosticCandidates, List columns ) { - private static PersistedTile from(HydrologyTile tile) { + private static PersistedTile from(HydrologyTile tile, String scopeIdentity) { ArrayList courses = new ArrayList<>(tile.courses().size()); for (RiverCourse course : tile.courses()) { courses.add(PersistedCourse.from(course)); @@ -187,6 +191,7 @@ final class StudioHydrologyTileStore { } return new PersistedTile( SCHEMA_VERSION, + scopeIdentity, tile.key(), tile.worldSeed(), tile.settingsFingerprint(), @@ -375,7 +380,7 @@ final class StudioHydrologyTileStore { private void advance(long amount) throws IOException { count += amount; if (count > limit) { - throw new IOException("Studio hydrology tile cache entry exceeds the decompressed size limit."); + throw new IOException("Prepared hydrology tile cache entry exceeds the decompressed size limit."); } } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/RefinedEdgeKey.java b/core/src/main/java/art/arcane/iris/generation/hydrology/RefinedEdgeKey.java index 4f372b28a..d65021f8c 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/RefinedEdgeKey.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/RefinedEdgeKey.java @@ -9,7 +9,6 @@ record RefinedEdgeKey( int downstreamZ, int continuationX, int continuationZ, - int transverseCandidates, - boolean surface + int transverseCandidates ) { } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/RouteTerrainEstimate.java b/core/src/main/java/art/arcane/iris/generation/hydrology/RouteTerrainEstimate.java deleted file mode 100644 index 349c8cf0f..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/RouteTerrainEstimate.java +++ /dev/null @@ -1,4 +0,0 @@ -package art.arcane.iris.generation.hydrology; - -record RouteTerrainEstimate(int height, double terrainScore) { -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/SurfaceRouteKey.java b/core/src/main/java/art/arcane/iris/generation/hydrology/SurfaceRouteKey.java deleted file mode 100644 index 3d07794cb..000000000 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/SurfaceRouteKey.java +++ /dev/null @@ -1,6 +0,0 @@ -package art.arcane.iris.generation.hydrology; - -import java.util.List; - -record SurfaceRouteKey(List guide, long geometryId) { -} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java index dd8d93c06..5a7d35b08 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/runtime/IrisHydrologyRuntime.java @@ -170,8 +170,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable { cache.prefetchArea(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, centreBlockX, centreBlockZ); } - public void preparePregeneration(int centerBlockX, int centerBlockZ) { - cache.preparePregeneration(centerBlockX, centerBlockZ); + public HydrologyTileCache.PregenerationScope preparePregeneration(HydrologyTileCache.PregenerationArea area) { + return cache.preparePregeneration(area); } public void setNeighbourPrefetchEnabled(boolean enabled) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java index fa21b3a05..6f1818deb 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompiler.java @@ -31,15 +31,17 @@ public final class ErosionFieldCompiler { private final HydrologyPlannerSettings.Surface surface; private final HydrologyTerrainSampler sampler; private final int seaLevel; + private final double maximumShoreWidth; public ErosionFieldCompiler( - HydrologyPlannerSettings.Surface surface, - HydrologyTerrainSampler sampler, - int seaLevel + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler ) { - this.surface = Objects.requireNonNull(surface, "surface"); + Objects.requireNonNull(settings, "settings"); + this.surface = settings.surface(); this.sampler = Objects.requireNonNull(sampler, "sampler"); - this.seaLevel = seaLevel; + this.seaLevel = settings.seaLevel(); + this.maximumShoreWidth = Math.max(surface.shoreWidth(), settings.widestShoreBiomeWidth()); } public ErosionField compile( @@ -80,11 +82,12 @@ public final class ErosionFieldCompiler { SurfaceRasterContext context ) { return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, ponds, context, - prepare(centerline, channel, valley, terminal)); + prepare(centerline, channel, valley, terminal, terminal == SurfaceTerminal.OCEAN_MOUTH + ? inletStart(valley.exposedStations()) : centerline.size())); } PreparedField prepare(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, - SurfaceTerminal terminal) { + SurfaceTerminal terminal, int inletStart) { int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations(); boolean[] basin = basins(valley, channel, count); double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel); @@ -99,7 +102,7 @@ public final class ErosionFieldCompiler { radii[station] = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + surface.banks().roughness()) + Math.max(stationShore + widest, stationBand)) + 1; } - return new PreparedField(count, basin, bendOffsets, blendWidth, radii); + return new PreparedField(count, basin, bendOffsets, blendWidth, radii, inletStart); } ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, @@ -157,6 +160,7 @@ public final class ErosionFieldCompiler { } } int oceanStart = oceanStart(centerline, valley, terminal); + int inletStart = prepared.inletStart(); Long2ObjectOpenHashMap columns = new Long2ObjectOpenHashMap<>(nearest.size()); LongArrayList wetKeys = new LongArrayList(); for (long key : nearest.keySet()) { @@ -168,7 +172,6 @@ public final class ErosionFieldCompiler { if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) { continue; } - HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); double halfWidth = channel.halfWidth()[station]; double outline = halfWidth * (1D + roughness * SurfaceNoise.signed( courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength())); @@ -176,6 +179,13 @@ public final class ErosionFieldCompiler { Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth), Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline)); boolean wet = cellDistance <= outline + 0.25D; + double bankDistance = cellDistance - outline; + if (!wet && cellDistance > outline + maximumShoreWidth + 0.25D + && (bankDistance > erosion.excavation().maximumWidth() || !erosion.enabled() + || bankDistance - maximumShoreWidth >= blendWidth[station][side(centerline, station, cellX, cellZ) < 0D ? 0 : 1])) { + continue; + } + HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel && SurfaceCellAdmission.mouthLand(terrain, seaLevel); if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) { @@ -198,6 +208,10 @@ public final class ErosionFieldCompiler { + erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()) + (basin[station] ? flow.plungeBasinDepth() : 0D); int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local))); + int minimumBed = natural - maximumIncision(terrain, station >= inletStart); + if (minimumBed < head) { + bed = Math.max(bed, minimumBed); + } columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); wetKeys.add(key); continue; @@ -212,7 +226,6 @@ public final class ErosionFieldCompiler { // geometric shore with the bank biome. boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D; SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK; - double bankDistance = cellDistance - outline; if (bankDistance > erosion.excavation().maximumWidth()) { if (shoreBiome) { columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); @@ -220,14 +233,14 @@ public final class ErosionFieldCompiler { continue; } if (cellDistance <= outline + shore + 0.25D) { - int height = Math.min(natural, bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore))); - height = boundedBankHeight(natural, height, erosion.excavation()); + int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore)); + height = boundedBankHeight(terrain, height, erosion.excavation()); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); continue; } int shoreTop = bankTop + (int) StrictMath.round(shoreRise); int cut = natural - shoreTop; - if (cut <= 0 || !erosion.enabled() || !terrain.erosion()) { + if (cut == 0 || !erosion.enabled() || !terrain.erosion()) { if (shoreBiome) { columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false)); } @@ -243,8 +256,8 @@ public final class ErosionFieldCompiler { } continue; } - int height = Math.min(natural, (int) StrictMath.round(shoreTop + cut * blend(progress, erosion))); - height = boundedBankHeight(natural, height, erosion.excavation()); + int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion)); + height = boundedBankHeight(terrain, height, erosion.excavation()); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false)); } if (count > 0) { @@ -255,7 +268,7 @@ public final class ErosionFieldCompiler { } int uncontained = contain(columns, wetKeys, sink, bounds, context.drops()); connectSteps(columns, wetKeys); - return validate(columns, wetKeys, centerline, terminal, bounds, uncontained); + return validate(columns, wetKeys, centerline, terminal, bounds, uncontained, inletStart); } /** @@ -340,6 +353,10 @@ public final class ErosionFieldCompiler { if (wasWet) { bed = Math.min(bed, existing.height()); } + int minimumBed = natural - maximumIncision(terrain, false); + if (minimumBed < head) { + bed = Math.max(bed, minimumBed); + } columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); if (!wasWet) { wetKeys.add(key); @@ -353,24 +370,24 @@ public final class ErosionFieldCompiler { continue; } if (distance <= outline + shore + 0.25D) { - int height = Math.min(natural, bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore))); - height = boundedBankHeight(natural, height, erosion.excavation()); + int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore)); + height = boundedBankHeight(terrain, height, erosion.excavation()); columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false)); continue; } int cut = natural - shoreTop; - if (cut <= 0 || !erosion.enabled() || !erode) { + if (cut == 0 || !erosion.enabled() || !erode) { continue; } double width = Math.max(banks.minimumBlendWidth(), - Math.min(banks.maximumBlendWidth(), cut * banks.blendSlope() + erosion.blendBaseWidth())); + Math.min(banks.maximumBlendWidth(), Math.abs(cut) * banks.blendSlope() + erosion.blendBaseWidth())); width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore)); double progress = (distance - outline - shore) / width; if (progress >= 1D) { continue; } - int height = Math.min(natural, (int) StrictMath.round(shoreTop + cut * blend(progress, erosion))); - height = boundedBankHeight(natural, height, erosion.excavation()); + int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion)); + height = boundedBankHeight(terrain, height, erosion.excavation()); if (existing != null && existing.height() <= height) { continue; } @@ -401,7 +418,8 @@ public final class ErosionFieldCompiler { 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) { + if (!SurfaceCellAdmission.writable(terrain, seaLevel) + || (long) terrain.naturalHeight() + maximumFill(terrain) < bankTop) { return false; } } @@ -512,20 +530,17 @@ public final class ErosionFieldCompiler { int uncontained = 0; for (int index = 0; index < wetKeys.size(); index++) { SurfaceColumn wet = columns.get(wetKeys.getLong(index)); - if (bounds != null && !bounds.contains(wet.x(), wet.z())) { - continue; - } - uncontained += lip(columns, wet, CARDINALS, sink, true, drops); + boolean owned = bounds == null || bounds.contains(wet.x(), wet.z()); + uncontained += lip(columns, wet, CARDINALS, sink, owned, drops); lip(columns, wet, DIAGONALS, sink, false, drops); } 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. A missed low cell can retain its natural ground, - * but cannot be raised above it. A shortfall below the water head stays uncontained and is counted. + * The bank beside water reaches the planned water level plus the sink. Low neighbours may be + * filled within the local channel and bank terrain limits. Any remaining shortfall below the + * water head stays uncontained and is counted. */ private int lip( Long2ObjectOpenHashMap columns, @@ -558,8 +573,7 @@ public final class ErosionFieldCompiler { } int required = wet.headY() + sink; if (neighbour.height() < required) { - int ceiling = neighbour.terrain().naturalHeight(); - int raised = Math.min(ceiling, required); + int raised = boundedBankHeight(neighbour.terrain(), required, surface.banks().erosion().excavation()); neighbour = neighbour.withHeight(raised); columns.put(key, neighbour); } @@ -584,18 +598,51 @@ public final class ErosionFieldCompiler { return count; } - private static int boundedBankHeight(int natural, int target, HydrologyPlannerSettings.Excavation limits) { - return Math.max(natural - limits.maximumDepth(), Math.min(natural, target)); + private int boundedBankHeight(HydrologyTerrainSample terrain, int target, HydrologyPlannerSettings.Excavation limits) { + int natural = terrain.naturalHeight(); + return (int) Math.clamp((long) target, (long) natural - limits.maximumDepth(), + (long) natural + maximumFill(terrain)); + } + + private int maximumFill(HydrologyTerrainSample terrain) { + int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision()); + return Math.min(surface.banks().erosion().excavation().maximumDepth(), + Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()))); + } + + int inletStart(int exposedStations) { + HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet(); + int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposedStations * inlet.courseFraction())); + return Math.max(0, exposedStations - reach - reach / 2); + } + + private int maximumIncision(HydrologyTerrainSample terrain, boolean inlet) { + int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision()); + if (inlet) { + incision = Math.max(incision, surface.banks().inlet().maximumIncision()); + } + return Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier())); } private ErosionField validate(Long2ObjectOpenHashMap columns, LongArrayList wetKeys, - SurfaceCenterline centerline, SurfaceTerminal terminal, SurfaceBounds bounds, int uncontained) { + SurfaceCenterline centerline, SurfaceTerminal terminal, + SurfaceBounds bounds, int uncontained, int inletStart) { long excavation = 0L; int[] stationVolumes = new int[centerline.size()]; int deepest = 0; for (SurfaceColumn column : columns.values()) { - if (column.apron() || column.role() == SurfaceRole.CHANNEL - || bounds != null && !bounds.contains(column.x(), column.z())) { + if (column.apron() || bounds != null && !bounds.contains(column.x(), column.z())) { + continue; + } + if (column.role() == SurfaceRole.CHANNEL) { + HydrologyTerrainSample terrain = column.terrain(); + int incision = maximumIncision(terrain, column.station() >= inletStart); + int cut = terrain.naturalHeight() - column.height(); + if (cut > incision) { + return new ErosionField(columns, uncontained, + HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, + cut, excavation); + } continue; } int cut = Math.max(0, column.terrain().naturalHeight() - column.height()); @@ -613,14 +660,20 @@ public final class ErosionFieldCompiler { return new ErosionField(columns, uncontained, HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, uncontained, excavation); } - if (bounds == null && terminal == SurfaceTerminal.OCEAN_MOUTH && !connectedToOcean(columns, wetKeys)) { + if (bounds == null && terminal == SurfaceTerminal.OCEAN_MOUTH && !connectedToOcean(columns, wetKeys, centerline)) { return new ErosionField(columns, 0, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, excavation); } return new ErosionField(columns, uncontained, null, 0, excavation); } - private boolean connectedToOcean(Long2ObjectOpenHashMap columns, LongArrayList wetKeys) { + private boolean connectedToOcean(Long2ObjectOpenHashMap columns, LongArrayList wetKeys, + SurfaceCenterline centerline) { if (wetKeys.isEmpty()) { + for (int station = 0; station < centerline.size(); station++) { + if (sampler.receivingWater(centerline.x()[station], centerline.z()[station], seaLevel)) { + return true; + } + } return false; } long firstKey = wetKeys.getLong(0); @@ -700,7 +753,7 @@ public final class ErosionFieldCompiler { int probeX = (int) StrictMath.round(centerline.x()[station] + centerline.normalX(station) * probe * direction); int probeZ = (int) StrictMath.round(centerline.z()[station] + centerline.normalZ(station) * probe * direction); HydrologyTerrainSample terrain = sampler.sample(probeX, probeZ); - double cut = terrain == null ? 0D : Math.max(0D, terrain.naturalHeight() - bankTop); + double cut = terrain == null ? 0D : Math.abs(terrain.naturalHeight() - (double) bankTop); double width = cut * banks.blendSlope() * channel.bankMultiplier()[station] + erosion.blendBaseWidth(); widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width)); } @@ -765,6 +818,7 @@ public final class ErosionFieldCompiler { return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station); } - record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii) { + record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii, + int inletStart) { } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/RiverProfileSolver.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/RiverProfileSolver.java new file mode 100644 index 000000000..5b6f5ec39 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/RiverProfileSolver.java @@ -0,0 +1,169 @@ +package art.arcane.iris.generation.hydrology.surface; + +import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.generation.hydrology.HydrologyPoint; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; + +import java.util.List; +import java.util.Objects; + +final class RiverProfileSolver { + private final HydrologyPlannerSettings.Surface surface; + private final HydrologyTerrainSampler sampler; + private final int seaLevel; + + RiverProfileSolver(Options options, HydrologyTerrainSampler sampler) { + this.surface = Objects.requireNonNull(options.surface()); + this.sampler = Objects.requireNonNull(sampler); + this.seaLevel = options.seaLevel(); + } + + ValleyProfile solve(List path, SurfaceCenterline centerline, ChannelProfile channel, + SurfaceTerminal terminal, int terminalHead, int minimumLength) { + int count = centerline.size(); + int[] heads = interpolate(path, centerline); + int[] natural = new int[count]; + int[] incision = new int[count]; + int[] inletIncision = new int[count]; + int exposed = count; + double length = 0D; + for (int station = 0; station < count; station++) { + HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]); + boolean writable = terminal == SurfaceTerminal.OCEAN_MOUTH + ? SurfaceCellAdmission.mouthLand(terrain, seaLevel) + : SurfaceCellAdmission.writable(terrain, seaLevel); + if (!writable) { + exposed = station; + break; + } + if (!terrain.transitAllowed()) { + return ValleyProfile.rejected(HydrologyCandidateRejection.POLICY_EXCLUDED, station); + } + natural[station] = terrain.naturalHeight(); + int limit = terrain.surfacePolicy().maximumIncision(surface.maximumIncision()); + incision[station] = Math.min(limit, (int) StrictMath.floor(limit * terrain.incisionMultiplier())); + int inletLimit = Math.max(limit, surface.banks().inlet().maximumIncision()); + inletIncision[station] = Math.min(inletLimit, + (int) StrictMath.floor(inletLimit * terrain.incisionMultiplier())); + if (station > 0) { + length += StrictMath.hypot((double) centerline.x()[station] - centerline.x()[station - 1], + (double) centerline.z()[station] - centerline.z()[station - 1]); + } + } + if (exposed < 2 || length < minimumLength) { + return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, (int) StrictMath.floor(length)); + } + if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH + && terminal != SurfaceTerminal.COASTAL_GROTTO) { + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed); + } + int floor = terminal == SurfaceTerminal.OCEAN_MOUTH || terminal == SurfaceTerminal.COASTAL_GROTTO + ? Math.max(seaLevel, terminalHead) : terminalHead; + for (int station = 0; station < exposed; station++) { + heads[station] = Math.max(heads[station], floor); + } + if (terminal == SurfaceTerminal.TRIBUTARY) { + gradeTerminal(heads, exposed, terminalHead, surface.banks().cascadeRun()); + } + int inletStart = exposed; + if (terminal == SurfaceTerminal.OCEAN_MOUTH) { + inletStart = gradeInlet(heads, natural, inletIncision, channel, exposed); + for (int station = exposed; station < count; station++) { + heads[station] = seaLevel; + } + } + int sourceHead = heads[0]; + int downstream = floor; + for (int station = exposed - 1; station >= 0; station--) { + int depth = (int) StrictMath.round(channel.depth()[station]); + int limit = incision[station]; + if (station >= inletStart) { + limit = inletIncision[station]; + } + int required = Math.max(Math.max(heads[station], downstream), natural[station] - limit + depth); + if (required > sourceHead || terminal == SurfaceTerminal.TRIBUTARY + && station == exposed - 1 && required > terminalHead) { + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, + natural[station] - heads[station] + depth); + } + heads[station] = required; + downstream = required; + } + for (int station = 1; station < exposed; station++) { + int drop = heads[station - 1] - heads[station]; + if (drop >= surface.banks().waterfallMinimumDrop()) { + continue; + } + int limit = station - 1 >= inletStart ? inletIncision[station - 1] : incision[station - 1]; + int connectedHead = Math.max(heads[station - 1] - 1, natural[station - 1] - limit + 1); + if (terminal == SurfaceTerminal.TRIBUTARY && station == exposed - 1 && connectedHead > terminalHead) { + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, + natural[station - 1] - terminalHead + 1); + } + heads[station] = Math.max(heads[station], Math.min(heads[station - 1], connectedHead)); + } + return new ValleyProfile(heads, natural, natural, natural, exposed, null, 0); + } + + private static int[] interpolate(List path, SurfaceCenterline centerline) { + int[] heads = new int[centerline.size()]; + for (int station = 0; station < heads.length; station++) { + int index = Math.min(centerline.pathIndex()[station], path.size() - 1); + HydrologyPoint start = path.get(index); + HydrologyPoint end = path.get(Math.min(index + 1, path.size() - 1)); + double dx = (double) end.x() - start.x(); + double dz = (double) end.z() - start.z(); + double squared = dx * dx + dz * dz; + double progress = squared == 0D ? 0D : Math.clamp( + ((centerline.x()[station] - (double) start.x()) * dx + + (centerline.z()[station] - (double) start.z()) * dz) / squared, 0D, 1D); + int head = (int) StrictMath.round(start.y() + ((double) end.y() - start.y()) * progress); + heads[station] = station == 0 ? head : Math.min(heads[station - 1], head); + } + return heads; + } + + private static void gradeTerminal(int[] heads, int exposed, int terminalHead, int run) { + for (int station = exposed - 1; station >= 0; station--) { + long target = (long) terminalHead + Math.floorDiv(exposed - 1L - station, Math.max(1, run)); + if (heads[station] <= target) { + break; + } + heads[station] = (int) Math.min(Integer.MAX_VALUE, target); + } + } + + private int gradeInlet(int[] heads, int[] natural, int[] incision, ChannelProfile channel, int exposed) { + HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet(); + int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposed * inlet.courseFraction())); + int start = exposed; + while (start > 0 && exposed - start < reach) { + int index = start - 1; + int cut = natural[index] - seaLevel + (int) StrictMath.round(channel.depth()[index]); + if (cut > incision[index]) { + break; + } + heads[index] = seaLevel; + start--; + } + int rampStart = start; + for (int station = start - 1; station >= Math.max(0, start - reach / 2); station--) { + int target = seaLevel + (int) StrictMath.round((start - station) * inlet.rampSlope()); + if (heads[station] <= target) { + break; + } + int cut = natural[station] - target + (int) StrictMath.round(channel.depth()[station]); + if (cut > incision[station]) { + break; + } + heads[station] = target; + rampStart = station; + } + return rampStart; + } + + record Options(HydrologyPlannerSettings.Surface surface, int seaLevel) { + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilder.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilder.java index e395617c1..ba9e23c1d 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilder.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilder.java @@ -5,6 +5,7 @@ import art.arcane.iris.generation.hydrology.HydraulicSegment; import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection; import art.arcane.iris.generation.hydrology.HydrologyFeatureType; import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.generation.hydrology.HydrologyHash; import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; import art.arcane.iris.generation.hydrology.HydrologyPoint; import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; @@ -15,6 +16,8 @@ import java.util.List; import java.util.Objects; public final class SurfaceCourseBuilder { + private static final long COASTAL_DROP_SALT = 0x434f41535446414cL; + private final HydrologyPlannerSettings.Surface surface; private final HydrologyTerrainSampler sampler; private final HydrologyGeometrySampler geometry; @@ -45,14 +48,14 @@ public final class SurfaceCourseBuilder { boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH; ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, geometry) .build(centerline, profileKey, directOcean); - ValleyProfile valley = new ValleyProfileSolver(surface, sampler, seaLevel, minimumCourseLength) - .solve(centerline, channel, terminal, terminalHead); + ValleyProfile valley = new RiverProfileSolver(new RiverProfileSolver.Options(surface, seaLevel), sampler) + .solve(path, centerline, channel, terminal, terminalHead, minimumCourseLength); if (!valley.accepted()) { return SurfaceCourseResult.rejected(valley.rejection(), valley.rejectionDetail()); } int exposed = valley.exposedStations(); boolean coastalDrop = directOcean && valley.head()[exposed - 1] > seaLevel; - int stations = coastalDrop ? exposed + 1 : exposed; + int stations = exposed; int[] x = new int[stations]; int[] z = new int[stations]; int[] head = new int[stations]; @@ -65,40 +68,52 @@ public final class SurfaceCourseBuilder { width[station] = channel.halfWidth()[station] * 2D; depth[station] = channel.depth()[station]; } - if (coastalDrop) { - int last = exposed - 1; - if (exposed < centerline.size()) { - x[exposed] = centerline.x()[exposed]; - z[exposed] = centerline.z()[exposed]; - } else { - x[exposed] = x[last] + (int) StrictMath.round(centerline.tangentX()[last]); - z[exposed] = z[last] + (int) StrictMath.round(centerline.tangentZ()[last]); - if (x[exposed] == x[last] && z[exposed] == z[last]) { - x[exposed] = x[last] + 1; - } - } - head[exposed] = seaLevel; - width[exposed] = width[last]; - depth[exposed] = depth[last]; - } - List segments = fallingTransitions(SurfaceSegmentLabeler.label( - worldSeed, courseId, x, z, head, width, depth, surface.banks())); + ArrayList segments = fallingTransitions(SurfaceSegmentLabeler.label( + worldSeed, courseId, x, z, head, width, depth, surface.banks()), directOcean); if (segments.isEmpty()) { return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, stations); } + if (coastalDrop) { + int last = exposed - 1; + int receiverX; + int receiverZ; + if (exposed < centerline.size()) { + receiverX = centerline.x()[exposed]; + receiverZ = centerline.z()[exposed]; + } else { + receiverX = x[last] + (int) StrictMath.round(centerline.tangentX()[last]); + receiverZ = z[last] + (int) StrictMath.round(centerline.tangentZ()[last]); + if (receiverX == x[last] && receiverZ == z[last]) { + receiverX = x[last] + 1; + } + } + int drop = head[last] - seaLevel; + HydrologyFeatureType type = drop == 1 ? HydrologyFeatureType.RIFFLE + : drop >= surface.banks().waterfallMinimumDrop() + ? HydrologyFeatureType.WATERFALL : HydrologyFeatureType.CASCADE; + boolean receiving = sampler.receivingWater(receiverX, receiverZ, seaLevel); + segments.add(new HydraulicSegment(HydrologyHash.mix(worldSeed, COASTAL_DROP_SALT, courseId), + courseId, type, head[last], seaLevel, + Math.max(1, (int) StrictMath.ceil(width[last])), + Math.max(1, (int) StrictMath.ceil(depth[last])), receiving, receiving, + List.of(new HydrologyPoint(x[last], head[last], z[last]), + new HydrologyPoint(receiverX, seaLevel, receiverZ)), + new HydraulicChannelProfile(new double[]{width[last], width[last]}, + new double[]{depth[last], depth[last]}))); + } HydraulicSegment last = segments.getLast(); - int lastStation = stations - 1; return new SurfaceCourseResult( segments, - head[lastStation], + last.downstreamHeadY(), Math.max(1, (int) StrictMath.ceil(last.channelProfile().widthAt(last.centerline().size() - 1))), Math.max(1, (int) StrictMath.ceil(last.channelProfile().depthAt(last.centerline().size() - 1))), - new HydrologyPoint(x[lastStation], head[lastStation], z[lastStation]), + last.end(), null, 0 ); } - private List fallingTransitions(List labelled) { + + private ArrayList fallingTransitions(List labelled, boolean directOcean) { ArrayList segments = new ArrayList<>(labelled); for (int index = 0; index + 1 < segments.size(); index++) { HydraulicSegment fall = segments.get(index); @@ -112,6 +127,9 @@ public final class SurfaceCourseBuilder { continue; } int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision()); + if (directOcean && fall.downstreamHeadY() == seaLevel) { + incision = Math.max(incision, surface.banks().inlet().maximumIncision()); + } incision = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier())); if (terrain.naturalHeight() - fall.downstreamHeadY() + fall.depth() > incision) { continue; @@ -142,7 +160,7 @@ public final class SurfaceCourseBuilder { receiver.fallingFluid(), receiver.receivingPool(), receiver.centerline().subList(1, receiver.centerline().size()), receiverProfile)); } - return List.copyOf(segments); + return segments; } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java index 9d54a73c6..a343b9aa4 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompiler.java @@ -69,12 +69,11 @@ public final class SurfaceFootprintCompiler { } if (prepared.receivingSampler == null) { prepared.receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course); - prepared.offsets = stationOffsets(course.segments()); + prepared.inlet = SurfaceInletRange.forCourse(settings, course, prepared.receivingSampler); } HydrologyTerrainSampler receivingSampler = prepared.receivingSampler; HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(settings, receivingSampler, geometry, course, bounds == null ? null : bounds.expand(2)); - int[] offsets = prepared.offsets; ArrayList columns = new ArrayList<>(); int uncontained = 0; long excavation = 0L; @@ -90,7 +89,7 @@ public final class SurfaceFootprintCompiler { while (after < course.segments().size() && exposedSegment(course.segments().get(after))) { after++; } - SurfaceFootprint run = compileRun(prepared, first, after, offsets[first], bounds, drops, pool, receivingSampler); + SurfaceFootprint run = compileRun(prepared, first, after, prepared.inlet.offset(first), bounds, drops, pool, receivingSampler); columns.addAll(run.columns()); uncontained += run.uncontainedWetCells(); excavation += run.bankExcavation(); @@ -123,7 +122,7 @@ public final class SurfaceFootprintCompiler { } private PreparedRun prepareRun(RiverCourse course, int first, int after, boolean pool, - HydrologyTerrainSampler receivingSampler) { + HydrologyTerrainSampler receivingSampler, SurfaceInletRange inlet) { List exposed = course.segments().subList(first, after); Stations stations = stations(exposed); boolean fallingEnd = after < course.segments().size() && course.segments().get(after).fallingFluid(); @@ -141,9 +140,9 @@ public final class SurfaceFootprintCompiler { : new HydrologyPlannerSettings.Ponds(first == 0 ? settings.surface().banks().ponds().source() : disabled.source(), fallingEnd ? disabled.terminal() : settings.surface().banks().ponds().terminal()); SurfaceRunBoundary boundary = runBoundary(course, first, after, centerline, channel); - ErosionFieldCompiler erosion = new ErosionFieldCompiler(settings.surface(), receivingSampler, settings.seaLevel()); + ErosionFieldCompiler erosion = new ErosionFieldCompiler(settings, receivingSampler); return new PreparedRun(exposed, stations, coastalChannel, centerline, channel, poolBiome, valley, - terminal, ponds, boundary, erosion, erosion.prepare(centerline, channel, valley, terminal)); + terminal, ponds, boundary, erosion, erosion.prepare(centerline, channel, valley, terminal, inlet.firstStation(first))); } private SurfaceFootprint compileRun(PreparedCourse prepared, int first, int after, int stationOffset, @@ -152,7 +151,7 @@ public final class SurfaceFootprintCompiler { RiverCourse course = prepared.course; PreparedRun run = prepared.runs[first]; if (run == null) { - run = prepareRun(course, first, after, pool, receivingSampler); + run = prepareRun(course, first, after, pool, receivingSampler, prepared.inlet); prepared.runs[first] = run; } List exposed = run.exposed(); @@ -210,26 +209,6 @@ public final class SurfaceFootprintCompiler { + settings.surface().banks().maximumBlendWidth() + settings.surface().shoreWidth() + 2D; } - private static int[] stationOffsets(List segments) { - int[] offsets = new int[segments.size()]; - int station = 0; - HydrologyPoint previous = null; - for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) { - List points = segments.get(segmentIndex).centerline(); - for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) { - HydrologyPoint point = points.get(pointIndex); - if (previous != null) { - station += Math.max(Math.abs(point.x() - previous.x()), Math.abs(point.z() - previous.z())); - } - if (pointIndex == 0) { - offsets[segmentIndex] = station; - } - previous = point; - } - } - return offsets; - } - private ChannelProfile acceptedProfile(Stations stations, SurfaceCenterline centerline) { int count = centerline.size(); double[] halfWidth = new double[count]; @@ -406,7 +385,7 @@ public final class SurfaceFootprintCompiler { private final RiverCourse course; private final PreparedRun[] runs; private HydrologyTerrainSampler receivingSampler; - private int[] offsets; + private SurfaceInletRange inlet; private PreparedCourse(SurfaceFootprintCompiler compiler, RiverCourse course) { this.compiler = compiler; diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceInletRange.java b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceInletRange.java new file mode 100644 index 000000000..71c28e166 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/surface/SurfaceInletRange.java @@ -0,0 +1,104 @@ +package art.arcane.iris.generation.hydrology.surface; + +import art.arcane.iris.generation.hydrology.HydraulicSegment; +import art.arcane.iris.generation.hydrology.HydrologyFeatureType; +import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.generation.hydrology.HydrologyPoint; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; +import art.arcane.iris.generation.hydrology.RiverCourse; +import art.arcane.iris.generation.hydrology.RiverCourseType; +import art.arcane.iris.generation.hydrology.RiverFootprint; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.util.List; + +public final class SurfaceInletRange { + private final int[] offsets; + private final int firstStation; + private final Long2ObjectOpenHashMap fallingStations; + + private SurfaceInletRange(int[] offsets, int firstStation, + Long2ObjectOpenHashMap fallingStations) { + this.offsets = offsets; + this.firstStation = firstStation; + this.fallingStations = fallingStations; + } + + public static SurfaceInletRange forCourse(HydrologyPlannerSettings settings, RiverCourse course, + HydrologyTerrainSampler receivingSampler) { + List segments = course.segments(); + int[] offsets = offsets(segments); + Long2ObjectOpenHashMap fallingStations = new Long2ObjectOpenHashMap<>(); + if (course.type() != RiverCourseType.SURFACE || segments.isEmpty() + || segments.getLast().type() != HydrologyFeatureType.MOUTH) { + return new SurfaceInletRange(offsets, Integer.MAX_VALUE, fallingStations); + } + int exposed = exposedStations(settings.seaLevel(), segments, offsets, receivingSampler); + HydrologyPlannerSettings.Inlet inlet = settings.surface().banks().inlet(); + int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposed * inlet.courseFraction())); + int firstStation = Math.max(0, exposed - reach - reach / 2); + for (int index = 0; index < segments.size(); index++) { + HydraulicSegment segment = segments.get(index); + if (!segment.type().isSurface() || !segment.fallingFluid()) { + continue; + } + SurfaceCenterline centerline = SurfaceCenterline.densify(segment.centerline()); + int first = Math.max(0, firstStation - offsets[index]); + if (first >= centerline.size()) { + continue; + } + LongOpenHashSet positions = new LongOpenHashSet(centerline.size() - first); + for (int station = first; station < centerline.size(); station++) { + positions.add(RiverFootprint.pack(centerline.x()[station], centerline.z()[station])); + } + fallingStations.put(segment.id(), positions); + } + return new SurfaceInletRange(offsets, firstStation, fallingStations); + } + + public int offset(int segmentIndex) { + return offsets[segmentIndex]; + } + + public int firstStation(int segmentIndex) { + return Math.max(0, firstStation - offsets[segmentIndex]); + } + + public boolean allowsFallingStation(long segmentId, int x, int z) { + LongOpenHashSet positions = fallingStations.get(segmentId); + return positions != null && positions.contains(RiverFootprint.pack(x, z)); + } + + private static int[] offsets(List segments) { + int[] offsets = new int[segments.size()]; + int station = 0; + HydrologyPoint previous = null; + for (int index = 0; index < segments.size(); index++) { + List points = segments.get(index).centerline(); + for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) { + HydrologyPoint point = points.get(pointIndex); + if (previous != null) { + station += Math.max(Math.abs(point.x() - previous.x()), Math.abs(point.z() - previous.z())); + } + if (pointIndex == 0) { + offsets[index] = station; + } + previous = point; + } + } + return offsets; + } + + private static int exposedStations(int seaLevel, List segments, int[] offsets, + HydrologyTerrainSampler receivingSampler) { + int mouth = segments.size() - 1; + SurfaceCenterline centerline = SurfaceCenterline.densify(segments.get(mouth).centerline()); + for (int station = centerline.size() - 1; station >= 0; station--) { + if (!receivingSampler.receivingWater(centerline.x()[station], centerline.z()[station], seaLevel)) { + return offsets[mouth] + station + 1; + } + } + return offsets[mouth]; + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java index f946b0f46..faa931095 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleObjectComponent.java @@ -121,12 +121,12 @@ public class MantleObjectComponent extends IrisMantleComponent { @Override public int getOutputRadius() { - return hasCollisionRules() ? 0 : getRadius(); + return 0; } @Override public int getInputRadius() { - return hasCollisionRules() ? calculateInputRadius(getRadius(), true) : 0; + return calculateInputRadius(getRadius(), hasCollisionRules()); } @Override @@ -140,10 +140,6 @@ public class MantleObjectComponent extends IrisMantleComponent { @Override public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) { - if (!hasCollisionRules()) { - generateOrigin(writer, x, z, context); - return; - } ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, x, z); replaySourceChunks( x, @@ -169,12 +165,14 @@ public class MantleObjectComponent extends IrisMantleComponent { sourceChunkX, sourceChunkZ ); - replaySourcePredecessors( - sourceChunkX, - sourceChunkZ, - getRadius(), - (predecessorX, predecessorZ) -> generateOrigin(scratch, predecessorX, predecessorZ, context) - ); + if (hasCollisionRules()) { + replaySourcePredecessors( + sourceChunkX, + sourceChunkZ, + getRadius(), + (predecessorX, predecessorZ) -> generateOrigin(scratch, predecessorX, predecessorZ, context) + ); + } int checkpoint = scratch.mutationCheckpoint(); generateOrigin(scratch, sourceChunkX, sourceChunkZ, context); return scratch.sourcePlanSince(checkpoint); diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java index 432aebdcb..cb1bd8f8b 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/ObjectDestinationTransaction.java @@ -54,7 +54,9 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer { void apply(ObjectSourcePlan plan) { for (Mutation mutation : plan.mutations()) { - mutation.apply(this); + if (isDestination(mutation.x(), mutation.z())) { + mutation.apply(this); + } } } diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java index 619481bc4..67ec3d41a 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/Engine.java @@ -38,7 +38,6 @@ import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.pack.value.IrisColor; import art.arcane.iris.generation.terrain.IrisDimension; -import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry; import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; import art.arcane.iris.structure.object.IrisObject; import art.arcane.iris.structure.object.IrisObjectPlacement; @@ -424,15 +423,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, IrisDimensionCarvingResolver.State state ) { int worldY = y + getWorld().minHeight(); - IrisDimensionCarvingEntry rootCarvingEntry = IrisDimensionCarvingResolver.resolveRootEntry(this, worldY, state); - if (rootCarvingEntry != null) { - IrisDimensionCarvingEntry resolvedCarvingEntry = IrisDimensionCarvingResolver.resolveFromRoot(this, rootCarvingEntry, x, z, state); - IrisBiome resolvedCarvingBiome = IrisDimensionCarvingResolver.resolveEntryBiome(this, resolvedCarvingEntry, state); - if (resolvedCarvingBiome != null) { - return resolvedCarvingBiome; - } - } - return null; + return IrisDimensionCarvingResolver.resolveBiome(this, x, worldY, z, state); } private IrisBiome resolveDepthCaveBiome( diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java index 6aef135b0..e7f1556d8 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineHotloader.java @@ -79,6 +79,8 @@ final class EngineHotloader { previousGeneration.mantleStorageDirectory(), previousGeneration.runtimeKernel(), previousGeneration.transitionPlan()); + PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture( + assembly.target, assembly.runtimeKernel, assembly.transitionPlan, engine.isStudio()); engine.runtimeAssembly.set(assembly); EngineRuntime next; try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { @@ -86,6 +88,7 @@ final class EngineHotloader { assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine); assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine); BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes(); + EngineRuntimeBuilder.enablePreparedCache(assembly, cacheIdentity, engine.isStudio()); GenerationRuntime nextGeneration = previousGeneration.withComplex( assembly.cacheId, assembly.complex, diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EngineRuntimeBuilder.java b/core/src/main/java/art/arcane/iris/generation/runtime/EngineRuntimeBuilder.java index 21cce26ae..71ec16139 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EngineRuntimeBuilder.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EngineRuntimeBuilder.java @@ -57,13 +57,14 @@ final class EngineRuntimeBuilder { this.engine = engine; } - EngineRuntime buildRuntime() { + EngineRuntime buildRuntime(PreparedHydrologyCacheIdentity cacheIdentity) { return buildRuntime( engine.getTarget(), engine.getInitialMantleStorageDirectory(), engine.getInitialKernelVersion(), engine.getInitialTransitionPlan(), - null); + null, + cacheIdentity); } EngineRuntime buildRuntime(EngineTarget runtimeTarget) { @@ -73,7 +74,9 @@ final class EngineRuntimeBuilder { active.mantleStorageDirectory(), active.kernelVersion(), active.transitionPlan(), - active.mantle()); + active.mantle(), + PreparedHydrologyCacheIdentity.capture(runtimeTarget, selectRuntimeKernel(active.kernelVersion()), + active.transitionPlan(), engine.isStudio())); } private EngineRuntime buildRuntime( @@ -81,7 +84,8 @@ final class EngineRuntimeBuilder { Path mantleStorageDirectory, GenerationKernelRegistry.Version kernelVersion, TransitionGenerationPlan transitionPlan, - EngineMantle transferredMantle + EngineMantle transferredMantle, + PreparedHydrologyCacheIdentity cacheIdentity ) { RuntimeAssembly assembly = new RuntimeAssembly( RuntimeAssembly.nextRuntimeId(), @@ -135,6 +139,7 @@ final class EngineRuntimeBuilder { } IrisLogging.debug("[IrisEngine timing] IrisWorldManager=" + (M.ms() - started) + "ms"); BiomeMaxes biomeMaxes = computeBiomeMaxes(); + enablePreparedCache(assembly, cacheIdentity, engine.isStudio()); return assembly.freezeRuntime(biomeMaxes); } catch (Throwable e) { Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e); @@ -167,6 +172,8 @@ final class EngineRuntimeBuilder { mantleStorageDirectory, selectRuntimeKernel(kernelVersion), transitionPlan); + PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture( + runtimeTarget, assembly.runtimeKernel, transitionPlan, engine.isStudio()); engine.runtimeAssembly.set(assembly); try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) { IrisLogging.debug("Setup Detached Generation Runtime " + assembly.cacheId); @@ -184,7 +191,9 @@ final class EngineRuntimeBuilder { assembly.mode, assembly.dimensionStackContext ); - return assembly.freezeGeneration(computeBiomeMaxes()); + BiomeMaxes biomeMaxes = computeBiomeMaxes(); + enablePreparedCache(assembly, cacheIdentity, engine.isStudio()); + return assembly.freezeGeneration(biomeMaxes); } catch (Throwable e) { Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e); if (cleanupFailure != e) { @@ -196,6 +205,12 @@ final class EngineRuntimeBuilder { } } + static void enablePreparedCache(RuntimeAssembly assembly, PreparedHydrologyCacheIdentity identity, boolean studio) { + if (identity != null) { + identity.enableIfUnchanged(assembly.target, assembly.runtimeKernel, assembly.transitionPlan, assembly.complex, studio); + } + } + void publishRuntime(EngineRuntime next, EngineRuntime previous) { Throwable retirementFailure = engine.shutdownSequence.closeRuntime( previous, diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index eafcc077d..98aaf66a4 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -560,7 +560,7 @@ public class IrisComplex implements DataProvider { hydrologyNoiseCaches = List.of(); } - void enableStudioHydrologyCache(String runtimeIdentity, Path persistentRoot) { + void enablePreparedHydrologyCache(String runtimeIdentity, Path persistentRoot) { if (hydrologyRuntime != null) { hydrologyRuntime.enableSharedCache(runtimeIdentity, persistentRoot); } @@ -1934,6 +1934,7 @@ public class IrisComplex implements DataProvider { private Engine engine; private IrisGenerator[] generators; private IdentityHashMap cachedBounds; + private boolean integerCoordinates; private boolean inUse; private boolean isInUse() { @@ -1945,6 +1946,7 @@ public class IrisComplex implements DataProvider { this.engine = engine; this.generators = generators; this.cachedBounds = cachedBounds; + this.integerCoordinates = complex.baseBiomeStream instanceof CachedStream2D; this.inUse = true; if (sampleSource.get() != complex.baseBiomeStream) { sampleCache.clear(); @@ -1967,10 +1969,12 @@ public class IrisComplex implements DataProvider { @Override public NoiseBounds noise(double xx, double zz) { try { - IrisBiome bx = sampleCache.get(xx, zz); + double sampleX = integerCoordinates ? (int) xx : xx; + double sampleZ = integerCoordinates ? (int) zz : zz; + IrisBiome bx = sampleCache.get(sampleX, sampleZ); if (bx == null) { bx = complex.baseBiomeStream.get(xx, zz); - sampleCache.put(xx, zz, bx); + sampleCache.put(sampleX, sampleZ, bx); } GeneratorBounds bounds = complex.resolveGeneratorBounds(engine, generators, bx, cachedBounds, localBounds); diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java index 0ca8c6263..189bbc5a0 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisEngine.java @@ -35,7 +35,6 @@ import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver; import art.arcane.iris.generation.terrain.IrisRegion; -import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.spi.IrisLogging; import com.google.common.util.concurrent.AtomicDouble; import art.arcane.iris.spi.IrisPlatforms; @@ -303,7 +302,8 @@ public class IrisEngine implements Engine { getData().registerEngine(this); _t0 = M.ms(); long phaseStartedAt = System.nanoTime(); - String studioCacheIdentity = currentStudioCacheIdentity(); + PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture( + getTarget(), EngineRuntimeBuilder.selectRuntimeKernel(initialKernelVersion), initialTransitionPlan, studio); getData().loadPrefetch(this); IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms"); diagnostics.logStudioInitializationPhase("load_prefetch", phaseStartedAt, false); @@ -315,8 +315,7 @@ public class IrisEngine implements Engine { IrisLogging.notice("Engine init: " + requiredTarget.getWorld().name() + "/" + requiredTarget.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed()); _t0 = M.ms(); phaseStartedAt = System.nanoTime(); - EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(); - enableStableStudioCache(initialRuntime, studioCacheIdentity); + EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(cacheIdentity); runtimeBuilder.publishRuntime(initialRuntime, null); IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); diagnostics.logPackCompatSummary(); @@ -359,47 +358,6 @@ public class IrisEngine implements Engine { } } - private String currentStudioCacheIdentity() { - if (initializationMode != InitializationMode.STUDIO || !IrisPlatforms.isBound()) { - return ""; - } - try { - String contentFingerprint = PackValidationCache.contentFingerprint( - IrisPlatforms.get().packsFolderNoCreate()); - String contextFingerprint = PackValidationCache.contextFingerprint(); - if (contentFingerprint.isBlank() || contextFingerprint.isBlank()) { - return ""; - } - return contentFingerprint + contextFingerprint; - } catch (RuntimeException failure) { - IrisLogging.warn("Studio runtime cache fingerprint failed: " + failure.getMessage()); - return ""; - } - } - - private void enableStableStudioCache(EngineRuntime runtime, String initialIdentity) { - if (initialIdentity.isBlank()) { - return; - } - String finalIdentity = currentStudioCacheIdentity(); - if (!initialIdentity.equals(finalIdentity)) { - IrisLogging.warn("Studio packs changed during runtime compilation; shared generation caches are disabled."); - return; - } - runtime.generation().complex().enableStudioHydrologyCache(initialIdentity, studioHydrologyCacheRoot()); - IrisLogging.debug("Enabled shared Studio hydrology cache identity=" - + initialIdentity.substring(0, Math.min(12, initialIdentity.length()))); - } - - private Path studioHydrologyCacheRoot() { - Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath().toAbsolutePath().normalize(); - Path packRoot = packsRoot.resolve(getDimension().getLoadKey()).normalize(); - if (!packRoot.startsWith(packsRoot)) { - throw new IllegalStateException("Studio dimension key resolves outside the Iris packs folder."); - } - return packRoot.resolve(".iris").resolve("studio-hydrology"); - } - public void startEntryHydrology(int blockX, int blockZ) { GenerationRuntimeBinding binding = getActiveGenerationRuntimeBinding(); if (!backgroundTasks.scheduleTrackedTask(() -> prepareEntryHydrology(binding, blockX, blockZ))) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentity.java b/core/src/main/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentity.java new file mode 100644 index 000000000..eafcaa673 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentity.java @@ -0,0 +1,118 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.pack.PackValidationCache; +import art.arcane.iris.pack.PackDirectoryResolver; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.world.history.GenerationKernelRegistry; +import art.arcane.iris.world.history.GenerationPackFingerprint; +import art.arcane.iris.world.history.TransitionGenerationPlan; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +record PreparedHydrologyCacheIdentity(String fingerprint) { + static PreparedHydrologyCacheIdentity capture( + EngineTarget target, + GenerationKernelRegistry.RuntimeKernel kernel, + TransitionGenerationPlan transition, + boolean studio + ) { + if (!IrisPlatforms.isBound()) { + return null; + } + try { + String context = PackValidationCache.contextFingerprint(); + if (context.isBlank()) { + return null; + } + String content = GenerationPackFingerprint.compute( + target.getData().getDataFolder().toPath(), GenerationPackFingerprint.CURRENT_VERSION); + String dependencies = target.getData().hasGenerationRegistryContract() ? "closed" + : dependencyFingerprint(IrisPlatforms.get().packsFolderNoCreate()); + if (dependencies.isBlank()) { + return null; + } + int buffetSize = studio && "bukkit".equals(IrisPlatforms.get().platformName()) + ? target.getDimension().getStudioMode().biomeSizeChunks() : 0; + return fromInputs(content, dependencies, context, kernel, transition == null ? null : transition.specification(), + target.getWorld().minHeight(), target.getWorld().maxHeight(), buffetSize); + } catch (IOException | RuntimeException failure) { + IrisLogging.reportError("Prepared hydrology cache identity could not be captured.", failure); + return null; + } + } + + static PreparedHydrologyCacheIdentity fromInputs( + String packContent, + String dependencies, + String platformContext, + GenerationKernelRegistry.RuntimeKernel kernel, + TransitionGenerationPlan.Specification transition, + int minimumHeight, + int maximumHeight, + int biomeBuffetSize + ) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + update(digest, packContent); + update(digest, dependencies); + update(digest, platformContext); + update(digest, kernel.version().toString()); + update(digest, kernel.implementationFingerprint()); + update(digest, Integer.toString(minimumHeight)); + update(digest, Integer.toString(maximumHeight)); + update(digest, Integer.toString(biomeBuffetSize)); + update(digest, Boolean.toString(transition != null)); + if (transition != null) { + update(digest, Long.toString(transition.activationId())); + update(digest, transition.oldEpochId()); + update(digest, transition.newEpochId()); + update(digest, Integer.toString(transition.algorithmVersion())); + update(digest, Integer.toString(transition.widthBlocks())); + update(digest, transition.boundaryIdentity()); + update(digest, transition.terrainSignatureIdentity()); + } + return new PreparedHydrologyCacheIdentity(HexFormat.of().formatHex(digest.digest())); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable.", failure); + } + } + + void enableIfUnchanged(EngineTarget target, GenerationKernelRegistry.RuntimeKernel kernel, + TransitionGenerationPlan transition, IrisComplex complex, boolean studio) { + if (!equals(capture(target, kernel, transition, studio))) { + IrisLogging.warn("Generation inputs changed during compilation; prepared hydrology caching is disabled."); + return; + } + complex.enablePreparedHydrologyCache(fingerprint, + IrisPlatforms.get().dataFolderNoCreate("cache", "hydrology-plans").toPath()); + } + + private static void update(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update((byte) (bytes.length >>> 24)); + digest.update((byte) (bytes.length >>> 16)); + digest.update((byte) (bytes.length >>> 8)); + digest.update((byte) bytes.length); + digest.update(bytes); + } + + private static String dependencyFingerprint(File packsRoot) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (File pack : PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot)) { + update(digest, pack.getName()); + update(digest, GenerationPackFingerprint.compute( + pack.toPath().toRealPath(), GenerationPackFingerprint.CURRENT_VERSION)); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable.", failure); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/HydrologyBankTerrainRuntime.java b/core/src/main/java/art/arcane/iris/generation/terrain/HydrologyBankTerrainRuntime.java index 492920b5b..71ef0cb3a 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/HydrologyBankTerrainRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/HydrologyBankTerrainRuntime.java @@ -151,7 +151,8 @@ public final class HydrologyBankTerrainRuntime { return new BankColumn(flat, flat); } if (base >= natural.topY()) { - return new BankColumn(natural, natural); + Terrain3DColumn filled = Terrain3DColumn.unshaped(base, options.height()); + return new BankColumn(filled, filled); } double rise = Math.min(amplitude, natural.topY() - base); IntArrayList boundaries = new IntArrayList(natural.spanCount() * 2 + 8); diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolver.java b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolver.java index d681c68dd..282b5bd91 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolver.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolver.java @@ -28,6 +28,17 @@ public final class IrisDimensionCarvingResolver { } + public static IrisBiome resolveBiome(Engine engine, int worldX, int worldY, int worldZ, State state) { + State resolvedState = state == null ? threadState() : state; + IrisData data = resolvedState.bind(engine); + IrisDimensionCarvingEntry root = resolveBoundRootEntry(engine, worldY, data, resolvedState); + if (root == null) { + return null; + } + IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldZ, data, resolvedState); + return resolved == null ? null : resolveBoundEntryBiome(data, resolved, resolvedState); + } + public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY) { return resolveRootEntry(engine, worldY, threadState()); } @@ -35,6 +46,36 @@ public final class IrisDimensionCarvingResolver { public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY, State state) { State resolvedState = state == null ? threadState() : state; IrisData data = resolvedState.bind(engine); + return resolveBoundRootEntry(engine, worldY, data, resolvedState); + } + + public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) { + return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState()); + } + + public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) { + State resolvedState = state == null ? threadState() : state; + IrisData data = resolvedState.bind(engine); + return resolveBoundFromRoot(engine, rootEntry, worldX, worldZ, data, resolvedState); + } + + public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry) { + return resolveEntryBiome(engine, entry, null); + } + + public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry, State state) { + if (entry == null) { + return null; + } + + if (state == null) { + return entry.getRealBiome(engine.getData()); + } + + return resolveBoundEntryBiome(state.bind(engine), entry, state); + } + + private static IrisDimensionCarvingEntry resolveBoundRootEntry(Engine engine, int worldY, IrisData data, State resolvedState) { if (resolvedState.rootEntriesByWorldY.containsKey(worldY)) { return resolvedState.rootEntriesByWorldY.get(worldY); } @@ -59,13 +100,7 @@ public final class IrisDimensionCarvingResolver { return resolved; } - public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) { - return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState()); - } - - public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) { - State resolvedState = state == null ? threadState() : state; - IrisData data = resolvedState.bind(engine); + private static IrisDimensionCarvingEntry resolveBoundFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, IrisData data, State resolvedState) { if (rootEntry == null) { return null; } @@ -100,22 +135,6 @@ public final class IrisDimensionCarvingResolver { return current; } - public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry) { - return resolveEntryBiome(engine, entry, null); - } - - public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry, State state) { - if (entry == null) { - return null; - } - - if (state == null) { - return entry.getRealBiome(engine.getData()); - } - - return resolveBoundEntryBiome(state.bind(engine), entry, state); - } - private static IrisBiome resolveBoundEntryBiome(IrisData data, IrisDimensionCarvingEntry entry, State state) { if (state.biomeCache.containsKey(entry)) { return state.biomeCache.get(entry); diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java index 8e6d2d39f..8f61cc77c 100644 --- a/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java +++ b/core/src/main/java/art/arcane/iris/generation/terrain/Terrain3DRuntime.java @@ -92,9 +92,10 @@ public final class Terrain3DRuntime { int gridX = Math.floorDiv(x, STEP) * STEP; int gridZ = Math.floorDiv(z, STEP) * STEP; Anchor northWest = anchor(gridX, gridZ); - Anchor northEast = anchor(gridX + STEP, gridZ); - Anchor southWest = anchor(gridX, gridZ + STEP); - Anchor southEast = anchor(gridX + STEP, gridZ + STEP); + Anchor northEast = x == gridX ? northWest : anchor(gridX + STEP, gridZ); + Anchor southWest = z == gridZ ? northWest : anchor(gridX, gridZ + STEP); + Anchor southEast = x == gridX ? southWest + : z == gridZ ? northEast : anchor(gridX + STEP, gridZ + STEP); double dx = (x - gridX) / (double) STEP; double dz = (z - gridZ) / (double) STEP; double amplitude = interpolate(northWest.amplitude(), northEast.amplitude(), diff --git a/core/src/main/java/art/arcane/iris/pack/PackValidationRegistry.java b/core/src/main/java/art/arcane/iris/pack/PackValidationRegistry.java index d5e5bcbeb..b22cac7c1 100644 --- a/core/src/main/java/art/arcane/iris/pack/PackValidationRegistry.java +++ b/core/src/main/java/art/arcane/iris/pack/PackValidationRegistry.java @@ -49,14 +49,25 @@ public final class PackValidationRegistry { if (packRoot == null || result == null) { return; } - publish(normalize(packRoot), new RootValidation(result, "")); + publish(normalize(packRoot), new RootValidation(result, "", "")); } - public static void publish(Path packRoot, PackValidationResult result, String contentFingerprint) { + public static void publish( + Path packRoot, + PackValidationResult result, + String contentFingerprint, + String validatedContextFingerprint + ) { if (packRoot == null || result == null || contentFingerprint == null || contentFingerprint.isBlank()) { return; } - publish(normalize(packRoot), new RootValidation(result, contentFingerprint)); + if (validatedContextFingerprint == null + || !validatedContextFingerprint.equals(PackValidationCache.contextFingerprint())) { + publish(packRoot, result); + return; + } + publish(normalize(packRoot), new RootValidation( + result, contentFingerprint, validatedContextFingerprint)); } public static PackValidationResult publishMatchingCopy( @@ -118,7 +129,7 @@ public final class PackValidationRegistry { return new RootState( current.generation(), false, - new RootValidation(result, "")); + new RootValidation(result, "", "")); }); return published.get(); } @@ -140,6 +151,14 @@ public final class PackValidationRegistry { : state.validation().result(); } + public static PackValidationResult getMatching(Path packRoot, String contentFingerprint) { + if (packRoot == null || contentFingerprint == null || contentFingerprint.isBlank()) { + return null; + } + RootValidation validation = matchingValidation(packRoot, contentFingerprint); + return validation == null ? null : validation.result(); + } + public static PackValidationResult requireLoadable(String packName) { if (packName == null || packName.isBlank()) { throw new IllegalArgumentException("Pack name is required for validation"); @@ -250,10 +269,11 @@ public final class PackValidationRegistry { if (sourceState == null || sourceState.mutating() || sourceState.validation() == null - || !copiedContentFingerprint.equals(sourceState.validation().contentFingerprint())) { + || !copiedContentFingerprint.equals(sourceState.validation().contentFingerprint()) + || !sourceState.validation().contextFingerprint().equals(PackValidationCache.contextFingerprint())) { return null; } - return sourceState.validation(); + return ROOT_STATES.get(normalizedSource) == sourceState ? sourceState.validation() : null; } private static long nextGeneration(RootState current) { @@ -297,6 +317,7 @@ public final class PackValidationRegistry { requireOpen(); pendingValidation = new RootValidation( Objects.requireNonNull(result, "Pack validation result"), + "", ""); } @@ -345,7 +366,11 @@ public final class PackValidationRegistry { } } - private record RootValidation(PackValidationResult result, String contentFingerprint) { + private record RootValidation( + PackValidationResult result, + String contentFingerprint, + String contextFingerprint + ) { } private record RootState(long generation, boolean mutating, RootValidation validation) { diff --git a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java index 6f72897f3..614c765d5 100644 --- a/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java +++ b/core/src/main/java/art/arcane/iris/pack/loading/IrisData.java @@ -300,6 +300,10 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } } + public boolean hasGenerationRegistryContract() { + return generationRegistryContract != null; + } + public synchronized void bindGenerationRegistryContract(GenerationRegistryContract contract) { GenerationRegistryContract required = Objects.requireNonNull(contract, "contract"); if (generationRegistryContract != null && !generationRegistryContract.equals(required)) { diff --git a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java index b4e3b7eb5..540021909 100644 --- a/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/platform/generation/BukkitChunkGenerator.java @@ -716,6 +716,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } catch (IOException failure) { throw new IllegalStateException("Unable to load the active Iris generation transition.", failure); } + engineTarget.getData().bindGenerationRegistryContract(epoch.registryContract()); IrisEngine createdEngine = new IrisEngine( engineTarget, mode, @@ -1319,7 +1320,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun openGenerationHistoryCoordinateScope(engine, x << 4, z << 4); GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage"); IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { - engine.generate(x << 4, z << 4, blocks, biomes, engine.shouldGenerateMulticore()); + engine.generate(x << 4, z << 4, blocks, biomes, + initialEntryPending || studioEntryBootstrapActive.get() || engine.shouldGenerateMulticore()); blocks.apply(); if (historyScope != null) { historyScope.claimGeneratedSemantics(); diff --git a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java index 2efb96ee8..cd43d7c16 100644 --- a/core/src/main/java/art/arcane/iris/studio/StudioSVC.java +++ b/core/src/main/java/art/arcane/iris/studio/StudioSVC.java @@ -362,7 +362,7 @@ public class StudioSVC implements IrisService { packRoot, GenerationPackFingerprint.CURRENT_VERSION ); - validatePublishedPack(packRoot); + validateGenerationCandidate(packRoot, fingerprint); IrisData data = IrisData.openDatapackCompiler(packRoot.toFile()); try { IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false); @@ -566,6 +566,17 @@ public class StudioSVC implements IrisService { } } + static PackValidationResult validateGenerationCandidate(Path packRoot, String contentFingerprint) { + PackValidationResult validation = PackValidationRegistry.getMatching(packRoot, contentFingerprint); + if (validation == null) { + return validatePublishedPack(packRoot); + } + if (!validation.isLoadable()) { + throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); + } + return validation; + } + static PackValidationResult validatePublishedPack( Path packRoot, Path validatedSource, diff --git a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java index f3c8b7247..7c9fd87d4 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/AsyncPregenMethod.java @@ -29,6 +29,7 @@ import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.platform.bukkit.nms.INMS; import art.arcane.iris.platform.bukkit.nms.INMSBinding; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.hydrology.HydrologyTileCache; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -69,6 +70,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { private static final long MANTLE_CLEANUP_DRAIN_SECONDS = 60L; private static final long CHUNK_FLUSH_DRAIN_SECONDS = 30L; private static final long ADMISSION_WAIT_BOUND_MS = 500L; + private static final int HYDROLOGY_PREFETCH_HALO_BLOCKS = 512; private final World world; private final IrisRuntimeSchedulerMode runtimeSchedulerMode; private final IrisPaperLikeBackendMode paperLikeBackendMode; @@ -117,6 +119,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean(); private volatile Engine metricsEngine; private volatile Mantle cachedMantle; + private volatile HydrologyTileCache.PregenerationScope hydrologyPrefetchScope; private final PregenMantleBackpressure backpressure; public AsyncPregenMethod(World world, int unusedThreads) { @@ -298,7 +301,18 @@ public class AsyncPregenMethod implements PregeneratorMethod { if (engine == null || engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) { return; } - engine.getComplex().getHydrologyRuntime().preparePregeneration(centerBlockX, centerBlockZ); + HydrologyTileCache.PregenerationArea area = new HydrologyTileCache.PregenerationArea( + centerBlockX, centerBlockZ, + (long) boundsMinRegionX * 512L - HYDROLOGY_PREFETCH_HALO_BLOCKS, + (long) boundsMinRegionZ * 512L - HYDROLOGY_PREFETCH_HALO_BLOCKS, + ((long) boundsMaxRegionX + 1L) * 512L - 1L + HYDROLOGY_PREFETCH_HALO_BLOCKS, + ((long) boundsMaxRegionZ + 1L) * 512L - 1L + HYDROLOGY_PREFETCH_HALO_BLOCKS); + HydrologyTileCache.PregenerationScope scope = engine.getComplex().getHydrologyRuntime().preparePregeneration(area); + hydrologyPrefetchScope = scope; + if (closing.get()) { + scope.close(); + hydrologyPrefetchScope = null; + } } private boolean inBounds(int rx, int rz) { @@ -827,6 +841,11 @@ public class AsyncPregenMethod implements PregeneratorMethod { releaseWorkerThreadBoost(); } } finally { + HydrologyTileCache.PregenerationScope scope = hydrologyPrefetchScope; + if (scope != null) { + scope.close(); + hydrologyPrefetchScope = null; + } if (interrupted) { Thread.currentThread().interrupt(); } diff --git a/core/src/main/java/art/arcane/iris/world/pregen/PregenTask.java b/core/src/main/java/art/arcane/iris/world/pregen/PregenTask.java index 27b773b04..4ff67e8a5 100644 --- a/core/src/main/java/art/arcane/iris/world/pregen/PregenTask.java +++ b/core/src/main/java/art/arcane/iris/world/pregen/PregenTask.java @@ -196,9 +196,15 @@ public class PregenTask { public void iterateRegions(Spiraled s) { Bound bound = bounds.region(); - new Spiraler(bound.sizeX, bound.sizeZ, ((x, z) -> { - if (bound.check(x, z)) s.on(x, z); - })).setOffset(PowerOfTwoCoordinates.blockToRegionFloor(center.getX()), PowerOfTwoCoordinates.blockToRegionFloor(center.getZ())).drain(); + int centerRegionX = PowerOfTwoCoordinates.blockToRegionFloor(center.getX()); + int centerRegionZ = PowerOfTwoCoordinates.blockToRegionFloor(center.getZ()); + int width = Math.max(centerRegionX - bound.minX(), bound.maxX() - centerRegionX) * 2 + 1; + int depth = Math.max(centerRegionZ - bound.minZ(), bound.maxZ() - centerRegionZ) * 2 + 1; + new Spiraler(width, depth, ((x, z) -> { + if (bound.check(x, z)) { + s.on(x, z); + } + })).setOffset(centerRegionX, centerRegionZ).drain(); } public void iterateChunks(int rX, int rZ, Spiraled s) { @@ -291,10 +297,10 @@ public class PregenTask { PowerOfTwoCoordinates.ceilDivPow2(maxZ, PowerOfTwoCoordinates.CHUNK_BITS) ); region = new Bound( - PowerOfTwoCoordinates.blockToRegionFloor(minX), - PowerOfTwoCoordinates.blockToRegionFloor(minZ), - PowerOfTwoCoordinates.ceilDivPow2(maxX, PowerOfTwoCoordinates.REGION_BITS), - PowerOfTwoCoordinates.ceilDivPow2(maxZ, PowerOfTwoCoordinates.REGION_BITS) + PowerOfTwoCoordinates.chunkToRegion(chunk.minX()), + PowerOfTwoCoordinates.chunkToRegion(chunk.minZ()), + PowerOfTwoCoordinates.chunkToRegion(chunk.maxX()), + PowerOfTwoCoordinates.chunkToRegion(chunk.maxZ()) ); requireSaneSpan(region); } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudgetTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudgetTest.java index 3c1d4df21..2aa1bd340 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudgetTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCacheBudgetTest.java @@ -24,7 +24,7 @@ public class HydrologyCacheBudgetTest { HydrologyCacheBudget budget = HydrologyCacheBudget.forHeap(heap, regionalEnabled); long total = budget.tileBytes() + budget.columnBytes() + budget.diagnosticBytes() + budget.routingBytes() + budget.ownerBytes() + budget.edgeBytes() + budget.regionalDraftBytes() - + budget.regionalCoarseBytes() + budget.regionalTerrainBytes() + budget.regionalReachBytes(); + + budget.regionalCoarseBytes() + budget.regionalTerrainBytes(); assertTrue(total <= Math.clamp(heap / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE)); assertTrue(total >= 8L * MEBIBYTE); assertEquals(budget.tileBytes() / 6L, budget.columnBytes()); @@ -40,7 +40,7 @@ public class HydrologyCacheBudgetTest { assertEquals(24L * MEBIBYTE, localOnly.routingBytes()); assertEquals(8L * MEBIBYTE, localOnly.edgeBytes()); assertEquals(0L, localOnly.regionalDraftBytes() + localOnly.regionalCoarseBytes() - + localOnly.regionalTerrainBytes() + localOnly.regionalReachBytes()); + + localOnly.regionalTerrainBytes()); assertThrows(IllegalArgumentException.class, () -> HydrologyCacheBudget.forHeap(0L, false)); } @@ -112,7 +112,7 @@ public class HydrologyCacheBudgetTest { HydrologyRegionalGraph.Label second = new HydrologyRegionalGraph.Label( new HydrologyRegionalGraph.LabelState(3, 2, 63, 1D, 1D, 0), downstream); HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree( - new HydrologyRegionalGraph.Label[]{first, second, null}, new int[4], List.of()); + new HydrologyRegionalGraph.Label[]{first, second, null}, new int[4], List.of(), new int[4]); assertEquals(3, tree.retainedLabelCount()); } } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCoastalFallAdmissionTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCoastalFallAdmissionTest.java new file mode 100644 index 000000000..ecfabc64d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyCoastalFallAdmissionTest.java @@ -0,0 +1,66 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.OptionalLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class HydrologyCoastalFallAdmissionTest { + private static final HydrologyPlannerSettings SETTINGS = HydrologyPlannerSettings.defaults(); + private static final HydrologyTerrainSampler TERRAIN = (x, z) -> x >= 80 + ? HydrologyTerrainSample.ocean(54, "ocean") : HydrologyTerrainSample.openLand(95, 0D, "land"); + + @Test + public void aConnectedMouthAdmitsTheCoastalFallAtTheNaturalSeaSurface() { + RiverCourse course = course(HydrologyFeatureType.MOUTH, 80); + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN); + ArrayList diagnostics = new ArrayList<>(); + assertTrue(compiler.surfaceFootprint(course).toString(), compiler.surfaceFootprint(course).accepted()); + HydrologyCaveCourseFilter.Result result = filter().filter(List.of(), List.of(), List.of(), List.of(course), + compiler.compileValidation(List.of(course)), diagnostics); + + assertEquals(diagnostics.toString(), List.of(course), result.courses()); + assertEquals(1, result.cavePlans().size()); + assertTrue(result.cavePlans().getFirst().accepted()); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void anInlandTerminalOrDisconnectedMouthCannotOpenTheFallAtSeaLevel() { + for (RiverCourse course : List.of(course(HydrologyFeatureType.SURFACE_POOL, 80), + course(HydrologyFeatureType.MOUTH, 81))) { + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN); + ArrayList diagnostics = new ArrayList<>(); + HydrologyCaveCourseFilter.Result result = filter().filter(List.of(), List.of(), List.of(), List.of(course), + compiler.compileValidation(List.of(course)), diagnostics); + + assertTrue(result.courses().isEmpty()); + assertFalse(diagnostics.isEmpty()); + assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, diagnostics.getFirst().rejection()); + } + } + + private static HydrologyCaveCourseFilter filter() { + return new HydrologyCaveCourseFilter(new HydrologyTerrainCaveVoxelView(TERRAIN, SETTINGS.seaLevel(), -64, 256), + new HydrologyCaveCourseFilter.Options(false, 8192, 8192)); + } + + private static RiverCourse course(HydrologyFeatureType terminal, int receiverX) { + int sea = SETTINGS.seaLevel(); + HydraulicSegment approach = new HydraulicSegment(1L, 4L, HydrologyFeatureType.SURFACE_POOL, + 93, 93, 4, 2, false, false, List.of(new HydrologyPoint(0, 93, 0), new HydrologyPoint(79, 93, 0)), + HydraulicChannelProfile.uniform(4, 2)); + HydraulicSegment fall = new HydraulicSegment(2L, 4L, HydrologyFeatureType.WATERFALL, + 93, sea, 4, 2, true, true, List.of(new HydrologyPoint(79, 93, 0), new HydrologyPoint(80, sea, 0)), + HydraulicChannelProfile.uniform(4, 2)); + HydraulicSegment receiving = new HydraulicSegment(3L, 4L, terminal, sea, sea, 4, 2, false, false, + List.of(new HydrologyPoint(receiverX, sea, 0)), HydraulicChannelProfile.uniform(4, 2)); + return new RiverCourse(4L, RiverCourseType.SURFACE, OptionalLong.of(5L), OptionalLong.of(6L), + "default", 1, List.of(), List.of(approach, fall, receiving)); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGeometryTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGeometryTest.java new file mode 100644 index 000000000..707bcacbc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyDrainageGeometryTest.java @@ -0,0 +1,191 @@ +package art.arcane.iris.generation.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.doReturn; + +public class HydrologyDrainageGeometryTest { + @Test + public void sourceBranchesShareExactlyTheSameDescendingTrunk() { + HydrologyTerrainSampler terrain = (x, z) -> land(120 - x / 8); + Fixture fixture = fixture(terrain); + HydrologyDrainageGeometry geometry = fixture.geometry(); + List first = geometry.edge(0); + List tributary = geometry.edge(3); + List trunk = geometry.edge(1); + assertFalse(first.isEmpty()); + assertFalse(tributary.isEmpty()); + assertFalse(trunk.isEmpty()); + assertEquals(first.getLast(), trunk.getFirst()); + assertEquals(tributary.getLast(), trunk.getFirst()); + for (List edge : List.of(first, tributary, trunk)) { + for (int index = 1; index < edge.size(); index++) { + assertTrue(edge.get(index - 1).y() >= edge.get(index).y()); + } + } + assertEquals(geometry.head(1), trunk.getFirst().y()); + } + + @Test + public void repeatedAndConcurrentSourcesReuseOneGeometryComputation() { + AtomicInteger sampled = new AtomicInteger(); + Fixture fixture = fixture((x, z) -> { + sampled.incrementAndGet(); + return land(120 - x / 8); + }); + ArrayList>> tasks = new ArrayList<>(); + for (int index = 0; index < 16; index++) { + tasks.add(CompletableFuture.supplyAsync(() -> fixture.geometry().edge(0))); + } + List expected = tasks.getFirst().join(); + assertFalse(expected.isEmpty()); + for (CompletableFuture> task : tasks) { + assertSame(expected, task.join()); + } + int samples = sampled.get(); + assertSame(expected, fixture.geometry().edge(0)); + assertEquals(samples, sampled.get()); + } + + @Test + public void aFailedRequiredSampleCanBeRetriedWithoutCachingItsFailure() { + AtomicInteger attempts = new AtomicInteger(); + IllegalStateException unavailable = new IllegalStateException("Terrain unavailable"); + Fixture fixture = fixture((x, z) -> { + if (x == 31 && attempts.incrementAndGet() == 1) { + throw unavailable; + } + return land(120 - x / 8); + }); + assertSame(unavailable, assertThrows(IllegalStateException.class, () -> fixture.geometry().edge(0))); + assertFalse(fixture.geometry().edge(0).isEmpty()); + } + + @Test + public void detailedReliefDoesNotSendTheSharedRiverSearchingForAnotherRoute() { + Fixture flat = fixture((x, z) -> land(120 - x / 8)); + Fixture broken = fixture((x, z) -> land(x > 24 && x < 40 ? 90 : 120 - x / 8)); + assertEquals(flat.geometry().edge(0), broken.geometry().edge(0)); + assertFalse(broken.geometry().edge(0).isEmpty()); + } + + @Test + public void aThinExcludedPolicyBandStillBlocksAnEdge() { + HydrologyTerrainSample blocked = spy(land(116)); + doReturn(false).when(blocked).transitAllowed(); + Fixture fixture = fixture((x, z) -> x == 31 ? blocked : land(120 - x / 8)); + assertTrue(fixture.geometry().edge(0).isEmpty()); + } + + @Test + public void coastalRootKeepsItsLandwardPointAndDescendingHeadAboveTheSea() { + Fixture fixture = fixture((x, z) -> land(120 - x / 8)); + HydrologyPoint root = fixture.geometry().anchor(2); + assertEquals(128, root.x()); + assertEquals(0, root.z()); + assertTrue(root.y() >= fixture.planner().settings.seaLevel()); + assertEquals(root, fixture.geometry().edge(1).getLast()); + } + + @Test + public void everyOutletTrialReusesTheSameGridCostsAndPolicyEdges() { + Fixture fixture = fixture((x, z) -> land(120 - x / 8)); + HydrologySourcePlanner sources = fixture.planner().sourcePlanner; + HydrologyDrainageGraph graph = fixture.grid().drainage(sources); + sources.buildRouting(fixture.grid(), List.of(fixture.outlet()), true); + sources.buildRouting(fixture.grid(), List.of(fixture.outlet()), false); + assertSame(graph, fixture.grid().drainage(sources)); + for (HydrologyGridNode downstream : fixture.grid().nodes()) { + for (int direction = 0; direction < HydrologySourcePlanner.ROUTING_OFFSETS.size(); direction++) { + HydrologyGridOffset offset = HydrologySourcePlanner.ROUTING_OFFSETS.get(direction); + HydrologyGridNode upstream = fixture.grid().nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z()); + if (upstream == null) { + assertEquals(-1, graph.upstream(downstream.index(), direction, false)); + } else { + assertEquals(upstream.index(), graph.upstream(downstream.index(), direction, false)); + assertEquals(sources.routeCost(upstream, downstream, offset, + sources.minimumNeighborHeight(fixture.grid(), downstream)), graph.cost(downstream.index(), direction), 0D); + } + } + } + } + + @Test + public void aCuttableRidgeDoesNotLiftTheSharedUpstreamRiverAboveItsSpring() { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Surface surface = base.surface(); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(), + new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), + surface.maximumWidth(), surface.minimumDepth(), surface.maximumDepth(), 64, + surface.shoreWidth(), surface.banks()), base.hydraulics(), base.underground(), + base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), + base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds()); + HydrologyTerrainSampler terrain = (x, z) -> land(x == 64 ? 154 : 120 - x / 8); + Fixture fixture = fixture(terrain, settings, terrain); + HydrologyDrainageGeometry geometry = fixture.geometry(); + int depth = (surface.minimumDepth() + surface.maximumDepth()) / 2; + + assertEquals(120 - surface.banks().sink() - depth, geometry.head(0)); + assertTrue(geometry.head(0) >= geometry.head(1)); + assertTrue(geometry.head(1) >= geometry.head(2)); + assertTrue(154 - geometry.head(1) + depth <= 64); + assertEquals(geometry.edge(0).getLast(), geometry.edge(1).getFirst()); + } + + @Test + public void aSourceExcludedDepressionDoesNotLowerTheSharedOutletHead() { + HydrologyTerrainSample excluded = spy(land(90)); + doReturn(false).when(excluded).surfaceSourceAllowed(); + HydrologyTerrainSampler terrain = (x, z) -> z == 0 ? land(120 - x / 8) : excluded; + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + Fixture fixture = fixture(terrain, settings, terrain); + HydrologyPlannerSettings.Surface surface = settings.surface(); + int depth = (surface.minimumDepth() + surface.maximumDepth()) / 2; + + assertEquals(104 - surface.banks().sink() - depth, fixture.geometry().head(2)); + assertTrue(fixture.geometry().head(0) >= fixture.geometry().head(1)); + assertTrue(fixture.geometry().head(1) >= fixture.geometry().head(2)); + assertEquals(fixture.geometry().edge(0).getLast(), fixture.geometry().edge(1).getFirst()); + } + + private Fixture fixture(HydrologyTerrainSampler terrain) { + return fixture(terrain, HydrologyPlannerSettings.defaults(), (x, z) -> land(120 - x / 8)); + } + + private Fixture fixture(HydrologyTerrainSampler terrain, HydrologyPlannerSettings settings, + HydrologyTerrainSampler coarseTerrain) { + HydrologyPlanner planner = new HydrologyPlanner(91L, settings, terrain); + ArrayList nodes = new ArrayList<>(); + for (int index = 0; index < 9; index++) { + int x = index % 3 * 64; + int z = index / 3 * 64; + nodes.add(new HydrologyGridNode(index, index % 3, index / 3, x, z, index + 1L, coarseTerrain.sample(x, z))); + } + HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 192, 3, 64, nodes); + OutletCandidate outlet = new OutletCandidate(2, -1, new RiverOutlet(10L, HydrologyFeatureType.MOUTH, + 3L, new HydrologyPoint(128, 104, 0), new HydrologyPoint(129, 63, 0), 63, true)); + int[] parent = {1, 2, -1, 1, 1, 2, 3, 4, 5}; + HydrologyDrainageGeometry geometry = new HydrologyDrainageGeometry(planner, + new HydrologyDrainageGeometry.Basin(grid, parent, new int[9], List.of(outlet))); + return new Fixture(planner, grid, outlet, geometry); + } + + private static HydrologyTerrainSample land(int height) { + return HydrologyTerrainSample.openLand(height, 0D, "land"); + } + + private record Fixture(HydrologyPlanner planner, HydrologySampledGrid grid, + OutletCandidate outlet, HydrologyDrainageGeometry geometry) { + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackDiagnosticsTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackDiagnosticsTest.java index 196fbe0f0..16f57a53b 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackDiagnosticsTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackDiagnosticsTest.java @@ -154,7 +154,7 @@ public class HydrologyFallbackDiagnosticsTest { } private static HydrologyRoutingPlan routing(List outlets) { - return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true); + return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true, null); } private static OutletCandidate outlet(int index) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java index 7bad88960..28c76d145 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFallbackParallelismTest.java @@ -247,7 +247,7 @@ public class HydrologyFallbackParallelismTest { } private static HydrologyRoutingPlan routing(List outlets) { - return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true); + return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true, null); } private static OutletCandidate outlet(int index) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompilerTest.java index d542ab928..8dc530c5a 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyFootprintCompilerTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.generation.hydrology; +import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; import org.junit.Test; import java.util.ArrayDeque; @@ -1508,7 +1509,7 @@ public class HydrologyFootprintCompilerTest { } @Test - public void lowBanksKeepTheirNaturalHeightAndReportUncontainedWater() { + public void lowBanksFillWithinTheirPolicyAndReportRemainingUncontainedWater() { HydraulicSegment channel = new HydraulicSegment( 529L, 528L, @@ -1539,25 +1540,25 @@ public class HydrologyFootprintCompilerTest { 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" - ); + SurfaceRiverPolicy oneBlockFill = new SurfaceRiverPolicy("restricted-bank", + null, null, null, null, null, null, 1); + HydrologyTerrainSampler deepBankTerrain = (int x, int z) -> x == 12 && z == lowBankZ + ? HydrologyTerrainSample.openLand(68, 0D, "parent").withSurfacePolicy(oneBlockFill) + : HydrologyTerrainSample.openLand(82, 0D, "parent"); RiverFootprint deepBankFootprint = compiler(deepBankTerrain).compile(List.of(course)); HydrologyColumnSample deepBank = deepBankFootprint.sample(12, lowBankZ).orElseThrow(); assertEquals(0, unsupportedBankCells(highFootprint, 528L)); - // A low bank remains below the water and is reported without raising terrain. assertEquals(69, lowBank.naturalHeight()); - assertEquals(69, lowBank.terrainHeight()); - assertTrue(unsupportedBankCells(lowBankFootprint, 528L) > 0); + assertEquals(70, lowBank.terrainHeight()); + assertEquals(0, unsupportedBankCells(lowBankFootprint, 528L)); for (HydrologyColumnSample sample : lowBankFootprint.columns().values()) { - assertTrue(sample.terrainHeight() <= sample.naturalHeight()); + HydrologyColumnLayer layer = sample.primarySurfaceLayerOrNull(); + int fillLimit = layer != null && layer.channel() ? 0 : 8; + assertTrue(sample.terrainHeight() - sample.naturalHeight() <= fillLimit); } - // Two blocks short: the bank keeps its natural height and the edge is reported. assertEquals(68, deepBank.naturalHeight()); - assertEquals(68, deepBank.terrainHeight()); + assertEquals(69, deepBank.terrainHeight()); assertTrue(unsupportedBankCells(deepBankFootprint, 528L) > 0); } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyModelTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyModelTest.java index da66814c8..8c51c93a0 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyModelTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyModelTest.java @@ -394,6 +394,30 @@ public class HydrologyModelTest { assertEquals(72, sample.terrainHeight()); } + @Test + public void overlappingRaisedBanksRetainOwnershipWithoutAccumulatingFill() { + for (boolean sameFeature : List.of(true, false)) { + for (boolean reverse : List.of(true, false)) { + FootprintMutableColumn column = new FootprintMutableColumn(0, 0, + HydrologyTerrainSample.openLand(77, 0D, "parent"), 63); + int[] beds = reverse ? new int[]{81, 80, 77, 75} : new int[]{75, 77, 80, 81}; + for (int index = 0; index < beds.length; index++) { + HydrologyFeatureRef feature = feature(HydrologyFeatureType.SURFACE_POOL, + sameFeature ? 30L : 30L + index, 80); + column.add(surfaceLayer(feature, beds[index], beds[index], + false, false, true, beds[index] != 77, false)); + } + HydrologyColumnSample merged = column.build(); + + assertEquals(77, merged.naturalHeight()); + assertEquals(81, merged.terrainHeight()); + assertEquals(4, merged.terrainHeight() - merged.naturalHeight()); + assertTrue(merged.primarySurfaceLayerOrNull().terrainOwned()); + assertEquals(sameFeature ? 1 : 4, merged.layers().size()); + } + } + } + @Test public void uncutShoreRetainsItsBiomeWithoutOverridingAnOwnedBank() { HydrologyColumnLayer shore = surfaceLayer(feature(HydrologyFeatureType.SURFACE_POOL, 30L, 75), diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java index 336cf1627..ea9987859 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlanHashTest.java @@ -17,7 +17,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; public class HydrologyPlanHashTest { - private static final String EXPECTED_PLAN_DIGEST = "9ec4c9cf7dbdad16976041a70853b909816655170cd694058bf359092c2d5a10"; + private static final String EXPECTED_PLAN_DIGEST = "e54461c339854842f7fc7b2e85bd700b57942a2c691f5a249d8e498729378296"; private static final List TILES = List.of( new HydrologyTileKey(0, 0), diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java index e62e52f5f..5ab3042b7 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyPlannerTest.java @@ -17,7 +17,6 @@ import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; -import java.util.IdentityHashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -35,7 +34,6 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeUnit; -import java.util.function.Function; import java.util.function.Consumer; import static org.junit.Assert.assertEquals; @@ -669,25 +667,18 @@ public class HydrologyPlannerTest { } @Test - public void surfaceRouteMemoPreservesCompleteOwnerOutputAcrossPublicationPasses() throws Exception { + public void sharedDrainagePreservesCompleteOwnerOutputAcrossPublicationPasses() { for (boolean organic : new boolean[]{false, true}) { HydrologyPlannerSettings settings = organic ? organicShapeSettings() : standardSettings(4D, 2D, true, false, List.of()); HydrologyTerrainSampler terrain = organic ? organicShapeTerrain() : rollingCoast(112); - SurfaceRouteMemos uncached = new SurfaceRouteMemos(false); - SurfaceRouteMemos cached = new SurfaceRouteMemos(true); - HydrologyTile expected = planWithSurfaceRouteMemos(new HydrologyPlanner(642L, settings, terrain), uncached); - HydrologyTile actual = planWithSurfaceRouteMemos(new HydrologyPlanner(642L, settings, terrain), cached); - + HydrologyPlanner planner = new HydrologyPlanner(642L, settings, terrain); + HydrologyTile expected = planner.plan(TILE); + planner.clearOwnerDrafts(); + HydrologyTile actual = planner.plan(TILE); assertEquals(expected, actual); assertEquals(expected.courses(), actual.courses()); assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates()); - assertTrue(cached.computations <= uncached.computations); - if (organic) { - assertTrue("hits=" + cached.hits, cached.hits > 0); - assertTrue("uncached=" + uncached.computations + " cached=" + cached.computations, - cached.computations < uncached.computations); - } } } @@ -717,7 +708,10 @@ public class HydrologyPlannerTest { for (HydraulicSegment segment : course.segments()) { if (segment.drop() > 0) { assertTrue(segment.type().isDrop()); - assertFalse(segment.fallingFluid()); + if (segment.fallingFluid()) { + assertEquals(HydrologyFeatureType.WATERFALL, segment.type()); + assertTrue(tile.cavePlan(course.id()).orElseThrow().accepted()); + } } } } @@ -1124,7 +1118,9 @@ public class HydrologyPlannerTest { ); for (HydraulicSegment segment : segments(cliff, HydrologyFeatureType.WATERFALL)) { assertTrue(segment.drop() > 0); - assertFalse(segment.fallingFluid()); + assertTrue(segment.fallingFluid()); + assertTrue(segment.receivingPool()); + assertTrue(cliff.cavePlan(segment.courseId()).orElseThrow().accepted()); assertTrue(segment.centerline().size() >= 2); assertTrue(segment.width() >= 1); } @@ -1139,7 +1135,8 @@ public class HydrologyPlannerTest { HydraulicSegment receiver = course.segments().get(index + 1); assertTrue(approach.type().isSurface()); assertTrue(receiver.type().isSurface()); - assertEquals(approach.end(), waterfall.start()); + assertEquals(approach.downstreamHeadY(), waterfall.upstreamHeadY()); + assertTrue(approach.end().distanceSquared2D(waterfall.start()) <= 2L); assertEquals(waterfall.end(), receiver.start()); sawBlendedWaterfall = true; } @@ -1206,6 +1203,13 @@ public class HydrologyPlannerTest { } } assertTrue("courses=" + surfaceCourses(tile), aggregateTransition); + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().type().isSurface() && layer.channel() && layer.terrainOwned()) { + assertTrue(column.naturalHeight() - layer.bedY() <= settings.surface().maximumIncision()); + } + } + } } @Test @@ -1879,7 +1883,10 @@ public class HydrologyPlannerTest { } assertTrue(bed); assertEquals(drop.fallingFluid(), falling); - assertFalse(drop.fallingFluid()); + if (drop.fallingFluid()) { + assertEquals(HydrologyFeatureType.WATERFALL, drop.type()); + assertTrue(tile.cavePlan(drop.courseId()).orElseThrow().accepted()); + } if (!receiving) { HydrologyColumnSample endColumn = tile.columnAt(drop.end().x(), drop.end().z()).orElseThrow(); receiving = endColumn.layers().stream().anyMatch((HydrologyColumnLayer layer) -> @@ -2210,6 +2217,15 @@ public class HydrologyPlannerTest { ).orElseThrow(); assertEquals(course.id(), located.courseId()); assertEquals(sinkhole.id(), located.segmentId()); + for (HydrologyColumnSample column : first.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().courseId() == course.id() && layer.feature().type().isSurface() + && layer.channel()) { + assertTrue("Surface pond exceeded the channel incision limit at " + column.x() + "," + column.z(), + column.naturalHeight() - layer.bedY() <= settings.surface().maximumIncision()); + } + } + } } @Test @@ -3446,57 +3462,6 @@ public class HydrologyPlannerTest { } return false; } - @SuppressWarnings("unchecked") - private HydrologyTile planWithSurfaceRouteMemos(HydrologyPlanner planner, SurfaceRouteMemos memos) throws Exception { - Class samplesClass = Class.forName(HydrologyPlanner.class.getName() + "$PlanningSamples"); - Constructor constructor = samplesClass.getDeclaredConstructor(); - constructor.setAccessible(true); - Object samples = constructor.newInstance(); - Field routes = samplesClass.getDeclaredField("surfaceRoutes"); - routes.setAccessible(true); - routes.set(samples, memos); - Field scope = HydrologyPlanner.class.getDeclaredField("planningSamples"); - scope.setAccessible(true); - ThreadLocal local = (ThreadLocal) scope.get(planner); - local.set(samples); - try { - return planner.plan(TILE); - } finally { - local.remove(); - } - } - - private static final class SurfaceRouteMemos extends IdentityHashMap>> { - private final boolean retain; - private int hits; - private int computations; - - private SurfaceRouteMemos(boolean retain) { - this.retain = retain; - } - - @Override - public HashMap> computeIfAbsent(Object key, - Function>> ignored) { - return super.computeIfAbsent(key, unused -> new HashMap<>() { - @Override - public List computeIfAbsent(Object route, - Function> computation) { - if (!retain) { - clear(); - } - if (containsKey(route)) { - hits++; - } - return super.computeIfAbsent(route, missing -> { - computations++; - return computation.apply(missing); - }); - } - }); - } - } - private HydrologyPlanner countedPlanner(CountingNaturalSampler sampler) { return new HydrologyPlanner(91L, standardSettings(4D, 0D, true, false, List.of()), sampler.delegate, sampler, HydrologyGeometrySampler.deterministic(sampler.delegate), diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalBankSupportTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalBankSupportTest.java index 54970443c..09c18843d 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalBankSupportTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalBankSupportTest.java @@ -31,9 +31,7 @@ public class HydrologyRegionalBankSupportTest { HydrologyTerrainSampler terrain = depression(false); List path = path(); HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain); - List refined = new HydrologyRegionalTerrainRefiner(planner).refine(path, "default", false, terrain); - assertEquals(path, refined); assertNull(validate(planner, path, terrain).rejection()); SurfaceCourseResult built = build(path, terrain); assertTrue(built.accepted()); @@ -44,7 +42,7 @@ public class HydrologyRegionalBankSupportTest { assertEquals(0, footprint.uncontainedWetCells()); assertTrue(course.hydraulicallyNonRising()); for (SurfaceLayerColumn column : footprint.columns()) { - assertTrue(column.layer().bedY() <= column.terrain().naturalHeight()); + assertTrue(column.layer().bedY() - column.terrain().naturalHeight() <= SETTINGS.surface().maximumIncision()); } SurfaceLayerColumn center = footprint.columns().stream() .filter(column -> column.x() == 132 && column.z() == 0).findFirst().orElseThrow(); @@ -58,67 +56,16 @@ public class HydrologyRegionalBankSupportTest { HydrologyTerrainSampler terrain = depression(true); HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain); - assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, - validate(planner, path(), terrain).rejection()); - List refined = new HydrologyRegionalTerrainRefiner(planner).refine(path(), "default", false, terrain); - if (!refined.isEmpty()) { - assertFalse(build(refined, terrain).accepted()); - } + assertNull(validate(planner, path(), terrain).rejection()); SurfaceCourseResult built = build(path(), terrain); - assertFalse(built.accepted()); - assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, built.rejection()); - assertEquals(17, built.rejectionDetail()); - } - - @Test - public void levelCentersNeedNoBankSamplesAndSupportedDipsCannotRaiseIncomingHead() { - AtomicInteger samples = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - assertTrue(Math.abs(x - 132) <= 5 && Math.abs(z) <= 5); - samples.incrementAndGet(); - return depression(false).sample(x, z); - }; - HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS); - - assertEquals(79, hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation( - 132, 0, 1D, 0D, HydrologyTerrainSample.openLand(80, 0D, "land"), 79), terrain)); - assertEquals(0, samples.get()); - assertEquals(78, hydraulics.supportedHead(station(78), terrain)); - assertTrue(samples.get() > 0 && samples.get() < 128); - } - - @Test - public void unavailableCrossSectionOrPerimeterCannotCertifyAContainedDip() { - HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS); - HydrologyTerrainSampler crossMissing = (x, z) -> x == 132 && z == 2 ? null : depression(false).sample(x, z); - HydrologyTerrainSampler perimeterMissing = (x, z) -> x == 133 ? null : depression(false).sample(x, z); - - assertEquals(65, hydraulics.supportedHead(station(80), crossMissing)); - assertEquals(65, hydraulics.supportedHead(station(80), perimeterMissing)); - } - - @Test - public void bankSupportCannotSamplePastTheExistingSearchBudget() throws Exception { - AtomicInteger requests = new AtomicInteger(); - HydrologyTerrainSample ground = HydrologyTerrainSample.openLand(80, 0D, "land"); - HydrologyTerrainSampler terrain = (x, z) -> { - requests.incrementAndGet(); - return ground; - }; - HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain)); - HydrologyRegionalTerrainRefiner.Samples sampled = refiner.new Samples(); - for (int index = 0; index < 65536; index++) { - sampled.sample(index, 100000); + if (built.accepted()) { + RiverCourse course = new RiverCourse(91L, RiverCourseType.SURFACE, OptionalLong.of(1L), OptionalLong.of(2L), + "default", 1, List.of(), built.segments()); + SurfaceFootprint footprint = new SurfaceFootprintCompiler(SETTINGS, terrain, GEOMETRY).compile(course); + assertFalse(footprint.accepted()); + } else { + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, built.rejection()); } - requests.set(0); - - assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), sampled)); - assertEquals(0, requests.get()); - } - - private static HydrologyRegionalHydraulics.HeadStation station(int incomingHead) { - return new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D, - HydrologyTerrainSample.openLand(65, 0D, "land"), incomingHead); } private static HydrologyTerrainSampler depression(boolean acrossBanks) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java index 8c52a9576..c2e7d1209 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCacheTest.java @@ -14,34 +14,6 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class HydrologyRegionalCacheTest { - @Test - public void searchBudgetDoesNotLimitSubsequentSearchesOrTerrainResults() { - AtomicInteger reads = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - reads.incrementAndGet(); - return HydrologyTerrainSample.openLand(70, 0D, "land"); - }; - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner); - HydrologyRegionalTerrainRefiner.Samples first = refiner.new Samples(); - for (int x = 0; x < 65536; x++) { - assertNotNull(first.sample(x, 0)); - } - assertNull(first.sample(65536, 0)); - HydrologyRegionalTerrainRefiner.Samples second = refiner.new Samples(); - for (int x = 65536; x < 70000; x++) { - assertNotNull(second.sample(x, 0)); - } - assertEquals(70000, reads.get()); - for (int x = 0; x < 70000; x++) { - assertEquals(70, refiner.sample(x, 0).naturalHeight()); - } - int readsBeforeClear = reads.get(); - refiner.clear(); - assertEquals(70, refiner.sample(0, 0).naturalHeight()); - assertEquals(readsBeforeClear + 1, reads.get()); - } - @Test public void clearingTilesRefreshesChangedRegionalTerrain() throws Exception { AtomicInteger height = new AtomicInteger(70); @@ -51,7 +23,7 @@ public class HydrologyRegionalCacheTest { return HydrologyTerrainSample.openLand(height.get(), 0D, "land"); }; HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - HydrologyRegionalTerrainRefiner refiner = refiner(planner); + HydrologyRegionalTerrain refiner = refiner(planner); try (HydrologyTileCache cache = new HydrologyTileCache(planner)) { assertEquals(70, refiner.sample(0, 0).naturalHeight()); height.set(85); @@ -71,7 +43,7 @@ public class HydrologyRegionalCacheTest { return available.get() ? HydrologyTerrainSample.openLand(70, 0D, "land") : null; }; HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - HydrologyRegionalTerrainRefiner refiner = refiner(planner); + HydrologyRegionalTerrain refiner = refiner(planner); try (HydrologyTileCache cache = new HydrologyTileCache(planner)) { assertNull(refiner.sample(0, 0)); available.set(true); @@ -82,32 +54,11 @@ public class HydrologyRegionalCacheTest { } } - @Test - public void clearingTilesReplansRegionalReachesAgainstCurrentTerrain() throws Exception { - AtomicBoolean flooded = new AtomicBoolean(); - HydrologyTerrainSampler terrain = (x, z) -> flooded.get() && x >= 96 && x <= 160 && Math.abs(z) < 32 - ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - HydrologyRegionalTerrainRefiner refiner = refiner(planner); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)); - try (HydrologyTileCache cache = new HydrologyTileCache(planner)) { - assertEquals(guide, refiner.refine(guide, "default", false, terrain)); - flooded.set(true); - cache.clear(); - List actual = refiner.refine(guide, "default", false, terrain); - List fresh = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain); - - assertFalse(fresh.isEmpty()); - assertTrue(fresh.stream().anyMatch(point -> Math.abs(point.z()) >= 32)); - assertEquals(fresh, actual); - } - } - - private static HydrologyRegionalTerrainRefiner refiner(HydrologyPlanner planner) throws Exception { + private static HydrologyRegionalTerrain refiner(HydrologyPlanner planner) throws Exception { Field routes = HydrologyRegionalPlanner.class.getDeclaredField("routes"); routes.setAccessible(true); - Field refiner = HydrologyRegionalRoute.class.getDeclaredField("terrainRefiner"); + Field refiner = HydrologyRegionalRoute.class.getDeclaredField("terrain"); refiner.setAccessible(true); - return (HydrologyRegionalTerrainRefiner) refiner.get(routes.get(planner.regional)); + return (HydrologyRegionalTerrain) refiner.get(routes.get(planner.regional)); } } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCandidateSelectionTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCandidateSelectionTest.java index 288be796f..03de28f28 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCandidateSelectionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalCandidateSelectionTest.java @@ -19,7 +19,7 @@ import static org.junit.Assert.assertTrue; public class HydrologyRegionalCandidateSelectionTest { @Test - public void lowerMeanderCandidatePassesTheFullPlannerAfterTheFirstBankCutFails() throws Exception { + public void plannedHeadCrossesAChannelDepressionWithoutLoweringTheWholeCourse() throws Exception { HydrologyTerrainSampler terrain = coast(true); HydrologyPlanner planner = planner(terrain, 16, 2); List guide = guide(terrain); @@ -33,9 +33,8 @@ public class HydrologyRegionalCandidateSelectionTest { }); assertTrue(selected.toString(), selected.accepted()); - assertEquals(2, attempted.size()); - assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, attempted.getFirst().rejection().rejection()); - assertEquals(18, attempted.getFirst().rejection().detail()); + assertEquals(1, attempted.size()); + assertNull(attempted.getFirst().rejection()); assertEquals(1, selected.network().courses().size()); assertEquals(selected, attempted.getLast()); RiverCourse course = selected.network().courses().getFirst(); @@ -124,8 +123,10 @@ public class HydrologyRegionalCandidateSelectionTest { } int[] contributions = new int[9]; Arrays.fill(contributions, 1); + int[] heads = new int[9]; + Arrays.fill(heads, 80); HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, contributions, - List.of(new OutletCandidate(8, 8, outlet()))); + List.of(new OutletCandidate(8, 8, outlet())), heads); ArrayList diagnostics = new ArrayList<>(); Method build = HydrologyRegionalPlanner.class.getDeclaredMethod("build", HydrologySampledGrid.class, HydrologyRegionalGraph.Tree.class, int.class, OutletCandidate.class, long.class, List.class); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalFallsTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalFallsTest.java index 8405a6025..46ab117e7 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalFallsTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalFallsTest.java @@ -51,7 +51,7 @@ public final class HydrologyRegionalFallsTest { HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(), SETTINGS.routing().tileSize(), network.nodes(), network.edges(), network.outlets(), List.of(regional, local), Set.of(regional.id()), clipped.cavePlans(), List.of(), materialized.footprint()); - StudioHydrologyTileStore store = new StudioHydrologyTileStore(temporaryFolder.newFolder().toPath(), + PreparedHydrologyTileStore store = new PreparedHydrologyTileStore(temporaryFolder.newFolder().toPath(), new HydrologyTileCache.SharedCacheScope("regional-ownership", 1L, 128, "overworld", SETTINGS.fingerprint()), SETTINGS.routing().tileSize()); store.save(original); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalMouthRefinementTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalMouthRefinementTest.java index 1fd184505..4459d9f90 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalMouthRefinementTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalMouthRefinementTest.java @@ -28,8 +28,8 @@ public class HydrologyRegionalMouthRefinementTest { HydrologyTerrainSampler receiver = HydrologyOceanReceiver.forOutlet(settings, terrain, outlet); HydrologyPlanner planner = new HydrologyPlanner(1L, settings, terrain); HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner); - List path = List.of(new HydrologyPoint(0, 65, 0), new HydrologyPoint(767, 65, 0), - new HydrologyPoint(768, 80, 0), landward); + List path = List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(767, 63, 0), + new HydrologyPoint(768, 63, 0), landward); Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validate", List.class, String.class, boolean.class, HydrologyTerrainSampler.class); validate.setAccessible(true); @@ -40,7 +40,7 @@ public class HydrologyRegionalMouthRefinementTest { assertNull(accepted.toString(), accepted.rejection()); assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, unproved.rejection()); - assertTrue(new HydrologyRegionalTerrainRefiner(planner).receivingTerminal(landward, "default", false, receiver)); + assertTrue(new HydrologyRegionalTerrain(planner).receivingTerminal(landward, "default", false, receiver)); assertFalse(terrain.sample(800, 0).ocean()); assertTrue(receiver.receivingWater(800, 0, 63)); } @@ -171,8 +171,8 @@ public class HydrologyRegionalMouthRefinementTest { HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1); HydrologyTerrainSampler terrain = (x, z) -> x >= 800 ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(x >= 768 ? 80 : 65, 0D, "land"); - List path = List.of(new HydrologyPoint(0, 65, 0), new HydrologyPoint(767, 65, 0), - new HydrologyPoint(768, 80, 0), new HydrologyPoint(799, 80, 0), new HydrologyPoint(800, 63, 0)); + List path = List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(767, 63, 0), + new HydrologyPoint(768, 63, 0), new HydrologyPoint(799, 63, 0), new HydrologyPoint(800, 63, 0)); HydrologyGeometrySampler geometry = request -> switch (request.field()) { case SURFACE_WIDTH -> 6; case SURFACE_DEPTH -> 2; diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSamplingTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSamplingTest.java index d05eca45e..e3086af18 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSamplingTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSamplingTest.java @@ -38,7 +38,7 @@ public class HydrologyRegionalSamplingTest { RecordingSampler serial = new RecordingSampler(terrain); SurfaceBankSupport.CrossSection cross = support.crossSection(serial, station, true); SurfaceBankSupport.Perimeter perimeter = support.perimeter(serial, station, 80); - RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples()); + RecordingSampler parallel = new RecordingSampler(refiner(terrain)); pool.submit(() -> { assertEquals(cross, support.crossSection(parallel, station, true)); assertEquals(perimeter, support.perimeter(parallel, station, 80)); @@ -49,48 +49,6 @@ public class HydrologyRegionalSamplingTest { } } - @Test - public void searchLimitCountsNullsAndRetainsAlreadyAdmittedCoordinates() { - AtomicInteger reads = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - reads.incrementAndGet(); - return x == 0 ? null : HydrologyTerrainSample.openLand(70, 0D, "land"); - }; - HydrologyRegionalTerrainRefiner refiner = refiner(terrain); - refiner.sample(100000, 0); - HydrologyRegionalTerrainRefiner.Samples samples = refiner.new Samples(); - assertNull(samples.sample(0, 0)); - for (int x = 1; x < 65536; x++) { - assertEquals(70, samples.sample(x, 0).naturalHeight()); - } - assertNull(samples.sample(0, 0)); - assertEquals(70, samples.sample(1, 0).naturalHeight()); - assertNull(samples.sample(65536, 0)); - assertNull(samples.sample(100000, 0)); - assertEquals(65537, reads.get()); - } - - @Test - public void failedRequiredSampleRetainsItsExceptionAndDoesNotConsumeAdmission() { - AtomicInteger failedReads = new AtomicInteger(); - IllegalStateException failure = new IllegalStateException("Required terrain unavailable"); - HydrologyTerrainSampler terrain = (x, z) -> { - if (x == -1 && failedReads.incrementAndGet() == 1) { - throw failure; - } - return HydrologyTerrainSample.openLand(70, 0D, "land"); - }; - HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples(); - for (int x = 0; x < 65535; x++) { - samples.sample(x, 0); - } - assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(-1, 0))); - assertEquals(70, samples.sample(-1, 0).naturalHeight()); - assertEquals(70, samples.sample(0, 0).naturalHeight()); - assertNull(samples.sample(-2, 0)); - assertEquals(2, failedReads.get()); - } - @Test public void unavailableBankStopsBeforeALaterProviderFailure() throws Exception { IllegalStateException failure = new IllegalStateException("Later bank sample"); @@ -103,7 +61,7 @@ public class HydrologyRegionalSamplingTest { SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel()); SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(0, 0, 1D, 0D, 4D); SurfaceBankSupport.CrossSection expected = support.crossSection(terrain, station, true); - HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples(); + HydrologyRegionalTerrain samples = refiner(terrain); try (ForkJoinPool pool = new ForkJoinPool(4)) { assertEquals(expected, pool.submit(() -> support.crossSection(samples, station, true)).get(10, TimeUnit.SECONDS)); } @@ -111,137 +69,8 @@ public class HydrologyRegionalSamplingTest { assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(0, 0))); } - @Test - public void hydraulicBudgetAndLazyWidthsPreserveConsumptionOrder() throws Exception { - HydrologyPlannerSettings.Surface surface = SETTINGS.surface(); - HydrologyPlannerSettings settings = new HydrologyPlannerSettings(SETTINGS.seaLevel(), SETTINGS.routing(), - new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), 256, - surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()), - SETTINGS.hydraulics(), SETTINGS.underground(), SETTINGS.outlets(), SETTINGS.geometry(), SETTINGS.deepFluids(), - SETTINGS.surfacePools(), SETTINGS.widestShoreBiomeWidth(), SETTINGS.seaCaves(), SETTINGS.surfacePolicyBounds()); - HydrologyTerrainSample ground = new HydrologyTerrainSample(65, 0D, false, false, 0, 0, - true, true, true, false, true, false, 0D, 1D, 1D, 0.75D, 1D, 1D, 1D, 1D, - "land", "land", "land", "land", "land", "land", List.of("default"), List.of(), - Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT); - HydrologyTerrainSampler terrain = (x, z) -> x == 133 ? null : x == 132 && z == 0 ? ground - : HydrologyTerrainSample.openLand(80, 0D, "land"); - HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(settings); - HydrologyRegionalHydraulics.HeadStation station = new HydrologyRegionalHydraulics.HeadStation( - 132, 0, 1D, 0D, terrain.sample(132, 0), 80); - RecordingSampler serial = new RecordingSampler(terrain); - int expected = hydraulics.supportedHead(station, serial); - RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples()); - try (ForkJoinPool pool = new ForkJoinPool(4)) { - assertEquals(expected, pool.submit(() -> hydraulics.supportedHead(station, parallel)).get(10, TimeUnit.SECONDS).intValue()); - } - assertEquals(serial.requests, parallel.requests); - assertTrue(serial.requests.size() > 2048); - } - - @Test - public void routeRefinementMatchesSerialTerrainAndHydraulics() throws Exception { - HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( - x >= 96 && x <= 160 && Math.abs(z) < 32 ? 95 : 70, 0D, "land"); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)); - List expected = refiner(terrain).refine(guide, "default", false, terrain); - try (ForkJoinPool pool = new ForkJoinPool(4)) { - List actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain)) - .get(15, TimeUnit.SECONDS); - assertFalse(expected.isEmpty()); - assertEquals(expected, actual); - } - } - - @Test - public void coastalCenterlineRetainsNegativeAndFractionalTraversalOrder() throws Exception { - HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(64, 0D, "land"); - List guide = List.of(new HydrologyPoint(-173, 64, -117), new HydrologyPoint(174, 64, 138)); - List expected = refiner(terrain).refine(guide, "default", true, terrain); - try (ForkJoinPool pool = new ForkJoinPool(4)) { - assertFalse(expected.isEmpty()); - assertEquals(expected, pool.submit(() -> refiner(terrain).refine(guide, "default", true, terrain)) - .get(15, TimeUnit.SECONDS)); - } - } - - @Test - public void rejectedTraversalDoesNotReadALaterProviderFailure() throws Exception { - IllegalStateException failure = new IllegalStateException("Later centerline sample"); - AtomicInteger failures = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - if (x == 32 && z == 0) { - failures.incrementAndGet(); - throw failure; - } - return z == 0 && (x == 0 || x == 128) - ? HydrologyTerrainSample.openLand(70, 0D, "land") : null; - }; - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0)); - assertTrue(refiner(terrain).refine(guide, "default", false, terrain).isEmpty()); - assertEquals(0, failures.get()); - try (ForkJoinPool pool = new ForkJoinPool(4)) { - assertTrue(pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain)) - .get(15, TimeUnit.SECONDS).isEmpty()); - assertEquals(0, failures.get()); - } - } - - @Test - public void clearingAnActiveReachCannotPublishIntoTheNewCache() throws Exception { - AtomicBoolean changed = new AtomicBoolean(); - AtomicBoolean blocked = new AtomicBoolean(); - CountDownLatch started = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - HydrologyTerrainSampler terrain = (x, z) -> { - boolean elevated = changed.get(); - if (x == 64 && z == 0 && blocked.compareAndSet(false, true)) { - started.countDown(); - await(release); - } - return HydrologyTerrainSample.openLand(elevated && x == 64 && z == 0 ? 100 : 70, 0D, "land"); - }; - HydrologyRegionalTerrainRefiner refiner = refiner(terrain); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)); - try (ExecutorService executor = Executors.newSingleThreadExecutor()) { - Future> previous = executor.submit(() -> refiner.refine(guide, "default", false, terrain)); - try { - assertTrue(started.await(5, TimeUnit.SECONDS)); - changed.set(true); - refiner.clear(); - assertEquals(100, refiner.sample(64, 0).naturalHeight()); - } finally { - release.countDown(); - } - previous.get(15, TimeUnit.SECONDS); - assertEquals(refiner(terrain).refine(guide, "default", false, terrain), - refiner.refine(guide, "default", false, terrain)); - assertEquals(100, refiner.sample(64, 0).naturalHeight()); - } - } - - @Test - public void signedCoordinateTiesRemainStableInEveryDirection() throws Exception { - try (ForkJoinPool pool = new ForkJoinPool(8)) { - for (int[] direction : new int[][]{{1, 0}, {0, 1}, {-1, 0}, {0, -1}}) { - HydrologyTerrainSampler terrain = (x, z) -> { - int along = x * direction[0] + z * direction[1]; - int across = -x * direction[1] + z * direction[0]; - return HydrologyTerrainSample.openLand( - along >= 96 && along <= 160 && Math.abs(across) < 32 ? 95 : 70, 0D, "land"); - }; - List guide = List.of(new HydrologyPoint(0, 70, 0), - new HydrologyPoint(256 * direction[0], 70, 256 * direction[1])); - List expected = refiner(terrain).refine(guide, "default", false, terrain); - List actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain)) - .get(15, TimeUnit.SECONDS); - assertFalse(expected.isEmpty()); - assertEquals(expected, actual); - } - } - } - - private static HydrologyRegionalTerrainRefiner refiner(HydrologyTerrainSampler terrain) { - return new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain)); + private static HydrologyRegionalTerrain refiner(HydrologyTerrainSampler terrain) { + return new HydrologyRegionalTerrain(new HydrologyPlanner(71L, SETTINGS, terrain)); } private static void await(CountDownLatch latch) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSkippedWaypointTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSkippedWaypointTest.java deleted file mode 100644 index a3ed701d7..000000000 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSkippedWaypointTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package art.arcane.iris.generation.hydrology; - -import org.junit.Test; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class HydrologyRegionalSkippedWaypointTest { - @Test - public void aSkippableHillDoesNotBlockAnEarlierValleyAcceptedByTheFullPlanner() throws Exception { - Set hills = Set.of(4); - HydrologyTerrainSampler terrain = terrain(hills, true); - HydrologyPlanner planner = planner(terrain); - List guide = guide(terrain, hills); - assertCoarseFeasible(planner, terrain, guide); - List refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain); - - assertFalse(refined.isEmpty()); - assertFalse(refined.contains(guide.get(4))); - Object context = context(guide, terrain); - HydrologyRegionalRoute.Attempt selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, - candidate -> attempt(planner, context, candidate)); - - assertTrue(selected.toString(), selected.accepted()); - assertEquals(1, selected.network().courses().size()); - assertTrue(selected.network().courses().getFirst().hydraulicallyNonRising()); - assertEquals(16, planner.settings.surface().maximumIncision()); - } - - @Test - public void theTerminalHeadRemainsMandatory() { - HydrologyTerrainSampler highTerminal = terrain(Set.of(8), false); - HydrologyPlanner planner = planner(highTerminal); - List guide = guide(highTerminal, Set.of(8)); - assertCoarseFeasible(planner, highTerminal, guide); - - assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, highTerminal).isEmpty()); - - HydrologyTerrainSampler lowTerminal = terrain(Set.of(), false); - assertFalse(new HydrologyRegionalTerrainRefiner(planner(lowTerminal)) - .refine(guide(lowTerminal, Set.of()), "default", false, lowTerminal).isEmpty()); - } - - @Test - public void threeConsecutiveHighWaypointsCannotBeSkipped() { - Set hills = Set.of(4, 5, 6); - HydrologyTerrainSampler terrain = terrain(hills, true); - HydrologyPlanner planner = planner(terrain); - List guide = guide(terrain, hills); - assertCoarseFeasible(planner, terrain, guide); - - assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain).isEmpty()); - - Set twoHills = Set.of(4, 5); - HydrologyTerrainSampler skippable = terrain(twoHills, true); - assertFalse(new HydrologyRegionalTerrainRefiner(planner(skippable)) - .refine(guide(skippable, twoHills), "default", false, skippable).isEmpty()); - } - - @Test - public void aSampledRidgeCannotRaiseTheHeadAfterAnEarlierValley() { - HydrologyTerrainSampler base = terrain(Set.of(), true); - HydrologyTerrainSampler ridge = (x, z) -> x >= 200 && x <= 224 - ? HydrologyTerrainSample.openLand(95, 0D, "land") : base.sample(x, z); - HydrologyPlanner planner = planner(ridge); - List guide = guide(ridge, Set.of()); - assertCoarseFeasible(planner, ridge, guide); - - assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, ridge).isEmpty()); - AtomicInteger admitted = new AtomicInteger(); - HydrologyRegionalRoute.Attempt selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, ridge, - candidate -> { - admitted.incrementAndGet(); - return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null); - }); - - assertFalse(selected.accepted()); - assertEquals(0, admitted.get()); - assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, selected.refinement().rejection()); - } - - private static void assertCoarseFeasible(HydrologyPlanner planner, HydrologyTerrainSampler terrain, List guide) { - HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings); - int available = Integer.MAX_VALUE; - for (HydrologyPoint point : guide) { - HydrologyTerrainSample sample = terrain.sample(point.x(), point.z()); - available = Math.min(available, hydraulics.maximumHead(sample)); - assertTrue(point.toString(), available >= hydraulics.minimumHead(sample)); - } - } - - private static HydrologyRegionalRoute.Attempt attempt(HydrologyPlanner planner, Object context, - HydrologyRegionalRoute.Refinement candidate) { - try { - Method attempt = HydrologyRegionalPlanner.class.getDeclaredMethod("attempt", context.getClass(), HydrologyRegionalRoute.Refinement.class); - attempt.setAccessible(true); - return (HydrologyRegionalRoute.Attempt) attempt.invoke(planner.regional, context, candidate); - } catch (ReflectiveOperationException failure) { - throw new AssertionError(failure); - } - } - - private static Object context(List guide, HydrologyTerrainSampler terrain) throws Exception { - Class type = Class.forName(HydrologyRegionalPlanner.class.getName() + "$BuildContext"); - Constructor constructor = type.getDeclaredConstructors()[0]; - constructor.setAccessible(true); - int[] contributions = new int[guide.size()]; - Arrays.fill(contributions, 1); - RiverOutlet outlet = new RiverOutlet(2L, HydrologyFeatureType.MOUTH, 8L, new HydrologyPoint(1024, 63, 0), - new HydrologyPoint(1025, 63, 0), 63, true); - return constructor.newInstance(new HydrologyGridNode(0, 0, 0, 0, 0, 100L, terrain.sample(0, 0)), - null, 123L, outlet, "default", new HydrologyRegionalFlow(guide, contributions), - HydrologyRegionalMorphology.sample(guide, terrain, HydrologyRegionalPlannerTest.settings(false, 1))); - } - - private static List guide(HydrologyTerrainSampler terrain, Set hills) { - ArrayList guide = new ArrayList<>(); - for (int index = 0; index < 9; index++) { - int x = index * 128; - int z = index != 8 && hills.contains(index) ? 128 : 0; - guide.add(new HydrologyPoint(x, terrain.sample(x, z).naturalHeight(), z)); - } - return List.copyOf(guide); - } - - private static HydrologyTerrainSampler terrain(Set hills, boolean coast) { - return (x, z) -> { - if (coast && x >= 1025) { - return HydrologyTerrainSample.ocean(60, "ocean"); - } - int height = coast ? 80 - Math.max(0, x - 752) / 16 : 80; - if (x >= 32 && x <= 96) { - height = 75; - } - for (int hill : hills) { - if (Math.abs(x - hill * 128) <= 24 && Math.abs(z - (hill == 8 ? 0 : 128)) <= 24) { - height = 93; - } - } - return HydrologyTerrainSample.openLand(height, 0D, "land"); - }; - } - - private static HydrologyPlanner planner(HydrologyTerrainSampler terrain) { - HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 1); - HydrologyPlannerSettings.Surface surface = base.surface(); - HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(), - new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), surface.maximumWidth(), - surface.minimumDepth(), surface.maximumDepth(), 16, surface.shoreWidth(), surface.banks()), - base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), - base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds()); - HydrologyGeometrySampler geometry = request -> switch (request.field()) { - case SURFACE_WIDTH -> 8; - case SURFACE_DEPTH -> 2; - default -> request.minimum(); - }; - return new HydrologyPlanner(71L, settings, terrain, geometry, -4096, - footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, -4096, 4096)); - } -} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java index a9719cee8..a1f279e25 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceParallelismTest.java @@ -209,7 +209,7 @@ public class HydrologyRegionalSourceParallelismTest { sources.put(RiverFootprint.pack(node.x(), node.z()), ordinal++); } assertEquals(secondCatchmentSize + 4, ordinal); - return new HydrologyRegionalGraph.Tree(labels, contributions, outlets); + return new HydrologyRegionalGraph.Tree(labels, contributions, outlets, new int[contributions.length]); }); when(graph.path(any(), anyInt())).thenAnswer(invocation -> List.of(invocation.getArgument(1), 0)); replace(planner.regional, "graph", graph); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceTrialsTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceTrialsTest.java index 45d62aa2f..56eb38696 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceTrialsTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSourceTrialsTest.java @@ -92,7 +92,7 @@ public class HydrologyRegionalSourceTrialsTest { new RiverOutlet(100L + root, HydrologyFeatureType.MOUTH, root, point, point, 63, true))); } HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 16384, sources.size(), 1, nodes); - HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, new int[sources.size()], outlets); + HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, new int[sources.size()], outlets, new int[sources.size()]); return new Fixture(grid, tree, indices); } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSurfacePolicyTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSurfacePolicyTest.java index 4a1731cab..a6237f9a9 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSurfacePolicyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalSurfacePolicyTest.java @@ -249,7 +249,7 @@ public class HydrologyRegionalSurfacePolicyTest { candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L, HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true))); } - return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false); + return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false, null); } private static HydrologyPlannerSettings withSources(HydrologyPlannerSettings.Source sources) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefinerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefinerTest.java deleted file mode 100644 index a5125b247..000000000 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainRefinerTest.java +++ /dev/null @@ -1,541 +0,0 @@ -package art.arcane.iris.generation.hydrology; - -import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; -import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline; -import org.junit.Test; - -import java.util.List; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.lang.reflect.Method; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -public class HydrologyRegionalTerrainRefinerTest { - @Test(timeout = 10000) - public void clearingDuringSamplingCannotPublishOldTerrain() throws Exception { - CountDownLatch started = new CountDownLatch(1); - CountDownLatch release = new CountDownLatch(1); - AtomicInteger calls = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - int call = calls.incrementAndGet(); - if (call == 1) { - started.countDown(); - try { - assertTrue(release.await(5, TimeUnit.SECONDS)); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(interrupted); - } - } - return HydrologyTerrainSample.openLand(call == 1 ? 70 : 80, 0D, "land"); - }; - HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner( - new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain)); - try (ExecutorService workers = Executors.newSingleThreadExecutor()) { - Future first = workers.submit(() -> refiner.sample(0, 0)); - try { - assertTrue(started.await(3, TimeUnit.SECONDS)); - refiner.clear(); - assertEquals(80, refiner.sample(0, 0).naturalHeight()); - } finally { - release.countDown(); - } - assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight()); - assertEquals(80, refiner.sample(0, 0).naturalHeight()); - assertEquals(2, calls.get()); - } - } - - @Test(timeout = 10000) - public void independentDiagonalSamplesDoNotWaitForEachOthersTerrain() throws Exception { - CountDownLatch sampling = new CountDownLatch(2); - AtomicInteger calls = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - calls.incrementAndGet(); - sampling.countDown(); - try { - assertTrue("Independent terrain samples were serialized", sampling.await(3, TimeUnit.SECONDS)); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - throw new IllegalStateException(interrupted); - } - return HydrologyTerrainSample.openLand(70 + x, 0D, "land"); - }; - HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner( - new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain)); - try (ExecutorService workers = Executors.newFixedThreadPool(2)) { - Future first = workers.submit(() -> refiner.sample(0, 0)); - Future second = workers.submit(() -> refiner.sample(1, 1)); - assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight()); - assertEquals(71, second.get(5, TimeUnit.SECONDS).naturalHeight()); - assertEquals(first.get(), refiner.sample(0, 0)); - assertEquals(second.get(), refiner.sample(1, 1)); - assertEquals(2, calls.get()); - } - } - - @Test - public void aHigherHeadDetourSurvivesACheaperInfeasibleArrival() { - int[][] heights = { - {86, 94, 77, 91, 75, 85, 82, 88, 74}, - {84, 94, 83, 85, 72, 76, 73, 80, 72}, - {73, 85, 78, 77, 73, 82, 84, 81, 79}, - {83, 77, 92, 81, 84, 80, 93, 94, 96}, - {90, 73, 76, 84, 74, 73, 91, 80, 85}, - {91, 96, 90, 75, 91, 84, 94, 96, 95}, - {84, 76, 80, 92, 72, 73, 96, 75, 86}, - {94, 93, 86, 81, 80, 89, 75, 93, 77}, - {85, 95, 85, 73, 88, 82, 81, 94, 92} - }; - HydrologyTerrainSampler terrain = (x, z) -> { - int gridX = Math.floorDiv(x, 16); - int gridZ = Math.floorDiv(z, 16) + 4; - return gridX < 0 || gridX >= 9 || gridZ < 0 || gridZ >= 9 - ? HydrologyTerrainSample.ocean(60, "outside") - : HydrologyTerrainSample.openLand(heights[gridZ][gridX], 0D, "land"); - }; - HydrologyPlanner planner = new HydrologyPlanner(1L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 90, 0), new HydrologyPoint(128, 85, 0)); - HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner); - List path = refiner.refine(guide, "default", false, terrain); - - assertFalse(path.isEmpty()); - assertEquals(guide.getFirst(), path.getFirst()); - assertEquals(guide.getLast(), path.getLast()); - HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings); - SurfaceCenterline centerline = SurfaceCenterline.densify(path); - int available = 90; - for (int station = 0; station < centerline.size(); station++) { - HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]); - assertFalse(sample.ocean()); - int supported = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation( - centerline.x()[station], centerline.z()[station], centerline.tangentX()[station], - centerline.tangentZ()[station], sample, available), terrain); - assertTrue(supported <= available); - available = supported; - assertTrue("station=" + station, available >= hydraulics.minimumHead(sample)); - } - assertEquals(path, refiner.refine(guide, "default", false, terrain)); - assertEquals(path, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain)); - } - - @Test - public void shortRepairedPrefixDoesNotHideALongEnoughGuide() { - HydrologyTerrainSampler terrain = (x, z) -> x >= 406 ? HydrologyTerrainSample.ocean(60, "ocean") - : HydrologyTerrainSample.openLand(x >= 400 ? 60 : 70, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(0, 70, 96), - new HydrologyPoint(128, 70, 96), new HydrologyPoint(256, 70, 96), new HydrologyPoint(256, 70, 0), - new HydrologyPoint(512, 70, 0)); - HydrologyRegionalRoute.Refinement selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, - candidate -> { - double length = 0D; - for (int index = 1; index < candidate.points().size(); index++) { - length += StrictMath.sqrt(candidate.points().get(index - 1).distanceSquared2D(candidate.points().get(index))); - } - assertTrue(length >= planner.settings.routing().regional().minimumLength()); - return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null); - }).refinement(); - - assertNull(selected.toString(), selected.rejection()); - assertEquals(new HydrologyPoint(399, 63, 0), selected.oceanEntry().landward()); - assertEquals(new HydrologyPoint(406, 63, 0), selected.oceanEntry().receiving()); - } - - @Test - public void onlyTheRetainedDryPrefixNeedsTerrainRepair() { - HydrologyTerrainSampler terrain = (x, z) -> x >= 806 ? HydrologyTerrainSample.ocean(60, "ocean") - : HydrologyTerrainSample.openLand(x >= 800 ? 60 : x >= 96 && x <= 160 && Math.abs(z) < 32 ? 86 : 70, - 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0), - new HydrologyPoint(512, 70, 0), new HydrologyPoint(1024, 70, 0)); - assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain).isEmpty()); - HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement(); - - assertNull(repaired.toString(), repaired.rejection()); - assertEquals(new HydrologyPoint(799, 63, 0), repaired.oceanEntry().landward()); - assertEquals(new HydrologyPoint(806, 63, 0), repaired.oceanEntry().receiving()); - assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32)); - assertTrue(repaired.points().stream().allMatch(point -> point.x() < 800)); - } - - @Test - public void aCoarseOceanHitBeforeMinimumLengthStillAllowsADryTerrainDetour() { - HydrologyTerrainSampler terrain = (x, z) -> x >= 800 || x >= 96 && x <= 160 && Math.abs(z) < 32 - ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0), - new HydrologyPoint(512, 70, 0), new HydrologyPoint(799, 70, 0)); - HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement(); - - assertNull(repaired.toString(), repaired.rejection()); - assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32)); - assertEquals(guide.getLast(), repaired.points().getLast()); - } - - @Test - public void naturallyFloodedLandCannotBeUsedAsAnOwnedDryReach() { - HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( - x >= 96 && x <= 160 && Math.abs(z) < 32 ? 60 : 70, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)); - List repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain); - - assertFalse(repaired.isEmpty()); - SurfaceCenterline centerline = SurfaceCenterline.densify(repaired); - for (int station = 0; station < centerline.size(); station++) { - HydrologyTerrainSample sampled = terrain.sample(centerline.x()[station], centerline.z()[station]); - assertFalse(sampled.ocean()); - assertTrue(sampled.naturalHeight() >= planner.settings.seaLevel()); - } - } - - @Test - public void repeatedCoarseAnchorsCannotCreateRetracedWaterways() { - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), - (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land")); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0), - new HydrologyPoint(128, 70, 128), new HydrologyPoint(128, 70, 0), new HydrologyPoint(256, 70, 0)); - List refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler); - - assertEquals(List.of(guide.getFirst(), guide.getLast()), refined); - } - - @Test - public void redundantCoarseAnchorHairpinsUseTheClearTerrainShortcut() { - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), - (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land")); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0), - new HydrologyPoint(160, 70, 32), new HydrologyPoint(144, 70, 48), new HydrologyPoint(160, 70, 48), - new HydrologyPoint(192, 70, 16), new HydrologyPoint(256, 70, 0)); - List refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler); - - assertEquals(guide.getFirst(), refined.getFirst()); - assertEquals(guide.getLast(), refined.getLast()); - assertTrue(refined.size() <= 3); - for (int index = 1; index < refined.size(); index++) { - assertTrue(refined.get(index).x() > refined.get(index - 1).x()); - } - } - - @Test - public void repairsUnsampledOceanPocketsAtPositiveAndNegativeCoordinates() { - for (int origin : new int[]{0, -512}) { - HydrologyTerrainSampler terrain = (x, z) -> x >= origin + 96 && x <= origin + 160 && Math.abs(z) < 32 - ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land"); - List guide = List.of(new HydrologyPoint(origin, 70, 0), new HydrologyPoint(origin + 256, 70, 0)); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner); - List first = refiner.refine(guide, "default", false, planner.sampler); - - assertFalse(first.isEmpty()); - assertEquals(guide.getFirst(), first.getFirst()); - assertEquals(guide.getLast(), first.getLast()); - SurfaceCenterline centerline = SurfaceCenterline.densify(first); - for (int station = 0; station < centerline.size(); station++) { - assertFalse(terrain.sample(centerline.x()[station], centerline.z()[station]).ocean()); - } - assertEquals(first, refiner.refine(guide, "default", false, planner.sampler)); - assertEquals(first, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler)); - } - } - - @Test - public void aWaterCourseDetoursAroundALavaOnlyBiome() { - HydrologyTerrainSampler terrain = (x, z) -> land(x >= 96 && x <= 160 && Math.abs(z) < 32 ? "lava" : "water"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)); - List refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "water", false, planner.sampler); - - assertFalse(refined.isEmpty()); - SurfaceCenterline centerline = SurfaceCenterline.densify(refined); - for (int station = 0; station < centerline.size(); station++) { - assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys()); - } - } - - @Test - public void repairedTerrainRouteStillPassesOrganicShapeValidation() { - HydrologyTerrainSampler terrain = (x, z) -> x >= 96 && x <= 160 && Math.abs(z) < 32 - ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - HydrologyRegionalRoute.Refinement refined = new HydrologyRegionalRoute(planner).select( - List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement(); - - assertNull(refined.toString(), refined.rejection()); - assertTrue(refined.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32)); - } - - @Test - public void anUnavoidableOceanBarrierRejectsWithinTheTerrainSampleBudget() { - AtomicInteger calls = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - calls.incrementAndGet(); - return x >= 96 && x <= 160 ? HydrologyTerrainSample.ocean(60, "ocean") - : HydrologyTerrainSample.openLand(70, 0D, "land"); - }; - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List refined = new HydrologyRegionalTerrainRefiner(planner).refine( - List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler); - - assertTrue(refined.isEmpty()); - assertTrue("terrain samples=" + calls.get(), calls.get() <= 65536); - } - - @Test - public void unavailableTerrainIsSampledOnceAndCountsTowardTheBudget() { - Map unavailable = new HashMap<>(); - AtomicInteger calls = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - calls.incrementAndGet(); - if (x >= 96 && x <= 160 && Math.abs(z) < 32) { - unavailable.merge(RiverFootprint.pack(x, z), 1, Integer::sum); - return null; - } - return HydrologyTerrainSample.openLand(70, 0D, "land"); - }; - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List repaired = new HydrologyRegionalTerrainRefiner(planner).refine( - List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler); - - assertFalse(repaired.isEmpty()); - assertFalse(unavailable.isEmpty()); - assertTrue(unavailable.values().stream().allMatch(count -> count == 1)); - assertTrue(calls.get() <= 65536); - } - - @Test - public void anUnsampledRidgeUsesTheLowerValleyWithoutIncreasingIncision() { - HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( - x >= 96 && x <= 160 && Math.abs(z) < 32 ? 94 : 70, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)); - List repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler); - - assertFalse(repaired.isEmpty()); - SurfaceCenterline centerline = SurfaceCenterline.densify(repaired); - for (int station = 0; station < centerline.size(); station++) { - assertEquals(70, terrain.sample(centerline.x()[station], centerline.z()[station]).naturalHeight()); - } - } - - @Test - public void anImpossibleIntermediateAnchorCanBeSkippedWithinTheSameValley() { - HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( - x < 48 || x >= 120 && x <= 136 && Math.abs(z) < 8 ? 90 : 65, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 90, 0), new HydrologyPoint(128, 90, 0), - new HydrologyPoint(256, 65, 0)); - HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner); - List repaired = refiner.refine(guide, "default", false, planner.sampler); - - assertFalse(repaired.isEmpty()); - assertEquals(guide.getFirst(), repaired.getFirst()); - assertEquals(guide.getLast(), repaired.getLast()); - assertFalse(repaired.contains(guide.get(1))); - SurfaceCenterline centerline = SurfaceCenterline.densify(repaired); - HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings); - int head = Integer.MAX_VALUE; - for (int station = 0; station < centerline.size(); station++) { - HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]); - head = Math.min(head, hydraulics.maximumHead(sample)); - assertTrue(hydraulics.minimumHead(sample) <= head); - } - assertEquals(repaired, refiner.refine(guide, "default", false, planner.sampler)); - } - - @Test - public void aLowFineDetourCannotResetTheHeadBeforeTheNextRidge() { - HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( - x >= 96 && x <= 160 && Math.abs(z) < 32 ? 65 : x >= 384 ? 92 : 80, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List guide = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(256, 80, 0), - new HydrologyPoint(512, 92, 0)); - List repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler); - - assertFalse(repaired.isEmpty()); - assertTrue(repaired.stream().anyMatch(point -> Math.abs(point.z()) >= 32)); - SurfaceCenterline centerline = SurfaceCenterline.densify(repaired); - HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings); - int head = Integer.MAX_VALUE; - for (int station = 0; station < centerline.size(); station++) { - HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]); - head = Math.min(head, hydraulics.maximumHead(sample)); - assertTrue(hydraulics.minimumHead(sample) <= head); - } - } - - @Test - public void ridgeRepairRespectsTheLocalIncisionPolicy() { - SurfaceRiverPolicy limited = new SurfaceRiverPolicy("valley", null, null, null, null, null, null, 4); - HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( - x >= 96 && x <= 160 && Math.abs(z) < 32 ? 76 : 70, 0D, "land").withSurfacePolicy(limited); - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List repaired = new HydrologyRegionalTerrainRefiner(planner).refine( - List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler); - - assertFalse(repaired.isEmpty()); - assertTrue(repaired.stream().anyMatch(point -> Math.abs(point.z()) >= 32)); - } - - @Test - public void detourLengthRejectsBeforeDetailedSamplingOfTheRemainingCourse() { - AtomicInteger maximumX = new AtomicInteger(Integer.MIN_VALUE); - HydrologyTerrainSampler terrain = (x, z) -> { - boolean coarseAnchor = Math.floorMod(x + 1, 256) <= 2 && Math.abs(z) <= 1; - if (!coarseAnchor) { - maximumX.accumulateAndGet(x, Math::max); - } - return x >= 96 && x <= 160 && Math.abs(z) < 32 ? HydrologyTerrainSample.ocean(60, "ocean") - : HydrologyTerrainSample.openLand(70, 0D, "land"); - }; - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - ArrayList guide = new ArrayList<>(); - for (int x = 0; x <= planner.settings.routing().maximumRouteLength(); x += 256) { - guide.add(new HydrologyPoint(x, 70, 0)); - } - assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler).isEmpty()); - assertTrue("A rejected first detour must not sample later reaches", maximumX.get() < 512); - } - - @Test - public void anAlreadyOverlongGuideDoesNotSampleTerrain() { - AtomicInteger calls = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - calls.incrementAndGet(); - return HydrologyTerrainSample.openLand(70, 0D, "land"); - }; - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - int before = calls.get(); - assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine( - List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(4096, 70, 0)), "default", false, planner.sampler).isEmpty()); - assertEquals(before, calls.get()); - } - - @Test - public void clearancePreferenceDoesNotExcludeTheOnlyNarrowCorridor() { - HydrologyTerrainSampler terrain = (x, z) -> { - boolean horizontal = Math.abs(z) <= 3 && (x <= 80 || x >= 176); - boolean detour = Math.abs(z - 16) <= 3 && x >= 61 && x <= 195; - boolean vertical = (Math.abs(x - 64) <= 3 || Math.abs(x - 192) <= 3) && z >= -3 && z <= 19; - return land(horizontal || detour || vertical ? "water" : "lava"); - }; - HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); - List repaired = new HydrologyRegionalTerrainRefiner(planner).refine( - List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "water", false, planner.sampler); - - assertFalse(repaired.isEmpty()); - SurfaceCenterline centerline = SurfaceCenterline.densify(repaired); - for (int station = 0; station < centerline.size(); station++) { - assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys()); - } - } - - @Test - public void oceanDetourPreservesTheShippingTwentyDegreeTurnLimit() { - HydrologyTerrainSampler terrain = (x, z) -> x >= 96 && x <= 160 && Math.abs(z) < 32 - ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land"); - HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders( - new HydrologyPlannerSettings.Meanders(64, 12, 0.34D, 0.42D, 0.48D, 1, 20D)), terrain); - HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select( - List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement(); - - assertNull(repaired.toString(), repaired.rejection()); - assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32)); - } - - @Test - public void shortRepairReversalsReceiveEnoughReachToMeetTwentyDegrees() { - HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders( - new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 1, 20D)), - (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land")); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0), - new HydrologyPoint(160, 70, 32), new HydrologyPoint(160, 70, 16), - new HydrologyPoint(192, 70, 16), new HydrologyPoint(192, 70, 128), new HydrologyPoint(512, 70, 128)); - HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement(); - - assertNull(repaired.toString(), repaired.rejection()); - assertEquals(guide.getFirst(), repaired.points().getFirst()); - assertEquals(guide.getLast(), repaired.points().getLast()); - } - - @Test - public void roundedCurveTurnLimitIsMeasuredBeforeBlockQuantization() throws Exception { - HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders( - new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 20D)), - (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land")); - HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0), - new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128)); - Method interpolate = HydrologyRegionalRoute.class.getDeclaredMethod("interpolateLinear", List.class); - Method round = HydrologyRegionalRoute.class.getDeclaredMethod("roundBends", List.class, double.class); - Method resample = HydrologyRegionalRoute.class.getDeclaredMethod("resample", List.class); - Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateRounded", List.class, String.class, boolean.class, HydrologyTerrainSampler.class); - interpolate.setAccessible(true); - round.setAccessible(true); - resample.setAccessible(true); - validate.setAccessible(true); - Object linear = interpolate.invoke(route, guide); - Object rounded = round.invoke(route, linear, 128D); - Object curve = resample.invoke(route, rounded); - HydrologyRegionalRoute.Refinement accepted = (HydrologyRegionalRoute.Refinement) validate.invoke(route, curve, "default", false, planner.sampler); - - assertNull(accepted.toString(), accepted.rejection()); - assertEquals(guide.getFirst(), accepted.points().getFirst()); - assertEquals(guide.getLast(), accepted.points().getLast()); - Object sharper = resample.invoke(route, round.invoke(route, linear, 64D)); - HydrologyRegionalRoute.Refinement rejected = (HydrologyRegionalRoute.Refinement) validate.invoke(route, sharper, "default", false, planner.sampler); - assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, rejected.rejection()); - } - - @Test - public void splineOvershootCannotDiscardAValidNarrowDetour() { - HydrologyTerrainSampler terrain = (x, z) -> { - boolean first = Math.abs(z) <= 8 && x >= -8 && x <= 136; - boolean middle = Math.abs(x - 128) <= 8 && z >= -8 && z <= 136; - boolean last = Math.abs(z - 128) <= 8 && x >= 120 && x <= 264; - return land(first || middle || last ? "water" : "lava"); - }; - HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders( - new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 150D)), terrain); - List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0), - new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128)); - assertEquals(guide, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "water", false, planner.sampler)); - HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "water", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement(); - - assertNull(repaired.toString(), repaired.rejection()); - SurfaceCenterline centerline = SurfaceCenterline.densify(repaired.points()); - for (int station = 0; station < centerline.size(); station++) { - assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys()); - } - } - - private static HydrologyPlannerSettings withMeanders(HydrologyPlannerSettings.Meanders meanders) { - HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1); - HydrologyPlannerSettings.Geometry geometry = settings.geometry(); - return new HydrologyPlannerSettings(settings.seaLevel(), settings.routing(), settings.surface(), settings.hydraulics(), - settings.underground(), settings.outlets(), new HydrologyPlannerSettings.Geometry(meanders, - geometry.surface(), geometry.underground(), geometry.grottos(), geometry.drops()), settings.deepFluids(), - settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves(), settings.surfacePolicyBounds()); - } - - private static HydrologyTerrainSample land(String profile) { - return new HydrologyTerrainSample(70, 0D, false, false, 38, 40, - true, true, true, false, false, false, - 0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D, - "land", "land", "land", "land", "land", "land", List.of(profile), List.of(), - Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT); - } -} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainTest.java new file mode 100644 index 000000000..de30b0f05 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalTerrainTest.java @@ -0,0 +1,192 @@ +package art.arcane.iris.generation.hydrology; + +import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy; +import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline; +import org.junit.Test; + +import java.util.List; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class HydrologyRegionalTerrainTest { + @Test(timeout = 10000) + public void clearingDuringSamplingCannotPublishOldTerrain() throws Exception { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> { + int call = calls.incrementAndGet(); + if (call == 1) { + started.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + } + return HydrologyTerrainSample.openLand(call == 1 ? 70 : 80, 0D, "land"); + }; + HydrologyRegionalTerrain refiner = new HydrologyRegionalTerrain( + new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain)); + try (ExecutorService workers = Executors.newSingleThreadExecutor()) { + Future first = workers.submit(() -> refiner.sample(0, 0)); + try { + assertTrue(started.await(3, TimeUnit.SECONDS)); + refiner.clear(); + assertEquals(80, refiner.sample(0, 0).naturalHeight()); + } finally { + release.countDown(); + } + assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight()); + assertEquals(80, refiner.sample(0, 0).naturalHeight()); + assertEquals(2, calls.get()); + } + } + + @Test(timeout = 10000) + public void independentDiagonalSamplesDoNotWaitForEachOthersTerrain() throws Exception { + CountDownLatch sampling = new CountDownLatch(2); + AtomicInteger calls = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> { + calls.incrementAndGet(); + sampling.countDown(); + try { + assertTrue("Independent terrain samples were serialized", sampling.await(3, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + return HydrologyTerrainSample.openLand(70 + x, 0D, "land"); + }; + HydrologyRegionalTerrain refiner = new HydrologyRegionalTerrain( + new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain)); + try (ExecutorService workers = Executors.newFixedThreadPool(2)) { + Future first = workers.submit(() -> refiner.sample(0, 0)); + Future second = workers.submit(() -> refiner.sample(1, 1)); + assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight()); + assertEquals(71, second.get(5, TimeUnit.SECONDS).naturalHeight()); + assertEquals(first.get(), refiner.sample(0, 0)); + assertEquals(second.get(), refiner.sample(1, 1)); + assertEquals(2, calls.get()); + } + } + + @Test + public void shortRepairedPrefixDoesNotHideALongEnoughGuide() { + HydrologyTerrainSampler terrain = (x, z) -> x >= 406 ? HydrologyTerrainSample.ocean(60, "ocean") + : HydrologyTerrainSample.openLand(x >= 400 ? 60 : 70, 0D, "land"); + HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain); + List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(0, 70, 96), + new HydrologyPoint(128, 70, 96), new HydrologyPoint(256, 70, 96), new HydrologyPoint(256, 70, 0), + new HydrologyPoint(512, 70, 0)); + HydrologyRegionalRoute.Refinement selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, + candidate -> { + double length = 0D; + for (int index = 1; index < candidate.points().size(); index++) { + length += StrictMath.sqrt(candidate.points().get(index - 1).distanceSquared2D(candidate.points().get(index))); + } + assertTrue(length >= planner.settings.routing().regional().minimumLength()); + return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null); + }).refinement(); + + assertNull(selected.toString(), selected.rejection()); + assertEquals(new HydrologyPoint(399, 63, 0), selected.oceanEntry().landward()); + assertEquals(new HydrologyPoint(406, 63, 0), selected.oceanEntry().receiving()); + } + + @Test + public void shortRepairReversalsReceiveEnoughReachToMeetTwentyDegrees() { + HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders( + new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 1, 20D)), + (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land")); + List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0), + new HydrologyPoint(160, 70, 32), new HydrologyPoint(160, 70, 16), + new HydrologyPoint(192, 70, 16), new HydrologyPoint(192, 70, 128), new HydrologyPoint(512, 70, 128)); + HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement(); + + assertNull(repaired.toString(), repaired.rejection()); + assertEquals(guide.getFirst(), repaired.points().getFirst()); + assertEquals(guide.getLast(), repaired.points().getLast()); + } + + @Test + public void roundedCurveTurnLimitIsMeasuredBeforeBlockQuantization() throws Exception { + HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders( + new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 20D)), + (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land")); + HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner); + List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0), + new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128)); + Method interpolate = HydrologyRegionalRoute.class.getDeclaredMethod("interpolateLinear", List.class); + Method round = HydrologyRegionalRoute.class.getDeclaredMethod("roundBends", List.class, double.class); + Method resample = HydrologyRegionalRoute.class.getDeclaredMethod("resample", List.class); + Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateRounded", List.class, String.class, boolean.class, HydrologyTerrainSampler.class); + interpolate.setAccessible(true); + round.setAccessible(true); + resample.setAccessible(true); + validate.setAccessible(true); + Object linear = interpolate.invoke(route, guide); + Object rounded = round.invoke(route, linear, 128D); + Object curve = resample.invoke(route, rounded); + HydrologyRegionalRoute.Refinement accepted = (HydrologyRegionalRoute.Refinement) validate.invoke(route, curve, "default", false, planner.sampler); + + assertNull(accepted.toString(), accepted.rejection()); + assertEquals(guide.getFirst(), accepted.points().getFirst()); + assertEquals(guide.getLast(), accepted.points().getLast()); + Object sharper = resample.invoke(route, round.invoke(route, linear, 64D)); + HydrologyRegionalRoute.Refinement rejected = (HydrologyRegionalRoute.Refinement) validate.invoke(route, sharper, "default", false, planner.sampler); + assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, rejected.rejection()); + } + + @Test + public void splineOvershootCannotDiscardAValidNarrowDetour() { + HydrologyTerrainSampler terrain = (x, z) -> { + boolean first = Math.abs(z) <= 8 && x >= -8 && x <= 136; + boolean middle = Math.abs(x - 128) <= 8 && z >= -8 && z <= 136; + boolean last = Math.abs(z - 128) <= 8 && x >= 120 && x <= 264; + return land(first || middle || last ? "water" : "lava"); + }; + HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders( + new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 150D)), terrain); + List guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0), + new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128)); + HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "water", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement(); + + assertNull(repaired.toString(), repaired.rejection()); + SurfaceCenterline centerline = SurfaceCenterline.densify(repaired.points()); + for (int station = 0; station < centerline.size(); station++) { + assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys()); + } + } + + private static HydrologyPlannerSettings withMeanders(HydrologyPlannerSettings.Meanders meanders) { + HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1); + HydrologyPlannerSettings.Geometry geometry = settings.geometry(); + return new HydrologyPlannerSettings(settings.seaLevel(), settings.routing(), settings.surface(), settings.hydraulics(), + settings.underground(), settings.outlets(), new HydrologyPlannerSettings.Geometry(meanders, + geometry.surface(), geometry.underground(), geometry.grottos(), geometry.drops()), settings.deepFluids(), + settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves(), settings.surfacePolicyBounds()); + } + + private static HydrologyTerrainSample land(String profile) { + return new HydrologyTerrainSample(70, 0D, false, false, 38, 40, + true, true, true, false, false, false, + 0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D, + "land", "land", "land", "land", "land", "land", List.of(profile), List.of(), + Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalWidthSupportTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalWidthSupportTest.java index eb0847722..806749a91 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalWidthSupportTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRegionalWidthSupportTest.java @@ -27,8 +27,6 @@ public class HydrologyRegionalWidthSupportTest { public void aWiderChannelContainsTheCenterDepressionWithoutRaisingLand() throws Exception { HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( x >= 128 && x <= 136 && Math.abs(z) <= 1 ? 65 : x >= 256 && x <= 288 ? 86 : 80, 0D, "land"); - HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS); - assertEquals(80, hydraulics.supportedHead(station(terrain, 80), terrain)); HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain); Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateHydraulics", HydrologyRegionalRoute.Refinement.class, String.class, boolean.class, HydrologyTerrainSampler.class); @@ -48,7 +46,7 @@ public class HydrologyRegionalWidthSupportTest { assertTrue(course.hydraulicallyNonRising()); SurfaceLayerColumn center = null; for (SurfaceLayerColumn column : footprint.columns()) { - assertTrue(column.layer().bedY() <= column.terrain().naturalHeight()); + assertTrue(column.layer().bedY() - column.terrain().naturalHeight() <= SETTINGS.surface().maximumIncision()); assertTrue(column.terrain().naturalHeight() - column.layer().bedY() <= SETTINGS.surface().maximumIncision()); if (column.x() == 132 && column.z() == 0) { center = column; @@ -59,112 +57,8 @@ public class HydrologyRegionalWidthSupportTest { assertEquals(80, center.layer().fluidHeadY()); SurfaceCourseResult narrow = build(terrain, geometry(4)); - assertFalse(narrow.accepted()); - assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, narrow.rejection()); - assertEquals(23, narrow.rejectionDetail()); - } - - @Test - public void anInteriorWidthCanHoldHeadWhenBothAuthoredExtremaFail() { - HydrologyTerrainSampler terrain = narrowBanks(); - assertEquals(65, bankHead(terrain, 4)); - assertEquals(80, bankHead(terrain, 6)); - assertEquals(65, bankHead(terrain, 8)); - AtomicInteger samples = new AtomicInteger(); - HydrologyTerrainSampler bounded = (x, z) -> { - assertTrue("A supported interior width must stop wider probes", Math.abs(z) <= 7); - samples.incrementAndGet(); - return terrain.sample(x, z); - }; - - assertEquals(80, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), bounded)); - assertTrue(samples.get() > 0); - } - - @Test - public void fractionalLocalWidthIsCheckedBeforeUnneededWiderCandidates() { - HydrologyTerrainSampler source = narrowBanks(); - HydrologyTerrainSampler terrain = (x, z) -> withWidthMultiplier(source.sample(x, z), 0.8125D); - assertEquals(80, bankHead(terrain, 6.5D)); - HydrologyTerrainSampler bounded = (x, z) -> { - assertTrue("The local width6.5 already supports the incoming head", Math.abs(z) <= 6); - return terrain.sample(x, z); - }; - - assertEquals(80, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), bounded)); - } - - @Test - public void minimumWidthSupportStaysLazyAndCannotRaiseIncomingHead() { - HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand( - x >= 128 && x <= 136 && z == 0 ? 65 : 80, 0D, "land"); - AtomicInteger expectedSamples = new AtomicInteger(); - bankHead((x, z) -> { - expectedSamples.incrementAndGet(); - return terrain.sample(x, z); - }, 4); - AtomicInteger actualSamples = new AtomicInteger(); - HydrologyTerrainSampler counted = (x, z) -> { - actualSamples.incrementAndGet(); - return terrain.sample(x, z); - }; - - assertEquals(78, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 78), counted)); - assertEquals(expectedSamples.get(), actualSamples.get()); - } - - @Test - public void incompleteWiderPerimetersCannotCertifyAHighHead() { - HydrologyTerrainSampler terrain = narrowBanks(); - HydrologyTerrainSampler incomplete = (x, z) -> x == 133 ? null : terrain.sample(x, z); - - assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), incomplete)); - } - - @Test - public void additionalSamplingAndRadiusStayBoundedWhenWiderPerimetersAreIncomplete() { - HydrologyPlannerSettings settings = wideSettings(); - HydrologyTerrainSample ground = withWidthMultiplier(HydrologyTerrainSample.openLand(65, 0D, "land"), 0.75D); - HydrologyTerrainSample bank = HydrologyTerrainSample.openLand(80, 0D, "land"); - HydrologyTerrainSampler incomplete = (x, z) -> x == 133 ? null : x == 132 && z == 0 ? ground : bank; - AtomicInteger minimumSamples = new AtomicInteger(); - HydrologyTerrainSampler minimum = (x, z) -> { - minimumSamples.incrementAndGet(); - return incomplete.sample(x, z); - }; - SurfaceBankSupport support = new SurfaceBankSupport(settings.surface(), settings.seaLevel()); - SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(132, 0, 1D, 0D, 2D); - SurfaceBankSupport.CrossSection cross = support.crossSection(minimum, station, true); - assertFalse(support.perimeter(minimum, station, cross.minimum()).complete()); - AtomicInteger samples = new AtomicInteger(); - HydrologyTerrainSampler terrain = (x, z) -> { - assertTrue(Math.abs(x - 132) <= 96 && Math.abs(z) <= 96); - samples.incrementAndGet(); - return incomplete.sample(x, z); - }; - - assertEquals(65, new HydrologyRegionalHydraulics(settings).supportedHead( - new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D, ground, 80), terrain)); - assertEquals(minimumSamples.get() + 2048, samples.get()); - } - - private static HydrologyTerrainSampler narrowBanks() { - return (x, z) -> HydrologyTerrainSample.openLand( - x >= 128 && x <= 136 && Math.abs(z) <= 1 || Math.abs(z) >= 7 ? 65 : 80, 0D, "land"); - } - - private static int bankHead(HydrologyTerrainSampler terrain, double width) { - SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel()); - SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(132, 0, 1D, 0D, width / 2D); - SurfaceBankSupport.CrossSection cross = support.crossSection(terrain, station, true); - assertFalse(cross.blocked()); - SurfaceBankSupport.Perimeter perimeter = support.perimeter(terrain, station, cross.minimum()); - assertTrue(perimeter.complete()); - return perimeter.minimum(); - } - - private static HydrologyRegionalHydraulics.HeadStation station(HydrologyTerrainSampler terrain, int incoming) { - return new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D, terrain.sample(132, 0), incoming); + assertTrue(narrow.accepted()); + assertTrue(narrow.segments().stream().allMatch(segment -> segment.upstreamHeadY() >= segment.downstreamHeadY())); } private static HydrologyGeometrySampler geometry(int width) { @@ -180,22 +74,4 @@ public class HydrologyRegionalWidthSupportTest { .build(71L, 91L, "default", PATH, SurfaceTerminal.SINKHOLE, 40, 256); } - private static HydrologyTerrainSample withWidthMultiplier(HydrologyTerrainSample source, double multiplier) { - return new HydrologyTerrainSample(source.naturalHeight(), source.slope(), source.ocean(), source.caveAvailable(), - source.caveFloorY(), source.caveFluidY(), source.transitAllowed(), source.outletAllowed(), source.surfaceSourceAllowed(), - source.surfaceSourceRequired(), source.undergroundSourceAllowed(), source.undergroundSourceRequired(), source.routingCost(), - source.surfaceSourceWeight(), source.undergroundSourceWeight(), multiplier, source.depthMultiplier(), source.incisionMultiplier(), - source.routingMultiplier(), source.bankMultiplier(), source.parentBiomeKey(), source.surfaceBiomeKey(), source.mouthBiomeKey(), - source.shoreBiomeKey(), source.bankBiomeKey(), source.floodedCaveBiomeKey(), source.preferredProfileKeys(), source.surfacePoolKeys(), - source.shoreBiomeWidth(), source.confinesKey(), source.shoreWidth(), source.erosion(), source.surfacePolicy()); - } - - private static HydrologyPlannerSettings wideSettings() { - HydrologyPlannerSettings.Surface surface = SETTINGS.surface(); - return new HydrologyPlannerSettings(SETTINGS.seaLevel(), SETTINGS.routing(), - new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), 256, - surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()), - SETTINGS.hydraulics(), SETTINGS.underground(), SETTINGS.outlets(), SETTINGS.geometry(), SETTINGS.deepFluids(), SETTINGS.surfacePools(), - SETTINGS.widestShoreBiomeWidth(), SETTINGS.seaCaves(), SETTINGS.surfacePolicyBounds()); - } } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteHeightProbeTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteHeightProbeTest.java index 515fad0b5..59f729f0e 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteHeightProbeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteHeightProbeTest.java @@ -10,28 +10,6 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; public class HydrologyRouteHeightProbeTest { - @Test - public void crossDropUsesScalarHeightsIncludingMissingAndExtremeValues() { - HydrologyTerrainSample center = land(87); - HydrologyTerrainSample[] sides = {null, HydrologyTerrainSample.ocean(20, "ocean"), - land(20), land(100), land(Integer.MIN_VALUE), land(Integer.MAX_VALUE)}; - for (HydrologyTerrainSample side : sides) { - Fixture fixture = fixture((x, z) -> side); - int lowest = side == null || side.ocean() ? 87 : Math.min(87, side.naturalHeight()); - double expected = (double) (87 - lowest) * fixture.planner().settings.routing().valleyPreference() - * HydrologyRouteGeometry.CROSS_DROP_WEIGHT; - double actual = fixture.planner().routeGeometry.crossDropPenalty( - new HydrologyPoint(21, 87, -37), new RouteDirection(3D, 4D), center); - assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); - assertEquals(4, fixture.scalarCalls().get()); - assertEquals(0, fixture.basisCalls().get()); - } - Fixture untouched = fixture((x, z) -> land(20)); - assertEquals(0D, untouched.planner().routeGeometry.crossDropPenalty( - new HydrologyPoint(0, 87, 0), new RouteDirection(0D, 0D), center), 0D); - assertEquals(0, untouched.scalarCalls().get()); - } - @Test public void pitChecksRetainMissingAndIntegerDifferenceSemantics() { int threshold = HydrologyPlannerSettings.defaults().hydraulics().waterfallMinimumDrop(); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteTurnCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteTurnCacheTest.java deleted file mode 100644 index 76bdc77ee..000000000 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyRouteTurnCacheTest.java +++ /dev/null @@ -1,254 +0,0 @@ -package art.arcane.iris.generation.hydrology; - -import org.junit.Test; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Random; - -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.CALLS_REAL_METHODS; -import static org.mockito.Mockito.mock; - -public class HydrologyRouteTurnCacheTest { - @Test - public void repeatedRefinementMatchesExhaustiveUncachedRoutes() throws Exception { - Random random = new Random(712319L); - for (int scenario = 0; scenario < 40; scenario++) { - Solver solver = new Solver(); - int count = 4 + random.nextInt(4); - HydrologyPoint[][] points = new HydrologyPoint[count][]; - for (int layer = 0; layer < count; layer++) { - points[layer] = new HydrologyPoint[layer == 0 || layer == count - 1 ? 1 : 1 + random.nextInt(4)]; - for (int candidate = 0; candidate < points[layer].length; candidate++) { - points[layer][candidate] = new HydrologyPoint(layer * 12 + random.nextInt(5), 80, - candidate * 9 + random.nextInt(7)); - } - } - double maximumTurn = scenario % 2 == 0 ? 35D : 115D; - double[][][][] cache = new double[count][][][]; - for (int refinement = 0; refinement < 8; refinement++) { - Fixture fixture = fixture(points, random); - assertArrayEquals("scenario=" + scenario + " refinement=" + refinement, - exhaustive(fixture, maximumTurn), solver.solve(fixture, maximumTurn, cache)); - assertCacheGeometry(fixture, maximumTurn, cache); - } - } - } - - @Test - public void twoLayerRefinementResetsReusedCostsAndKeepsFiniteRoutes() throws Exception { - Random random = new Random(173L); - HydrologyPoint[][] points = { - {new HydrologyPoint(0, 80, 0), new HydrologyPoint(0, 80, 4)}, - {new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 4)} - }; - Solver solver = new Solver(); - double[][][][] cache = new double[2][][][]; - for (int refinement = 0; refinement < 32; refinement++) { - Fixture fixture = fixture(points, random); - assertArrayEquals(exhaustive(fixture, 75D), solver.solve(fixture, 75D, cache)); - } - } - - @Test - public void straightTiesAndDegenerateSegmentsKeepOriginalFirstChoice() throws Exception { - HydrologyPoint[][] points = { - {new HydrologyPoint(0, 80, 0)}, - {new HydrologyPoint(0, 90, 0), new HydrologyPoint(0, 70, 0)}, - {new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 0)}, - {new HydrologyPoint(24, 80, 0)} - }; - Fixture fixture = fixture(points, new Random(2L)); - for (double[] scores : fixture.scores()) { - Arrays.fill(scores, 0D); - } - for (double[] penalties : fixture.penalties()) { - Arrays.fill(penalties, 0D); - } - for (double[][] transitions : fixture.transitions()) { - if (transitions != null) { - for (double[] row : transitions) { - Arrays.fill(row, 0D); - } - } - } - assertArrayEquals(new int[]{0, 0, 0, 0}, new Solver().solve(fixture, 75D, new double[4][][][])); - } - - private static Fixture fixture(HydrologyPoint[][] coordinates, Random random) { - int count = coordinates.length; - HydrologyPoint[][] points = new HydrologyPoint[count][]; - double[][] scores = new double[count][]; - double[][] penalties = new double[count][]; - double[][][] transitions = new double[count][][]; - for (int layer = 0; layer < count; layer++) { - int size = coordinates[layer].length; - points[layer] = new HydrologyPoint[size]; - scores[layer] = new double[size]; - penalties[layer] = new double[size]; - for (int candidate = 0; candidate < size; candidate++) { - HydrologyPoint point = coordinates[layer][candidate]; - points[layer][candidate] = new HydrologyPoint(point.x(), random.nextInt(200), point.z()); - scores[layer][candidate] = random.nextDouble() * 20D; - penalties[layer][candidate] = random.nextDouble() * 10D; - } - if (layer > 0) { - transitions[layer] = new double[coordinates[layer - 1].length][size]; - for (double[] row : transitions[layer]) { - for (int current = 0; current < size; current++) { - row[current] = random.nextInt(8) == 0 ? Double.POSITIVE_INFINITY : random.nextDouble() * 30D; - } - } - } - } - return new Fixture(points, scores, penalties, transitions); - } - - private static int[] exhaustive(Fixture fixture, double maximumTurn) { - ArrayList routes = new ArrayList<>(); - enumerate(fixture.points(), new int[fixture.points().length], 0, routes); - int[] selected = new int[0]; - double selectedCost = Double.POSITIVE_INFINITY; - for (int[] route : routes) { - double cost = fixture.scores()[0][route[0]] + fixture.scores()[1][route[1]] - + fixture.penalties()[1][route[1]] + fixture.transitions()[1][route[0]][route[1]]; - for (int layer = 2; layer < route.length && Double.isFinite(cost); layer++) { - double turn = turn(fixture.points()[layer - 2][route[layer - 2]], - fixture.points()[layer - 1][route[layer - 1]], fixture.points()[layer][route[layer]]); - if (turn > maximumTurn) { - cost = Double.POSITIVE_INFINITY; - break; - } - double excess = Math.max(0D, turn - 8D); - cost = cost + fixture.scores()[layer][route[layer]] + fixture.penalties()[layer][route[layer]] - + fixture.transitions()[layer][route[layer - 1]][route[layer]] - + excess * excess * 12D / maximumTurn; - } - if (cost < selectedCost || Double.isFinite(cost) && cost == selectedCost && precedes(route, selected)) { - selectedCost = cost; - selected = route; - } - } - return selected; - } - - private static boolean precedes(int[] candidate, int[] selected) { - int last = candidate.length - 1; - if (candidate[last - 1] != selected[last - 1]) { - return candidate[last - 1] < selected[last - 1]; - } - if (candidate[last] != selected[last]) { - return candidate[last] < selected[last]; - } - for (int index = last - 2; index >= 0; index--) { - if (candidate[index] != selected[index]) { - return candidate[index] < selected[index]; - } - } - return false; - } - - private static void enumerate(HydrologyPoint[][] points, int[] route, int layer, List routes) { - if (layer == route.length) { - routes.add(route.clone()); - return; - } - for (int candidate = 0; candidate < points[layer].length; candidate++) { - route[layer] = candidate; - enumerate(points, route, layer + 1, routes); - } - } - - private static void assertCacheGeometry(Fixture fixture, double maximumTurn, double[][][][] cache) { - int populated = 0; - for (int layer = 2; layer < cache.length; layer++) { - for (int previous = 0; previous < cache[layer].length; previous++) { - for (int current = 0; current < cache[layer][previous].length; current++) { - double[] costs = cache[layer][previous][current]; - if (costs == null) { - continue; - } - for (int before = 0; before < costs.length; before++) { - if (Double.isNaN(costs[before])) { - continue; - } - double turn = turn(fixture.points()[layer - 2][before], - fixture.points()[layer - 1][previous], fixture.points()[layer][current]); - double excess = Math.max(0D, turn - 8D); - double expected = turn > maximumTurn ? Double.POSITIVE_INFINITY : excess * excess * 12D / maximumTurn; - assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(costs[before])); - populated++; - } - } - } - } - assertTrue(populated > 0); - } - - private static double turn(HydrologyPoint before, HydrologyPoint previous, HydrologyPoint current) { - double incomingX = previous.x() - before.x(); - double incomingZ = previous.z() - before.z(); - double outgoingX = current.x() - previous.x(); - double outgoingZ = current.z() - previous.z(); - double incomingLength = StrictMath.hypot(incomingX, incomingZ); - double outgoingLength = StrictMath.hypot(outgoingX, outgoingZ); - if (incomingLength == 0D || outgoingLength == 0D) { - return 0D; - } - return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, - (incomingX * outgoingX + incomingZ * outgoingZ) / (incomingLength * outgoingLength))))); - } - - private record Fixture(HydrologyPoint[][] points, double[][] scores, double[][] penalties, double[][][] transitions) { - } - - private static final class Solver { - private final HydrologyRouteGeometry geometry = mock(HydrologyRouteGeometry.class, CALLS_REAL_METHODS); - private final Constructor candidateConstructor; - private final Object tangent; - private final Method solve; - private final Method indices; - private HydrologyRouteGeometry.CurvatureWorkspace workspace; - - private Solver() throws Exception { - Class direction = RouteDirection.class; - Constructor directionConstructor = direction.getDeclaredConstructor(double.class, double.class); - directionConstructor.setAccessible(true); - tangent = directionConstructor.newInstance(1D, 0D); - Class candidate = RouteCandidate.class; - candidateConstructor = candidate.getDeclaredConstructor(HydrologyPoint.class, double.class, double.class, - double.class, double.class, double.class, direction, boolean.class); - candidateConstructor.setAccessible(true); - solve = HydrologyRouteGeometry.class.getDeclaredMethod("selectCurvatureAwareTerrainRoute", List.class, - double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class, - HydrologyRouteGeometry.CurvatureWorkspace.class); - solve.setAccessible(true); - indices = solve.getReturnType().getDeclaredMethod("indices"); - indices.setAccessible(true); - } - - private int[] solve(Fixture fixture, double maximumTurn, double[][][][] cache) throws Exception { - ArrayList> layers = new ArrayList<>(); - for (int layer = 0; layer < fixture.points().length; layer++) { - ArrayList candidates = new ArrayList<>(); - for (int candidate = 0; candidate < fixture.points()[layer].length; candidate++) { - HydrologyPoint point = fixture.points()[layer][candidate]; - candidates.add(candidateConstructor.newInstance(point, point.x(), point.z(), 0D, - fixture.scores()[layer][candidate], 0D, tangent, true)); - } - layers.add(candidates); - } - if (workspace == null) { - workspace = new HydrologyRouteGeometry.CurvatureWorkspace(layers); - } - return (int[]) indices.invoke(solve.invoke(geometry, layers, 12D, maximumTurn, 12D, - fixture.penalties(), fixture.transitions(), cache, workspace)); - } - } -} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java index b6dad9021..a4a5510da 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySourceSpacingTest.java @@ -259,7 +259,7 @@ public class HydrologySourceSpacingTest { candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L, HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true))); } - return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false); + return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false, null); } private static HydrologyTerrainSample land(int naturalHeight, double weight) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyStyledGeometryTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyStyledGeometryTest.java index eda4b016f..99436c948 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyStyledGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyStyledGeometryTest.java @@ -64,6 +64,7 @@ public class HydrologyStyledGeometryTest { Set.of( HydrologyGeometrySampler.Field.SURFACE_WIDTH, HydrologyGeometrySampler.Field.SURFACE_DEPTH, + HydrologyGeometrySampler.Field.SURFACE_BLEND_WIDTH, HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, HydrologyGeometrySampler.Field.UNDERGROUND_WIDTH, HydrologyGeometrySampler.Field.UNDERGROUND_DEPTH, diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java deleted file mode 100644 index 75cfa74d6..000000000 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceBankProbeTest.java +++ /dev/null @@ -1,145 +0,0 @@ -package art.arcane.iris.generation.hydrology; - -import org.junit.Test; - -import java.util.NavigableSet; -import java.util.TreeSet; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -public class HydrologySurfaceBankProbeTest { - @Test - public void raisingShoreWidthWidensTheBankProbeBandInsteadOfSlidingIt() { - NavigableSet narrow = probedBankOffsets(1.5D); - NavigableSet wide = probedBankOffsets(3D); - int innerRing = innerRing(); - - assertTrue("narrow band " + narrow, narrow.contains(innerRing)); - assertTrue("wide band " + wide, wide.contains(innerRing)); - assertTrue("band " + wide + " must still cover " + narrow, wide.containsAll(narrow)); - assertTrue("band " + wide + " must reach past " + narrow, wide.size() > narrow.size()); - } - - @Test - public void theInnerProbeRingDoesNotMoveWithShoreWidth() { - int innerRing = innerRing(); - for (double shoreWidth : new double[]{0D, 1.5D, 3D, 16D}) { - NavigableSet band = probedBankOffsets(shoreWidth); - assertEquals("band " + band + " at shoreWidth " + shoreWidth, - innerRing, band.first().intValue()); - } - } - - @Test - public void bankHeightQueriesPreserveExactPenaltiesWithoutBankPolicySamples() { - HydrologyTerrainSampler terrain = (x, z) -> x + z == 7 ? null - : x < -3 ? HydrologyTerrainSample.ocean(60, "ocean") - : HydrologyTerrainSample.openLand(80 + Math.floorMod(x * 3 + z * 7, 31), 0D, "land"); - AtomicInteger basisCalls = new AtomicInteger(); - AtomicInteger heightCalls = new AtomicInteger(); - HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() { - @Override - public HydrologyTerrainSample sampleBasis(int x, int z) { - basisCalls.incrementAndGet(); - return terrain.sample(x, z); - } - - @Override - public double sampleLandHeight(int x, int z) { - heightCalls.incrementAndGet(); - HydrologyTerrainSample sampled = terrain.sample(x, z); - return sampled == null || sampled.ocean() ? Double.NaN : sampled.naturalHeight(); - } - - @Override - public HydrologyTerrainSample[] sampleGrid(GridRequest request) { - throw new AssertionError("Bank probes do not require a routing grid"); - } - - @Override - public NaturalClassification classifyNatural(int x, int z) { - throw new AssertionError("Bank probes only require land heights"); - } - }; - for (double angle : new double[]{0D, 0.37D, 0.7853981633974483D}) { - HydrologyPlannerSettings settings = settings(3D); - HydrologyPlanner reference = new HydrologyPlanner(91L, settings, terrain); - HydrologyPlanner optimized = new HydrologyPlanner(91L, settings, terrain, natural, - HydrologyGeometrySampler.deterministic(terrain), -4096, - footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096)); - HydrologyPoint point = new HydrologyPoint(0, 80, 0); - RouteCandidate candidate = new RouteCandidate(point, 0D, 0D, 0D, 0D, 0D, - new RouteDirection(StrictMath.cos(angle), StrictMath.sin(angle)), true); - basisCalls.set(0); - heightCalls.set(0); - double expected = reference.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate); - double actual = optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate); - assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual)); - assertEquals(1, basisCalls.get()); - assertTrue(heightCalls.get() > 0); - optimized.planningSamples.set(new HydrologyPlanner.PlanningSamples()); - try { - assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D); - int firstPassHeightCalls = heightCalls.get(); - assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D); - assertEquals(firstPassHeightCalls, heightCalls.get()); - } finally { - optimized.planningSamples.remove(); - } - } - } - - /** One block outside half the nominal maximum width - see HydrologySurfaceCoursePlanner. */ - private int innerRing() { - return (int) StrictMath.ceil( - HydrologyPlannerSettings.defaults().surface().maximumWidth() / 2D) + 1; - } - - private NavigableSet probedBankOffsets(double shoreWidth) { - HydrologyPoint center = new HydrologyPoint(0, 84, 0); - NavigableSet probed = new TreeSet<>(); - HydrologyTerrainSampler sampler = (x, z) -> { - if (x == center.x()) { - probed.add(Math.abs(z - center.z())); - } - return HydrologyTerrainSample.openLand(84, 0D, "land"); - }; - HydrologyPlanner planner = new HydrologyPlanner(91L, settings(shoreWidth), sampler); - planner.planningSamples.set(new HydrologyPlanner.PlanningSamples()); - - planner.surfaceCourses.surfaceRouteCandidateBankPenalty(new RouteCandidate( - center, center.x(), center.z(), 0D, 0D, 0D, new RouteDirection(1D, 0D), true)); - - probed.remove(0); - return probed; - } - - private HydrologyPlannerSettings settings(double shoreWidth) { - HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); - HydrologyPlannerSettings.Surface surface = base.surface(); - return new HydrologyPlannerSettings( - base.seaLevel(), - base.routing(), - new HydrologyPlannerSettings.Surface( - surface.enabled(), - surface.sources(), - surface.minimumWidth(), - surface.maximumWidth(), - surface.minimumDepth(), - surface.maximumDepth(), - surface.maximumIncision(), - shoreWidth, - surface.banks()), - base.hydraulics(), - base.underground(), - base.outlets(), - base.geometry(), - base.deepFluids(), - base.surfacePools(), - base.widestShoreBiomeWidth(), - base.seaCaves(), - base.surfacePolicyBounds()); - } -} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRasterTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRasterTest.java index d19665e3b..d7f3edd1a 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRasterTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceDropRasterTest.java @@ -51,6 +51,75 @@ public final class HydrologySurfaceDropRasterTest { assertThrows(UnsupportedOperationException.class, () -> raster.columns().clear()); } + @Test + public void diagonalDropCoordinatesResolveExactlyInBothDirectionsFromTheOrigin() { + for (int origin : new int[]{-128, 128}) { + HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL, + 90, 80, 4, 3, true, true, + List.of(new HydrologyPoint(origin, 90, origin), new HydrologyPoint(origin + 12, 80, origin + 12)), + HydraulicChannelProfile.uniform(4, 3)); + RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(3L), OptionalLong.of(4L), + "water", 1, List.of(), List.of(fall)); + HydrologySurfaceDropRaster raster = HydrologySurfaceDropRaster.compile(SETTINGS, TERRAIN, GEOMETRY, course); + + assertFalse(raster.columns().isEmpty()); + for (HydrologyColumnSample column : raster.columns()) { + assertSame(column, raster.sample(column.x(), column.z()).orElseThrow()); + } + assertTrue(raster.connects(origin, origin, 90)); + assertTrue(raster.connects(origin + 12, origin + 12, 80)); + assertTrue(raster.sample(origin + 512, origin + 512).isEmpty()); + assertFalse(raster.connects(origin + 512, origin + 512, 80)); + } + } + + @Test + public void receivingDropBedsUseInletIncisionOnlyAtAuthorizedCourseStations() { + HydrologyTerrainSampler terrain = (x, z) -> x >= 200 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(107, 0D, "parent"); + for (int start : new int[]{50, 100, 150}) { + int end = start == 100 ? 110 : start + 1; + HydraulicSegment approach = new HydraulicSegment(1L, 1L, HydrologyFeatureType.SURFACE_POOL, + 122, 122, 4, 3, false, false, + List.of(new HydrologyPoint(0, 122, 0), new HydrologyPoint(start, 122, 0)), + HydraulicChannelProfile.uniform(4, 3)); + HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL, + 122, 78, 4, 3, true, true, + List.of(new HydrologyPoint(start, 122, 0), new HydrologyPoint(end, 78, 0)), + HydraulicChannelProfile.uniform(4, 3)); + HydraulicSegment downstream = new HydraulicSegment(3L, 1L, HydrologyFeatureType.CASCADE, + 78, SETTINGS.seaLevel(), 4, 3, false, false, + List.of(new HydrologyPoint(end, 78, 0), new HydrologyPoint(199, SETTINGS.seaLevel(), 0)), + HydraulicChannelProfile.uniform(4, 3)); + HydraulicSegment mouth = new HydraulicSegment(4L, 1L, HydrologyFeatureType.MOUTH, + SETTINGS.seaLevel(), SETTINGS.seaLevel(), 4, 3, false, false, + List.of(new HydrologyPoint(199, SETTINGS.seaLevel(), 0), new HydrologyPoint(200, SETTINGS.seaLevel(), 0)), + HydraulicChannelProfile.uniform(4, 3)); + RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(3L), OptionalLong.of(4L), + "water", 1, List.of(), List.of(approach, fall, downstream, mouth)); + HydrologySurfaceDropRaster raster = HydrologySurfaceDropRaster.compile(SETTINGS, terrain, GEOMETRY, course); + + assertEquals(start != 50, raster.connects(end, 0, 78)); + if (start != 50) { + HydrologyColumnSample receiver = raster.sample(end, 0).orElseThrow(); + HydrologyColumnLayer water = receiver.layers().stream() + .filter(layer -> layer.channel() && layer.fluidOwned()).findFirst().orElseThrow(); + int cut = receiver.naturalHeight() - water.bedY(); + assertTrue(cut > SETTINGS.surface().maximumIncision()); + assertTrue(cut <= SETTINGS.surface().banks().inlet().maximumIncision()); + } + if (start == 100) { + assertFalse(raster.connects(102, 0, 78)); + assertTrue(raster.connects(108, 0, 78)); + } + SurfaceBounds bounds = new SurfaceBounds(start - 2, -8, end + 2, 8); + HydrologySurfaceDropRaster bounded = HydrologySurfaceDropRaster.compile(SETTINGS, terrain, GEOMETRY, course, bounds); + assertEquals(raster.columns().stream().filter(column -> bounds.contains(column.x(), column.z())).toList(), + bounded.columns()); + } + } + @Test public void surfaceDropsDoNotOwnSubmergedGroundOrRaiseWaterOnSeaLevelLand() { RiverCourse course = course(true); @@ -97,7 +166,7 @@ public final class HydrologySurfaceDropRasterTest { HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN, GEOMETRY); HydrologyPlannerSettings.Excavation limits = SETTINGS.surface().banks().erosion().excavation(); FootprintLayerShape shape = new FootprintLayerShape(2, 2D, 40D, 79, 90, 90, - false, false, false, false, true); + false, false, false, false, true, false); HydrologyFeatureRef feature = new HydrologyFeatureRef(5L, segment.type(), course.id(), segment.id(), 0, 90, 0, 1, 0, false); HydrologyTerrainSample highTerrain = HydrologyTerrainSample.openLand(160, 0D, "parent"); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceRouteGeometryTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceRouteGeometryTest.java index cfbfa3a18..d5cf083c6 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceRouteGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologySurfaceRouteGeometryTest.java @@ -5,54 +5,9 @@ import org.junit.Test; import java.util.List; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.clearInvocations; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; public class HydrologySurfaceRouteGeometryTest { - @Test - public void lazyTransitionGeometryIsReusedUntilResolvedHeightsChange() { - HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), - (x, z) -> { - throw new AssertionError("Geometry lookup must not sample terrain."); - }); - RouteCandidate previous = candidate(0D, 0D); - RouteCandidate current = spy(candidate(4D, 0D)); - HydrologyRouteGeometry.CurvatureWorkspace workspace = new HydrologyRouteGeometry.CurvatureWorkspace( - List.of(List.of(previous), List.of(current))); - double expected = planner.routePaths.routeTransitionGeometryCost(previous, current, 4D); - clearInvocations(current); - - for (int iteration = 0; iteration < 8; iteration++) { - double actual = planner.routeGeometry.resolvedTransitionCost( - previous, current, 4D, Double.NaN, workspace, 1, 0, 0); - assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); - } - verify(current, times(1)).offset(); - - RouteCandidate raised = spy(new RouteCandidate(new HydrologyPoint(4, 91, 0), - current.continuousX(), current.continuousZ(), current.offset(), current.localScore(), - current.terrainScore(), current.tangent(), true)); - double raisedExpected = planner.routePaths.routeTransitionGeometryCost(previous, raised, 4D); - assertTrue(raisedExpected > expected); - clearInvocations(raised); - for (int iteration = 0; iteration < 8; iteration++) { - double actual = planner.routeGeometry.resolvedTransitionCost( - previous, raised, 4D, Double.NaN, workspace, 1, 0, 0); - assertEquals(Double.doubleToRawLongBits(raisedExpected), Double.doubleToRawLongBits(actual)); - } - verify(raised, times(1)).offset(); - assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits( - planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D, -0D, - workspace, 1, 0, 0))); - assertEquals(Double.POSITIVE_INFINITY, - planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D, - Double.POSITIVE_INFINITY, workspace, 1, 0, 0), 0D); - } - @Test public void surfaceAnchorKeepsTheCoarseValleyWhenSmoothGroundIsMuchHigher() { HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand( @@ -85,7 +40,7 @@ public class HydrologySurfaceRouteGeometryTest { } @Test - public void steepSurfaceEdgesCanDetourAroundAContainedTerrainPit() { + public void undergroundEdgesRejectAContainedTerrainPit() { HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand( 128 - Math.floorDiv(x, 2) - (x >= 30 && x <= 34 && Math.abs(z) <= 1 ? 14 : 0), 0D, "land"); @@ -94,47 +49,7 @@ public class HydrologySurfaceRouteGeometryTest { HydrologyPoint end = new HydrologyPoint(64, 96, 0); HydrologyPoint continuation = new HydrologyPoint(96, 80, 0); - List cave = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, false); - List surface = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, true); - + List cave = planner.routeGeometry.refineUndergroundEdge(1L, 2L, start, end, continuation, 4); assertTrue(cave.isEmpty()); - assertFalse(surface.isEmpty()); - assertTrue(surface.stream().anyMatch(point -> Math.abs(point.z()) > 1)); - assertTrue(surface.stream().allMatch(point -> Math.abs(point.z()) <= 16)); - assertTrue(planner.routePaths.traversableRoute(surface)); - assertFalse(planner.routePaths.containsTerrainPit(surface)); - } - - @Test - public void curvatureUsesTheContinuousCurveBeforeBlockRounding() { - HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), - (x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land")); - RouteCandidate first = candidate(0D, 0D); - RouteCandidate middle = candidate(3.6D, 0.49D); - RouteCandidate last = candidate(7D, 2.1D); - - assertTrue(HydrologyRoutePath.routeTurnDegrees(first.point(), middle.point(), last.point()) > 20D); - assertTrue(planner.routeGeometry.continuousTurnDegrees(first, middle, last) < 20D); - RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute( - List.of(List.of(first), List.of(middle), List.of(last)), 4D, 20D, 2.5D); - - assertEquals(3, route.length); - } - - @Test - public void continuousCurvatureStillRejectsASharpTurn() { - HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), - (x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land")); - RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute( - List.of(List.of(candidate(0D, 0D)), List.of(candidate(4D, 0D)), List.of(candidate(4D, 4D))), - 4D, 20D, 2.5D); - - assertEquals(0, route.length); - } - - private RouteCandidate candidate(double x, double z) { - return new RouteCandidate( - new HydrologyPoint((int) StrictMath.round(x), 84, (int) StrictMath.round(z)), - x, z, 0D, 0D, 0D, new RouteDirection(1D, 0D), true); } } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java index d99f6ecb3..a8d7fbeb8 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/HydrologyTileCacheTest.java @@ -6,6 +6,9 @@ import art.arcane.iris.testsupport.Await; import art.arcane.iris.generation.concurrent.MultiBurst; import art.arcane.volmlib.util.cache.CacheKey; import org.junit.Test; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import java.nio.file.Path; import java.lang.reflect.Field; import java.time.Duration; @@ -50,6 +53,55 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; public class HydrologyTileCacheTest { + @Rule + public TemporaryFolder preparedFolder = new TemporaryFolder(); + + @Test + public void preparedPlansRestoreFarFromOriginWithoutTerrainPlanning() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(emptySettings()); + HydrologyTileCache.SharedCacheScope scope = sharedScope(); + HydrologyTileKey key = new HydrologyTileKey(501, -703); + HydrologyTile tile = preparedTile(key, scope); + Path root = preparedFolder.newFolder().toPath(); + new PreparedHydrologyTileStore(root, scope, 64).save(tile); + try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4)) { + cache.enableSharedCache(scope, root); + cache.setTerrainPreparation(() -> { throw new AssertionError("Restoration must not sample terrain"); }); + HydrologyTile restored = cache.get(key); + assertEquals(tile.key(), restored.key()); + assertEquals(tile.footprint(), restored.footprint()); + verify(planner, never()).plan(any(HydrologyTileKey.class)); + verify(planner).reuseResolvedTile(restored); + } + } + + @Test + public void tileDemandAutomaticallyPersistsTheCompletedPlan() throws Exception { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + when(planner.settings()).thenReturn(emptySettings()); + HydrologyTileCache.SharedCacheScope scope = sharedScope(); + HydrologyTileKey key = new HydrologyTileKey(125, -141); + HydrologyTile tile = preparedTile(key, scope); + when(planner.plan(key)).thenReturn(tile); + Path root = preparedFolder.newFolder().toPath(); + try (HydrologyTileCache cache = new HydrologyTileCache(planner, 64)) { + cache.enableSharedCache(scope, root); + assertSame(tile, cache.get(key)); + verify(planner).plan(key); + } + HydrologyTile persisted = new PreparedHydrologyTileStore(root, scope, 64).load(key).orElseThrow(); + assertEquals(tile.key(), persisted.key()); + assertEquals(tile.worldSeed(), persisted.worldSeed()); + assertEquals(tile.settingsFingerprint(), persisted.settingsFingerprint()); + assertEquals(tile.footprint(), persisted.footprint()); + } + + private static HydrologyTile preparedTile(HydrologyTileKey key, HydrologyTileCache.SharedCacheScope scope) { + return new HydrologyTile(key, scope.worldSeed(), scope.settingsFingerprint(), 64, + List.of(), List.of(), List.of(), List.of(), Set.of(), List.of(), List.of(), RiverFootprint.empty()); + } + @Test public void weightedTileCapacityStopsSpeculationWithoutBlockingDemand() { HydrologyPlanner planner = mock(HydrologyPlanner.class); @@ -400,7 +452,7 @@ public class HydrologyTileCacheTest { monitor.set(queue.get(cache)); if (pregeneration) { - cache.preparePregeneration(262144, 262144); + cache.preparePregeneration(pregenArea(262144, 262144)); } else { cache.prefetchArea(261120, 261120, 263168, 263168, 262144, 262144); } @@ -409,8 +461,12 @@ public class HydrologyTileCacheTest { assertEquals(prefetchRectangle(254, 257, 254, 257), new HashSet<>(planned.subList(0, 16))); assertEquals(List.of(new HydrologyTileKey(255, 255), new HydrologyTileKey(256, 255), new HydrologyTileKey(257, 255)), planned.subList(1, 4)); - assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned.subList(16, planned.size()))); - assertEquals(22, planned.size()); + if (pregeneration) { + assertEquals(16, planned.size()); + } else { + assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned.subList(16, planned.size()))); + assertEquals(22, planned.size()); + } } } @@ -447,7 +503,7 @@ public class HydrologyTileCacheTest { Runnable demand = queued.poll(5, TimeUnit.SECONDS); assertNotNull(demand); - cache.preparePregeneration(262144, 262144); + cache.preparePregeneration(pregenArea(262144, 262144)); assertFalse(active.isDone()); assertFalse(demanded.isDone()); @@ -485,9 +541,9 @@ public class HydrologyTileCacheTest { LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add); cache.prefetchArea(0, 0, 1023, 0, 0, 0); - cache.preparePregeneration(262144, 262144); - cache.preparePregeneration(263168, 262144); - cache.preparePregeneration(263168, 262144); + cache.preparePregeneration(pregenArea(262144, 262144)); + cache.preparePregeneration(pregenArea(263168, 262144)); + cache.preparePregeneration(pregenArea(263168, 262144)); drainPrefetchTasks(queued); Set expected = new HashSet<>(prefetchRectangle(255, 258, 254, 257)); @@ -496,7 +552,7 @@ public class HydrologyTileCacheTest { assertEquals(17, planned.size()); cache.clear(); planned.clear(); - cache.preparePregeneration(263168, 262144); + cache.preparePregeneration(pregenArea(263168, 262144)); drainPrefetchTasks(queued); assertEquals(prefetchRectangle(255, 258, 254, 257), new HashSet<>(planned)); assertEquals(16, planned.size()); @@ -516,13 +572,113 @@ public class HydrologyTileCacheTest { LinkedBlockingQueue queued = new LinkedBlockingQueue<>(); HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add); cache.prefetchArea(0, 0, 1023, 0, 0, 0); - assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(Integer.MAX_VALUE, 0)); - assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(0, Integer.MIN_VALUE)); + assertThrows(IllegalArgumentException.class, () -> cache.preparePregeneration( + new HydrologyTileCache.PregenerationArea(0, 0, 1, -1, 2, 1))); + assertThrows(ArithmeticException.class, () -> cache.preparePregeneration( + new HydrologyTileCache.PregenerationArea(0, 0, Long.MIN_VALUE, -1, 1, 1))); drainPrefetchTasks(queued); assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned)); assertEquals(6, planned.size()); } + @Test + public void pregenBoundsLimitSpeculationWithoutLimitingRequestedChunks() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + ArrayList planned = new ArrayList<>(); + doAnswer(invocation -> { + planned.add(invocation.getArgument(0)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run); + try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration( + new HydrologyTileCache.PregenerationArea(0, 0, -1536, -1536, 2047, 2047))) { + cache.prefetchArea(-4096, -4096, 4095, 4095, 0, 0); + assertEquals(prefetchRectangle(-2, 2, -2, 2), new HashSet<>(planned)); + planned.clear(); + HydrologyTileKey demanded = new HydrologyTileKey(8, 8); + assertSame(tile, cache.get(demanded)); + assertEquals(List.of(demanded), planned); + planned.clear(); + assertFalse(cache.isPlanned(10_240, 10_240)); + assertTrue(cache.isPlanned(10_240, 10_240)); + assertEquals(prefetchRectangle(9, 10, 9, 10), new HashSet<>(planned)); + planned.clear(); + cache.prepareChunkColumns(-10_240, -10_240); + assertEquals(prefetchRectangle(-11, -10, -11, -10), new HashSet<>(planned)); + } + planned.clear(); + cache.prepareChunkColumns(20_480, 20_480); + assertEquals(prefetchRectangle(18, 21, 18, 21), new HashSet<>(planned)); + cache.close(); + } + + @Test + public void pregenScopesRestoreStudioPrefetchAndIgnoreSupersededClose() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + ArrayList planned = new ArrayList<>(); + doAnswer(invocation -> { + planned.add(invocation.getArgument(0)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run); + cache.setNeighbourPrefetchEnabled(false); + HydrologyTileCache.PregenerationScope first = cache.preparePregeneration(pregenArea(0, 0)); + HydrologyTileCache.PregenerationScope second = cache.preparePregeneration(pregenArea(262144, 262144)); + first.close(); + planned.clear(); + cache.prefetchArea(16384, 16384, 16384, 16384, 16384, 16384); + assertTrue(planned.isEmpty()); + second.close(); + cache.prepareChunkColumns(16384, 16384); + assertEquals(prefetchRectangle(15, 16, 15, 16), new HashSet<>(planned)); + HydrologyTileCache.PregenerationScope cleared = cache.preparePregeneration(pregenArea(0, 0)); + cache.clear(); + cleared.close(); + planned.clear(); + cache.prepareChunkColumns(32768, 32768); + assertEquals(prefetchRectangle(31, 32, 31, 32), new HashSet<>(planned)); + HydrologyTileCache.PregenerationScope closed = cache.preparePregeneration(pregenArea(0, 0)); + cache.close(); + closed.close(); + cache.prefetchArea(0, 0, 0, 0, 0, 0); + assertThrows(CancellationException.class, () -> cache.preparePregeneration(pregenArea(0, 0))); + } + + @Test + public void largePregenBoundsKeepInitialWarmupLocal() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + HydrologyPlannerSettings settings = stalePrefetchSettings(); + when(planner.settings()).thenReturn(settings); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + ArrayList planned = new ArrayList<>(); + doAnswer(invocation -> { + planned.add(invocation.getArgument(0)); + return tile; + }).when(planner).plan(any(HydrologyTileKey.class)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run); + try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration( + new HydrologyTileCache.PregenerationArea(0, 0, + Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE))) { + assertEquals(prefetchRectangle(-2, 1, -2, 1), new HashSet<>(planned)); + assertEquals(16, planned.size()); + } + cache.close(); + } + + private static HydrologyTileCache.PregenerationArea pregenArea(int x, int z) { + return new HydrologyTileCache.PregenerationArea(x, z, (long) x - 1024, (long) z - 1024, + (long) x + 1024, (long) z + 1024); + } + private static HydrologyPlannerSettings stalePrefetchSettings() { HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class); when(settings.routing()).thenReturn(new HydrologyPlannerSettings.Routing( @@ -1100,7 +1256,7 @@ public class HydrologyTileCacheTest { order.clear(); cache.setNeighbourPrefetchEnabled(false); - cache.preparePregeneration(0, 0); + cache.preparePregeneration(pregenArea(0, 0)); assertEquals(new HydrologyTileKey(1, -1), order.getFirst()); assertTrue(order.contains(new HydrologyTileKey(1, 0))); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStoreTest.java similarity index 89% rename from core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java rename to core/src/test/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStoreTest.java index b368f0082..9d8ea4cb1 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/StudioHydrologyTileStoreTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/PreparedHydrologyTileStoreTest.java @@ -35,14 +35,14 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -public class StudioHydrologyTileStoreTest { +public class PreparedHydrologyTileStoreTest { @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test public void roundTripsValidatedEntryTile() throws Exception { HydrologyTile original = tile(); - StudioHydrologyTileStore store = store(); + PreparedHydrologyTileStore store = store(); store.save(original); HydrologyTile restored = store.load(original.key()).orElseThrow(); @@ -93,7 +93,7 @@ public class StudioHydrologyTileStoreTest { HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 91L, 17L, 64, List.of(), List.of(), List.of(), List.of(course), Set.of(), List.of(plan), List.of(), new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column))); - StudioHydrologyTileStore store = store(); + PreparedHydrologyTileStore store = store(); store.save(original); HydrologyTile restored = store.load(original.key()).orElseThrow(); assertEquals(original, restored); @@ -118,7 +118,7 @@ public class StudioHydrologyTileStoreTest { @Test public void entryRequiresRegionalOwnershipMetadata() throws Exception { HydrologyTile original = tile(); - StudioHydrologyTileStore store = store(); + PreparedHydrologyTileStore store = store(); store.save(original); Path file = store.file(original.key()); JsonObject persisted; @@ -136,7 +136,7 @@ public class StudioHydrologyTileStoreTest { @Test public void corruptEntryFallsBackToCacheMiss() throws Exception { HydrologyTile original = tile(); - StudioHydrologyTileStore store = store(); + PreparedHydrologyTileStore store = store(); store.save(original); Path file = store.file(original.key()); Files.writeString(file, "invalid", StandardCharsets.UTF_8); @@ -149,15 +149,28 @@ public class StudioHydrologyTileStoreTest { Path root = temporaryFolder.newFolder().toPath(); HydrologyTileCache.SharedCacheScope scope = scope(); HydrologyTile original = tile(); - StudioHydrologyTileStore matching = new StudioHydrologyTileStore(root, scope, 64); + PreparedHydrologyTileStore matching = new PreparedHydrologyTileStore(root, scope, 64); matching.save(original); - StudioHydrologyTileStore mismatched = new StudioHydrologyTileStore(root, scope, 128); + PreparedHydrologyTileStore mismatched = new PreparedHydrologyTileStore(root, scope, 128); assertTrue(mismatched.load(original.key()).isEmpty()); } - private StudioHydrologyTileStore store() throws Exception { - return new StudioHydrologyTileStore( + @Test + public void copiedTileCannotCrossRuntimeIdentityScopes() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + PreparedHydrologyTileStore source = new PreparedHydrologyTileStore(root, scope(), 64); + PreparedHydrologyTileStore target = new PreparedHydrologyTileStore(root, + new HydrologyTileCache.SharedCacheScope("another-kernel", 91L, 384, "overworld", 17L), 64); + HydrologyTile tile = tile(); + source.save(tile); + Files.createDirectories(target.file(tile.key()).getParent()); + Files.copy(source.file(tile.key()), target.file(tile.key())); + assertTrue(target.load(tile.key()).isEmpty()); + } + + private PreparedHydrologyTileStore store() throws Exception { + return new PreparedHydrologyTileStore( temporaryFolder.newFolder().toPath(), scope(), 64 diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java index 1f973a91e..1aa73922f 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ErosionFieldCompilerTest.java @@ -13,9 +13,11 @@ import art.arcane.iris.generation.hydrology.IrisRiverBedProfile; import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle; import org.junit.Test; +import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.IntBinaryOperator; import static org.junit.Assert.assertEquals; @@ -31,6 +33,89 @@ public class ErosionFieldCompilerTest { default -> request.minimum(); }; + @Test + public void geometricExclusionAvoidsTerrainSamplesWithoutChangingAsymmetricBanks() { + HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(); + AtomicInteger referenceSamples = new AtomicInteger(); + AtomicInteger boundedSamples = new AtomicInteger(); + HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(z > 4 ? 100 : 80, 0D, "land"); + ErosionField reference = rasterWithShoreBound(surface, (x, z) -> { + referenceSamples.incrementAndGet(); + return terrain.sample(x, z); + }, 128D, null); + ErosionField bounded = rasterWithShoreBound(surface, (x, z) -> { + boundedSamples.incrementAndGet(); + return terrain.sample(x, z); + }, surface.shoreWidth(), null); + + assertEqualFields(reference, bounded); + assertNull(bounded.rejection()); + assertTrue("sample counts " + boundedSamples.get() + "/" + referenceSamples.get(), + boundedSamples.get() < referenceSamples.get() * 0.8D); + assertNotNull(bounded.column(0, 18)); + assertNull(bounded.column(0, -18)); + } + + @Test + public void geometricExclusionRetainsPolicyBandsAtExactAndAdjacentThresholds() { + HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(); + for (double width : new double[]{Math.nextDown(1.75D), 1.75D, Math.nextUp(1.75D), 12D}) { + HydrologyTerrainSampler terrain = (x, z) -> land(80, z >= 0 ? width : 0D) + .withShoreWidth(z < 0 ? width : surface.shoreWidth()); + double bound = Math.max(surface.shoreWidth(), width); + ErosionField reference = rasterWithShoreBound(surface, terrain, 128D, null); + ErosionField complete = rasterWithShoreBound(surface, terrain, bound, null); + assertEqualFields(reference, complete); + for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15), + new SurfaceBounds(0, -16, 15, 15))) { + ErosionField bounded = rasterWithShoreBound(surface, terrain, bound, bounds); + for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) { + for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) { + assertEquals("policy width " + width + " at " + x + "," + z, + complete.column(x, z), bounded.column(x, z)); + } + } + } + if (width >= 1.75D) { + assertEquals(SurfaceRole.SHORE, complete.column(0, 5).role()); + } + if (width == 12D) { + assertEquals(SurfaceRole.SHORE, complete.column(0, 15).role()); + assertEquals(SurfaceRole.BANK, complete.column(0, -15).role()); + } + } + } + + private static ErosionField rasterWithShoreBound(HydrologyPlannerSettings.Surface surface, + HydrologyTerrainSampler terrain, double bound, + SurfaceBounds bounds) { + HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings(SEA_LEVEL, defaults.routing(), surface, + defaults.hydraulics(), defaults.underground(), defaults.outlets(), defaults.geometry(), + defaults.deepFluids(), defaults.surfacePools(), bound, defaults.seaCaves(), defaults.surfacePolicyBounds()); + SurfaceCenterline centerline = SurfaceCenterline.densify(List.of( + new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 80, 0))); + double[] widths = new double[centerline.size()]; + double[] depths = new double[centerline.size()]; + double[] multipliers = new double[centerline.size()]; + int[] heads = new int[centerline.size()]; + Arrays.fill(widths, 3D); + Arrays.fill(depths, 2D); + Arrays.fill(multipliers, 1D); + Arrays.fill(heads, 80); + return new ErosionFieldCompiler(settings, terrain).compile(42L, centerline, + new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, heads.length), + SurfaceTerminal.SINKHOLE, 0, noPonds(), SurfaceRasterContext.bounded(bounds)); + } + + private static void assertEqualFields(ErosionField expected, ErosionField actual) { + assertEquals(expected.columns(), actual.columns()); + assertEquals(expected.rejection(), actual.rejection()); + assertEquals(expected.rejectionDetail(), actual.rejectionDetail()); + assertEquals(expected.uncontainedWetCells(), actual.uncontainedWetCells()); + assertEquals(expected.bankExcavation(), actual.bankExcavation()); + } + @Test public void mouthCutsDrySeaLevelSillBeforeReachingOceanWater() { HydrologyTerrainSampler coast = (int x, int z) -> x >= 100 @@ -81,7 +166,7 @@ public class ErosionFieldCompilerTest { SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(new HydrologyPoint(-150, 0, 0), new HydrologyPoint(150, 0, 0))); ChannelProfile channel = new ChannelProfileBuilder(surface, hillside, CONSTANT_GEOMETRY).build(centerline, "water", false); ValleyProfile valley = new ValleyProfileSolver(surface, hillside, SEA_LEVEL, 64).solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40); - ErosionFieldCompiler compiler = new ErosionFieldCompiler(surface, hillside, SEA_LEVEL); + ErosionFieldCompiler compiler = new ErosionFieldCompiler(rasterSettings(surface), hillside); ErosionField complete = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8); SurfaceBounds bounds = new SurfaceBounds(-16, -16, 15, 15); ErosionField bounded = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8, surface.banks().ponds(), SurfaceRasterContext.bounded(bounds)); @@ -93,6 +178,44 @@ public class ErosionFieldCompilerTest { } } + @Test + public void boundedRasterRaisesBanksFromHigherWaterAcrossTheChunkEdge() { + HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(); + HydrologyTerrainSampler lowGround = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land"); + SurfaceCenterline centerline = SurfaceCenterline.densify( + List.of(new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 78, 0))); + int count = centerline.size(); + double[] widths = new double[count]; + double[] depths = new double[count]; + double[] multipliers = new double[count]; + int[] heads = new int[count]; + Arrays.fill(widths, 3D); + Arrays.fill(depths, 2D); + Arrays.fill(multipliers, 1D); + for (int station = 0; station < count; station++) { + heads[station] = centerline.x()[station] < 0 ? 80 : 78; + } + ChannelProfile channel = new ChannelProfile(widths, depths, multipliers); + ValleyProfile valley = ValleyProfile.fromHeads(heads, count); + ErosionFieldCompiler compiler = new ErosionFieldCompiler(rasterSettings(surface), lowGround); + ErosionField complete = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 0); + + assertNull(complete.rejection()); + assertEquals(80, complete.column(0, 4).height()); + for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15), + new SurfaceBounds(0, -16, 15, 15), new SurfaceBounds(0, 4, 15, 19))) { + ErosionField bounded = compiler.compile(42L, centerline, channel, valley, + SurfaceTerminal.SINKHOLE, 0, noPonds(), SurfaceRasterContext.bounded(bounds)); + assertNull(bounded.rejection()); + assertEquals(0, bounded.uncontainedWetCells()); + for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) { + for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) { + assertEquals("bounded column " + x + "," + z, complete.column(x, z), bounded.column(x, z)); + } + } + } + } + @Test public void flatTerrainHoldsTheWaterFlushWithTheBankAndContainedByDefault() { Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); @@ -320,6 +443,12 @@ public class ErosionFieldCompilerTest { surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), banks); } + private static HydrologyPlannerSettings.Surface withMaximumIncision(HydrologyPlannerSettings.Surface surface, int maximumIncision) { + return new HydrologyPlannerSettings.Surface( + surface.enabled(), surface.sources(), surface.minimumWidth(), surface.maximumWidth(), + surface.minimumDepth(), surface.maximumDepth(), maximumIncision, surface.shoreWidth(), surface.banks()); + } + @Test public void bankColumnsBeyondTheBlendAreNotWritten() { Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); @@ -333,6 +462,14 @@ public class ErosionFieldCompilerTest { } } + private static void assertBoundedBankFill(Compiled compiled, int maximumFill) { + for (SurfaceColumn column : compiled.field().columns().values()) { + int fill = column.height() - column.terrain().naturalHeight(); + assertTrue(column.x() + "," + column.z() + " fill " + fill, + fill <= (column.role() == SurfaceRole.CHANNEL ? 0 : maximumFill)); + } + } + private static void assertChannelContained(Compiled compiled) { for (SurfaceColumn column : compiled.field().columns().values()) { if (column.role() != SurfaceRole.CHANNEL || column.apron()) { @@ -376,7 +513,7 @@ public class ErosionFieldCompilerTest { ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64) .solve(centerline, channel, terminal, terminalHead); assertNull(valley.rejection()); - ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL) + ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), sampler) .compile(1234L, centerline, channel, valley, terminal, 8); return new Compiled(field, valley); } @@ -624,7 +761,7 @@ public class ErosionFieldCompilerTest { .solve(centerline, channel, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); assertNull(valley.rejection()); - ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL) + ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), sampler) .compile(99L, centerline, channel, valley, SurfaceTerminal.OCEAN_MOUTH, 3); int firstApron = Integer.MAX_VALUE; @@ -754,10 +891,12 @@ public class ErosionFieldCompilerTest { assertEquals(0, compiled.field().uncontainedWetCells()); assertChannelContained(compiled); - assertNoWriteAboveNatural(compiled); + assertBoundedBankFill(compiled, 8); assertEquals(80, compiled.field().column(0, 0).headY()); SurfaceColumn nearRim = compiled.field().column(0, 4); assertEquals(SurfaceRole.CHANNEL, nearRim.role()); + assertTrue(compiled.field().columns().values().stream() + .anyMatch(column -> column.height() > column.terrain().naturalHeight())); for (SurfaceColumn column : compiled.field().columns().values()) { if (column.role() == SurfaceRole.CHANNEL) { assertTrue(Math.abs(column.z()) <= 7); @@ -805,19 +944,21 @@ public class ErosionFieldCompilerTest { HydrologyTerrainSampler shelf = (int x, int z) -> HydrologyTerrainSample.openLand(80 + Math.min(16, Math.max(0, Math.abs(z) - 4) * 4), 0D, "land"); Compiled terraced = compile( - zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D, + withMaximumIncision(zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D, IrisRiverBlendStyle.TERRACED, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D, - new HydrologyPlannerSettings.Excavation(64, 64, 8192))), + new HydrologyPlannerSettings.Excavation(64, 64, 8192))), 32), shelf, 300, SurfaceTerminal.SINKHOLE, 40); + assertNull(terraced.field().rejection()); Set steps = bankHeights(terraced, 150, 9, 40); assertTrue(steps.toString(), steps.size() >= 2 && steps.size() <= 4); assertHolds(terraced); Compiled cliff = compile( - zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D, + withMaximumIncision(zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D, IrisRiverBlendStyle.CLIFF, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D, - new HydrologyPlannerSettings.Excavation(64, 64, 8192))), + new HydrologyPlannerSettings.Excavation(64, 64, 8192))), 32), shelf, 300, SurfaceTerminal.SINKHOLE, 40); + assertNull(cliff.field().rejection()); Set bench = bankHeights(cliff, 150, 9, 40); assertEquals(bench.toString(), 2, bench.size()); assertTrue(bench.contains(80)); @@ -873,7 +1014,43 @@ public class ErosionFieldCompilerTest { assertTrue(column.height() <= hillside.sample(150, z).naturalHeight()); previous = column.height(); } - assertHolds(rising); + assertEquals(0, rising.field().uncontainedWetCells()); + assertChannelContained(rising); + assertBoundedBankFill(rising, 8); + assertEquals(92, rising.field().column(150, -7).height()); + assertEquals(90, rising.field().column(150, -7).terrain().naturalHeight()); + } + + @Test + public void zeroBankDepthBudgetLeavesLowBanksUnfilledAndUncontained() { + HydrologyPlannerSettings.Erosion erosion = new HydrologyPlannerSettings.Erosion( + true, 12, 0.45D, 1D, 0.5D, IrisRiverBlendStyle.SMOOTH, 4, 0.5D, + IrisRiverBedProfile.BOWL, 0D, 0D, new HydrologyPlannerSettings.Excavation(0, 16, 256)); + HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(0, erosion); + HydrologyTerrainSampler lowGround = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land"); + SurfaceCenterline centerline = SurfaceCenterline.densify( + List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(16, 80, 0))); + int count = centerline.size(); + double[] widths = new double[count]; + double[] depths = new double[count]; + double[] multipliers = new double[count]; + int[] heads = new int[count]; + Arrays.fill(widths, 3D); + Arrays.fill(depths, 2D); + Arrays.fill(multipliers, 1D); + Arrays.fill(heads, 80); + ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), lowGround).compile(42L, + centerline, new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, count), + SurfaceTerminal.SINKHOLE, 0); + + assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, field.rejection()); + assertTrue(field.uncontainedWetCells() > 0); + assertEquals(77, field.column(8, 4).height()); + for (SurfaceColumn column : field.columns().values()) { + if (column.role() != SurfaceRole.CHANNEL) { + assertEquals(column.terrain().naturalHeight(), column.height()); + } + } } @Test @@ -987,4 +1164,11 @@ public class ErosionFieldCompilerTest { assertEquals(SurfaceRole.SHORE, bare.field().column(150, 4).role()); assertHolds(bare); } + private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) { + HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults(); + return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(), + defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(), + defaults.surfacePools(), 12D, defaults.seaCaves(), defaults.surfacePolicyBounds()); + } + } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/RiverFirstTerrainTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/RiverFirstTerrainTest.java new file mode 100644 index 000000000..ba570ed0a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/RiverFirstTerrainTest.java @@ -0,0 +1,120 @@ +package art.arcane.iris.generation.hydrology.surface; + +import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.generation.hydrology.HydrologyPoint; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class RiverFirstTerrainTest { + @Test + public void riverBuildsContainedBanksAroundALowNaturalPatch() { + HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land"); + ErosionField field = compile(sampler, null); + + assertNull(field.rejection()); + assertEquals(0, field.uncontainedWetCells()); + SurfaceColumn bed = field.column(0, 0); + assertNotNull(bed); + assertEquals(bed.terrain().naturalHeight(), bed.height()); + assertTrue(bed.height() < bed.headY()); + int raisedBanks = 0; + for (SurfaceColumn column : field.columns().values()) { + if (column.role() != SurfaceRole.CHANNEL && column.height() > column.terrain().naturalHeight()) { + raisedBanks++; + assertTrue(column.height() - column.terrain().naturalHeight() <= 8); + } + } + assertTrue(raisedBanks > 0); + } + + @Test + public void riverRejectsRequiredFillBeyondTheTerrainBudget() { + ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(69, 0D, "land"), null); + assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, field.rejection()); + } + + @Test + public void unavailableBankCannotCertifyTheWetPerimeter() { + HydrologyTerrainSample ground = HydrologyTerrainSample.openLand(77, 0D, "land"); + ErosionField complete = compile((x, z) -> ground, null); + assertNull(complete.rejection()); + SurfaceColumn bank = complete.columns().values().stream() + .filter(column -> column.x() == 0 && column.role() != SurfaceRole.CHANNEL + && complete.column(0, column.z() - 1) != null + && complete.column(0, column.z() - 1).role() == SurfaceRole.CHANNEL) + .findFirst().orElseThrow(); + + ErosionField unavailable = compile((x, z) -> x == bank.x() && z == bank.z() ? null : ground, null); + + assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, unavailable.rejection()); + assertTrue(unavailable.uncontainedWetCells() > 0); + } + + @Test + public void fineChannelIncisionLimitIsCheckedBeforePublication() { + ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(x == 0 && z == 1 ? 110 : 82, 0D, "land"), null); + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, field.rejection()); + } + + @Test + public void channelEdgesRetainWaterWithinTheLocalIncisionLimit() { + ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(x == 0 && z == 1 ? 89 : 82, 0D, "land"), null); + assertNull(field.rejection()); + SurfaceColumn column = field.column(0, 1); + assertEquals(SurfaceRole.CHANNEL, column.role()); + assertEquals(79, column.height()); + assertEquals(80, column.headY()); + } + + @Test + public void adjacentChunkRastersRetainIdenticalRaisedTerrain() { + HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(77 + Math.floorMod(x + z, 3), 0D, "land"); + ErosionField complete = compile(sampler, null); + assertNull(complete.rejection()); + for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15), + new SurfaceBounds(0, -16, 15, 15))) { + ErosionField bounded = compile(sampler, bounds); + assertNull(bounded.rejection()); + for (SurfaceColumn column : complete.columns().values()) { + if (bounds.contains(column.x(), column.z())) { + assertEquals(column, bounded.column(column.x(), column.z())); + } + } + } + } + + private static ErosionField compile(HydrologyTerrainSampler sampler, SurfaceBounds bounds) { + HydrologyPlannerSettings.Surface settings = HydrologyPlannerSettings.defaults().surface(); + SurfaceCenterline centerline = SurfaceCenterline.densify( + List.of(new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 80, 0))); + int count = centerline.size(); + double[] widths = new double[count]; + double[] depths = new double[count]; + double[] multipliers = new double[count]; + int[] heads = new int[count]; + Arrays.fill(widths, 3D); + Arrays.fill(depths, 2D); + Arrays.fill(multipliers, 1D); + Arrays.fill(heads, 80); + return new ErosionFieldCompiler(rasterSettings(settings), sampler).compile(17L, centerline, + new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, count), + SurfaceTerminal.SINKHOLE, 0, HydrologyPlannerSettings.Ponds.none(), SurfaceRasterContext.bounded(bounds)); + } + private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) { + HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults(); + return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(), + defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(), + defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds()); + } + +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/RiverProfileSolverTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/RiverProfileSolverTest.java new file mode 100644 index 000000000..7bf10b6ed --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/RiverProfileSolverTest.java @@ -0,0 +1,112 @@ +package art.arcane.iris.generation.hydrology.surface; + +import art.arcane.iris.generation.hydrology.HydraulicSegment; +import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.generation.hydrology.HydrologyPoint; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSample; +import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class RiverProfileSolverTest { + private static final HydrologyGeometrySampler GEOMETRY = request -> switch (request.field()) { + case SURFACE_WIDTH -> 6; + case SURFACE_DEPTH -> 2; + default -> request.minimum(); + }; + + @Test + public void detailedTerrainDoesNotReplacePlannedRiverElevations() { + List path = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0)); + for (int natural : List.of(78, 83, 84)) { + SurfaceCourseResult result = build(path, (x, z) -> HydrologyTerrainSample.openLand(natural, 0D, "land")); + assertNull(result.rejection()); + assertEquals(80, result.lastHead()); + for (HydraulicSegment segment : result.segments()) { + assertEquals(80, segment.upstreamHeadY()); + assertEquals(80, segment.downstreamHeadY()); + } + } + } + + @Test + public void interpolatedSharedHeadsRemainNonRisingAcrossStations() { + List path = List.of(new HydrologyPoint(-96, 88, -16), + new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 72, 16)); + SurfaceCourseResult result = build(path, + (x, z) -> HydrologyTerrainSample.openLand(82 - Math.floorDiv(x, 12), 0D, "land")); + assertNull(result.rejection()); + int previous = Integer.MAX_VALUE; + for (HydraulicSegment segment : result.segments()) { + for (HydrologyPoint point : segment.centerline()) { + assertTrue(point.y() <= previous); + previous = point.y(); + } + } + assertEquals(72, result.lastHead()); + } + + @Test + public void plannedProfileChecksTheCenterWithoutSearchingBankRings() { + List path = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0)); + SurfaceCenterline centerline = SurfaceCenterline.densify(path); + double[] widths = new double[centerline.size()]; + double[] depths = new double[centerline.size()]; + double[] banks = new double[centerline.size()]; + Arrays.fill(widths, 3D); + Arrays.fill(depths, 2D); + Arrays.fill(banks, 1D); + AtomicInteger samples = new AtomicInteger(); + HydrologyTerrainSampler sampler = (x, z) -> { + assertEquals(0, z); + samples.incrementAndGet(); + return HydrologyTerrainSample.openLand(82, 0D, "land"); + }; + ValleyProfile profile = new RiverProfileSolver( + new RiverProfileSolver.Options(HydrologyPlannerSettings.defaults().surface(), 60), sampler) + .solve(path, centerline, new ChannelProfile(widths, depths, banks), SurfaceTerminal.SINKHOLE, 40, 32); + assertNull(profile.rejection()); + assertEquals(centerline.size(), samples.get()); + } + + @Test + public void aFineCliffKeepsTheSourceHeadAndRaisesOnlyTheRequiredApproach() { + HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(x < 32 ? 102 : 86, 0D, "land"); + SurfaceCourseResult result = build(List.of(new HydrologyPoint(0, 100, 0), + new HydrologyPoint(64, 84, 0)), sampler); + + assertNull(result.rejection()); + assertEquals(100, result.segments().getFirst().upstreamHeadY()); + assertEquals(84, result.lastHead()); + int previous = 100; + for (HydraulicSegment segment : result.segments()) { + for (HydrologyPoint point : segment.centerline()) { + assertTrue(point.y() <= previous); + assertTrue(sampler.sample(point.x(), point.z()).naturalHeight() - point.y() + 2 + <= HydrologyPlannerSettings.defaults().surface().maximumIncision()); + previous = point.y(); + } + } + } + + @Test + public void anUncuttableFineTerrainRidgeStillRejectsTheCourse() { + SurfaceCourseResult result = build(List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0)), + (x, z) -> HydrologyTerrainSample.openLand(x == 48 ? 110 : 82, 0D, "land")); + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, result.rejection()); + } + + private static SurfaceCourseResult build(List path, HydrologyTerrainSampler sampler) { + return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, GEOMETRY, 60) + .build(7L, 19L, "default", path, SurfaceTerminal.SINKHOLE, 40, 32); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceBendProfileTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceBendProfileTest.java index 9a1c80e29..64416f133 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceBendProfileTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceBendProfileTest.java @@ -180,7 +180,7 @@ public class SurfaceBendProfileTest { ValleyProfile valley = new ValleyProfileSolver(surface, FLAT, 60, 32) .solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40); assertTrue(valley.accepted()); - return new ErosionFieldCompiler(surface, FLAT, 60).compile(42L, centerline, channel, valley, + return new ErosionFieldCompiler(rasterSettings(surface), FLAT).compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8, HydrologyPlannerSettings.Ponds.none(), context); } @@ -191,4 +191,11 @@ public class SurfaceBendProfileTest { HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true, HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.none())); } + private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) { + HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults(); + return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(), + defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(), + defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds()); + } + } diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilderTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilderTest.java index afb4507ee..7ad66bf73 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilderTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceCourseBuilderTest.java @@ -13,6 +13,7 @@ import org.junit.Test; import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -34,7 +35,8 @@ public class SurfaceCourseBuilderTest { 7L, COURSE_ID, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(140, 0, 0), new HydrologyPoint(280, 0, 0)), + List.of(new HydrologyPoint(0, 110, 0), new HydrologyPoint(140, 87, 0), + new HydrologyPoint(260, 67, 0), new HydrologyPoint(280, SEA_LEVEL, 0)), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64 @@ -72,7 +74,7 @@ public class SurfaceCourseBuilderTest { 7L, COURSE_ID, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), + List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(200, 80, 0)), SurfaceTerminal.SINKHOLE, 40, 64 @@ -85,6 +87,41 @@ public class SurfaceCourseBuilderTest { assertEquals(201, result.segments().getFirst().centerline().size()); } + @Test + public void coastalDropKeepsTheUpstreamGradedReachExposed() { + 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(), + defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none())); + HydrologyTerrainSampler sampler = (x, z) -> x >= 40 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(100 - x, 0D, "land"); + SurfaceCourseBuilder builder = new SurfaceCourseBuilder(surface, sampler, CONSTANT_GEOMETRY, SEA_LEVEL); + List path = List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(40, SEA_LEVEL, 0)); + SurfaceCourseResult result = builder.build(7L, COURSE_ID, "water", path, + SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 16); + + assertNull(result.rejection()); + assertEquals(2, result.segments().size()); + HydraulicSegment approach = result.segments().getFirst(); + HydraulicSegment drop = result.segments().getLast(); + assertEquals(HydrologyFeatureType.CASCADE, approach.type()); + assertFalse(approach.fallingFluid()); + assertEquals(100, approach.upstreamHeadY()); + assertEquals(61, approach.downstreamHeadY()); + assertEquals(new HydrologyPoint(20, 80, 0), approach.centerline().get(20)); + assertEquals(HydrologyFeatureType.RIFFLE, drop.type()); + assertTrue(drop.fallingFluid()); + assertTrue(drop.receivingPool()); + assertEquals(List.of(new HydrologyPoint(39, 61, 0), new HydrologyPoint(40, 60, 0)), drop.centerline()); + assertEquals(approach.channelProfile().widthAt(39), drop.channelProfile().widthAt(0), 0D); + assertEquals(approach.channelProfile().depthAt(39), drop.channelProfile().depthAt(0), 0D); + assertTrue(approach.id() != drop.id()); + assertEquals(result, builder.build(7L, COURSE_ID, "water", path, + SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 16)); + } + @Test public void cliffProducesAWaterfallSegment() { HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land"); @@ -92,7 +129,8 @@ public class SurfaceCourseBuilderTest { 7L, COURSE_ID, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), + List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0), + new HydrologyPoint(100, 90, 0), new HydrologyPoint(200, 90, 0)), SurfaceTerminal.SINKHOLE, 40, 64 @@ -106,14 +144,32 @@ public class SurfaceCourseBuilderTest { assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(2).type()); } + @Test + public void cliffIntoTheOceanUsesFallingFluidInsteadOfAnInlandSeaLevelChannel() { + HydrologyTerrainSampler sampler = (x, z) -> x >= 100 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(100, 0D, "land"); + SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", + List.of(new HydrologyPoint(0, 97, 0), new HydrologyPoint(99, 97, 0), + new HydrologyPoint(100, SEA_LEVEL, 0)), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64); + + assertNull(result.rejection()); + HydraulicSegment fall = result.segments().getLast(); + assertEquals(HydrologyFeatureType.WATERFALL, fall.type()); + assertTrue(fall.fallingFluid()); + assertTrue(fall.receivingPool()); + assertEquals(SEA_LEVEL, fall.downstreamHeadY()); + assertEquals(100, fall.end().x()); + } + @Test public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() { HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land"); SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64); + List.of(new HydrologyPoint(0, 120, 0), new HydrologyPoint(200, 100, 0)), SurfaceTerminal.SINKHOLE, 40, 64); HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land"); SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64); + List.of(new HydrologyPoint(0, 300, 0), new HydrologyPoint(200, 100, 0)), SurfaceTerminal.SINKHOLE, 40, 64); assertNull(gentleResult.rejection()); assertNull("detail=" + steepResult.rejectionDetail(), steepResult.rejection()); @@ -138,7 +194,7 @@ public class SurfaceCourseBuilderTest { 7L, COURSE_ID, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), + List.of(new HydrologyPoint(0, 50, 0), new HydrologyPoint(200, 50, 0)), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64 @@ -153,7 +209,8 @@ public class SurfaceCourseBuilderTest { 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)); + List path = List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(239, 61, 0), + new HydrologyPoint(240, SEA_LEVEL, 0), new HydrologyPoint(241, SEA_LEVEL, 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, 64); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java index 2aec84967..6aebc75dc 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/SurfaceFootprintCompilerTest.java @@ -2,6 +2,7 @@ package art.arcane.iris.generation.hydrology.surface; import art.arcane.iris.generation.hydrology.HydraulicChannelProfile; import art.arcane.iris.generation.hydrology.HydraulicSegment; +import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection; import art.arcane.iris.generation.hydrology.HydrologyColumnLayer; import art.arcane.iris.generation.hydrology.HydrologyColumnSample; import art.arcane.iris.generation.hydrology.HydrologyFeatureType; @@ -131,6 +132,33 @@ public class SurfaceFootprintCompilerTest { assertNull(column(footprint, 160, 12)); } + @Test + public void plannedCoastalWaterfallRetainsAConnectedOceanReceiverWithoutCuttingTheCliffToSeaLevel() { + int seaLevel = HydrologyPlannerSettings.defaults().seaLevel(); + HydrologyTerrainSampler sampler = (x, z) -> x >= 100 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(100, 0D, "land"); + SurfaceCourseResult result = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), + sampler, CONSTANT_GEOMETRY, seaLevel).build(7L, 1L, "water", + List.of(new HydrologyPoint(0, 97, 0), new HydrologyPoint(99, 97, 0), + new HydrologyPoint(100, seaLevel, 0)), SurfaceTerminal.OCEAN_MOUTH, seaLevel, 64); + assertNull(result.rejection()); + ArrayList segments = new ArrayList<>(result.segments()); + segments.add(segment(77L, HydrologyFeatureType.MOUTH, seaLevel, seaLevel, + List.of(result.pathEnd()))); + SurfaceFootprint footprint = compiler(sampler).compile(course(segments)); + + assertNull(footprint.rejection()); + assertEquals(0, footprint.uncontainedWetCells()); + assertTrue(footprint.columns().stream().anyMatch(column -> column.layer().channel() + && column.layer().fluidOwned() && column.layer().fluidHeadY() == 97)); + for (SurfaceLayerColumn column : footprint.columns()) { + if (!column.apron() && column.role() == SurfaceRole.CHANNEL) { + assertTrue(column.layer().bedY() >= 90); + } + } + } + @Test public void mouthOverTheOceanPublishesOnlyANonOwningApron() { HydrologyTerrainSampler sampler = (int x, int z) -> x >= 100 @@ -212,13 +240,101 @@ public class SurfaceFootprintCompilerTest { } } + @Test + public void inletAllowanceSurvivesAFallWithoutReachingOrdinaryUpstreamTerrain() { + HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Banks banks = defaults.surface().banks(); + HydrologyPlannerSettings.Erosion erosion = banks.erosion(); + HydrologyPlannerSettings.Erosion expandedVolume = new HydrologyPlannerSettings.Erosion( + erosion.enabled(), erosion.smoothingRadius(), erosion.thalwegFraction(), erosion.blendCurve(), + erosion.bedNoise(), erosion.style(), erosion.terraceSteps(), erosion.cliffFraction(), erosion.bedProfile(), + erosion.shoreRise(), erosion.blendBaseWidth(), new HydrologyPlannerSettings.Excavation(8, 16, 512)); + HydrologyPlannerSettings.Banks expandedBanks = new HydrologyPlannerSettings.Banks( + banks.sink(), banks.blendSlope(), banks.minimumBlendWidth(), banks.maximumBlendWidth(), banks.roughness(), + banks.roughnessWavelength(), banks.cascadeRun(), banks.waterfallMinimumDrop(), banks.mouthFlareRatio(), + banks.inlet(), banks.springWidthRatio(), banks.springLength(), banks.exposeCutStrata(), expandedVolume, + banks.ponds(), banks.channel(), banks.flow()); + HydrologyPlannerSettings.Surface originalSurface = defaults.surface(); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + originalSurface.enabled(), originalSurface.sources(), originalSurface.minimumWidth(), originalSurface.maximumWidth(), + originalSurface.minimumDepth(), originalSurface.maximumDepth(), originalSurface.maximumIncision(), + originalSurface.shoreWidth(), expandedBanks); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings(defaults.seaLevel(), defaults.routing(), surface, + defaults.hydraulics(), defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(), + defaults.surfacePools(), defaults.widestShoreBiomeWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds()); + int seaLevel = settings.seaLevel(); + int upstreamHead = seaLevel + 22; + int downstreamHead = seaLevel + 12; + HydrologyTerrainSampler sampler = (x, z) -> x >= 200 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(x >= 120 && x <= 135 ? 100 : x < 151 ? 90 : 80, 0D, "land"); + HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL, + upstreamHead, downstreamHead, 6, 3, true, true, + List.of(new HydrologyPoint(150, upstreamHead, 0), new HydrologyPoint(151, downstreamHead, 0)), + HydraulicChannelProfile.uniform(6, 3)); + ArrayList ramp = new ArrayList<>(); + for (int x = 180; x <= 192; x++) { + ramp.add(new HydrologyPoint(x, downstreamHead - (x - 180), 0)); + } + RiverCourse course = course(List.of( + segment(1L, HydrologyFeatureType.SURFACE_POOL, upstreamHead, upstreamHead, points(0, 149, upstreamHead)), + fall, + segment(3L, HydrologyFeatureType.SURFACE_POOL, downstreamHead, downstreamHead, points(151, 180, downstreamHead)), + segment(4L, HydrologyFeatureType.CASCADE, downstreamHead, seaLevel, ramp), + segment(5L, HydrologyFeatureType.SURFACE_POOL, seaLevel, seaLevel, points(192, 199, seaLevel)), + segment(6L, HydrologyFeatureType.MOUTH, seaLevel, seaLevel, points(199, 201, seaLevel)))); + SurfaceFootprintCompiler compiler = new SurfaceFootprintCompiler(settings, sampler, CONSTANT_GEOMETRY); + SurfaceFootprint full = compiler.compile(course); + + assertNull("detail=" + full.rejectionDetail(), full.rejection()); + assertEquals(0, full.uncontainedWetCells()); + for (int x : new int[]{130, 195}) { + SurfaceLayerColumn channel = column(full, x, 0); + assertNotNull(channel); + assertTrue(channel.layer().channel()); + assertEquals(x == 130 ? upstreamHead : seaLevel, channel.layer().fluidHeadY()); + int cut = channel.terrain().naturalHeight() - channel.layer().bedY(); + assertTrue(cut > settings.surface().maximumIncision()); + assertTrue(cut <= settings.surface().banks().inlet().maximumIncision()); + } + for (SurfaceBounds bounds : List.of(new SurfaceBounds(112, -8, 159, 8), new SurfaceBounds(144, -8, 199, 8))) { + SurfaceFootprint bounded = compiler.compile(course, bounds); + assertNull(bounded.rejection()); + assertEquals(full.columns().stream().filter(value -> bounds.contains(value.x(), value.z())).toList(), + bounded.columns()); + } + HydrologyTerrainSampler upstreamRidge = (x, z) -> x == 60 + ? HydrologyTerrainSample.openLand(100, 0D, "land") : sampler.sample(x, z); + SurfaceFootprint rejected = new SurfaceFootprintCompiler(settings, upstreamRidge, CONSTANT_GEOMETRY).compile(course); + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, rejected.rejection()); + } + + @Test + public void standaloneMouthKeepsItsInletBudgetWithinTheDryCourseFraction() { + int seaLevel = HydrologyPlannerSettings.defaults().seaLevel(); + for (int coast : new int[]{8, 100}) { + int ridgeX = Math.max(1, coast / 10); + HydrologyTerrainSampler sampler = (x, z) -> x >= coast + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(x == ridgeX ? 85 : 70, 0D, "land"); + RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH, + seaLevel, seaLevel, points(0, coast + 1, seaLevel)))); + + SurfaceFootprint rejected = compiler(sampler).compile(mouth); + + assertEquals("coast=" + coast, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, + rejected.rejection()); + } + } + @Test public void aContainedFallRetainsBothExposedRunsAndWholeCourseStationIndices() { HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land"); HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL) - .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), - SurfaceTerminal.TRIBUTARY, 40, 64); + .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0), + new HydrologyPoint(100, 90, 0), new HydrologyPoint(200, 90, 0)), + SurfaceTerminal.TRIBUTARY, 90, 64); assertNull(built.rejection()); RiverCourse course = course(built.segments()); HydraulicSegment fall = course.segments().stream().filter(HydraulicSegment::fallingFluid).findFirst().orElseThrow(); @@ -246,7 +362,13 @@ public class SurfaceFootprintCompilerTest { assertEquals(full.columns().stream().filter(value -> bounds.contains(value.x(), value.z())).toList(), bounded.columns()); for (SurfaceLayerColumn value : full.columns()) { if (value.layer().terrainOwned()) { - assertTrue(value.layer().bedY() <= value.terrain().naturalHeight()); + int fill = value.layer().bedY() - value.terrain().naturalHeight(); + if (value.layer().channel()) { + assertTrue(fill <= 0); + } else { + assertTrue(fill <= settings.surface().maximumIncision()); + assertTrue(fill <= settings.surface().banks().erosion().excavation().maximumDepth()); + } } } } @@ -256,8 +378,8 @@ public class SurfaceFootprintCompilerTest { HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land"); HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL) - .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), - SurfaceTerminal.TRIBUTARY, 40, 64); + .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 300, 0), new HydrologyPoint(200, 100, 0)), + SurfaceTerminal.TRIBUTARY, 100, 64); assertNull("detail=" + built.rejectionDetail(), built.rejection()); SurfaceFootprint footprint = compiler(sampler).compile(course(built.segments())); assertNull("detail=" + footprint.rejectionDetail(), footprint.rejection()); @@ -276,8 +398,9 @@ public class SurfaceFootprintCompilerTest { x + z < 200 ? 100 : 86, 0D, "land"); HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL) - .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 200)), - SurfaceTerminal.TRIBUTARY, 40, 64); + .build(7L, 1L, "water", List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 99), + new HydrologyPoint(100, 86, 100), new HydrologyPoint(200, 86, 200)), + SurfaceTerminal.TRIBUTARY, 86, 64); assertNull(built.rejection()); RiverCourse course = course(built.segments()); assertTrue(course.segments().stream().anyMatch(segment -> segment.fallingFluid() && segment.drop() == 14)); @@ -311,8 +434,9 @@ public class SurfaceFootprintCompilerTest { : HydrologyTerrainSample.openLand((x < 100 ? 100 : 90) + Math.abs(z) / 3, 0D, "land"); SurfaceCourseResult built = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL).build(7L, 1L, "water", - List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(180, 0, 0)), - SurfaceTerminal.TRIBUTARY, 40, 64); + List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0), + new HydrologyPoint(100, 90, 0), new HydrologyPoint(180, 90, 0)), + SurfaceTerminal.TRIBUTARY, 90, 64); assertNull(built.rejection()); RiverCourse falling = course(built.segments()); RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL, diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ValleyProfileSolverTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ValleyProfileSolverTest.java index d24cdb34c..5dac7fe10 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ValleyProfileSolverTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/surface/ValleyProfileSolverTest.java @@ -186,7 +186,7 @@ public class ValleyProfileSolverTest { assertNull(valley.rejection()); assertEquals(100, valley.head()[130]); assertEquals(98, valley.head()[149]); - ErosionField field = new ErosionFieldCompiler(HydrologyPlannerSettings.defaults().surface(), terrain, SEA_LEVEL) + ErosionField field = new ErosionFieldCompiler(rasterSettings(HydrologyPlannerSettings.defaults().surface()), terrain) .compile(77L, centerline, channel, valley, SurfaceTerminal.TRIBUTARY, 0, HydrologyPlannerSettings.Ponds.none()); assertEquals(0, field.uncontainedWetCells()); @@ -214,7 +214,7 @@ public class ValleyProfileSolverTest { assertEquals(120, valley.head()[110]); assertEquals(118, valley.head()[128]); assertContained(valley); - ErosionField field = new ErosionFieldCompiler(surface, terrain, SEA_LEVEL) + ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), terrain) .compile(77L, centerline, channel, valley, SurfaceTerminal.TRIBUTARY, 0, HydrologyPlannerSettings.Ponds.none()); assertEquals(0, field.uncontainedWetCells()); @@ -231,7 +231,7 @@ public class ValleyProfileSolverTest { assertNull(valley.rejection()); assertTrue(valley.exposedStations() < 240); assertEquals(92, valley.head()[valley.exposedStations() - 1]); - ErosionField field = new ErosionFieldCompiler(HydrologyPlannerSettings.defaults().surface(), terrain, SEA_LEVEL) + ErosionField field = new ErosionFieldCompiler(rasterSettings(HydrologyPlannerSettings.defaults().surface()), terrain) .compile(77L, centerline, channel, valley, SurfaceTerminal.COASTAL_GROTTO, 0, HydrologyPlannerSettings.Ponds.none()); assertEquals(0, field.uncontainedWetCells()); @@ -492,4 +492,11 @@ public class ValleyProfileSolverTest { private static List path(int stations) { return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); } + private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) { + HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults(); + return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(), + defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(), + defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds()); + } + } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentBoundaryRadiusTest.java index de7d20e53..86d56bb57 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentBoundaryRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentBoundaryRadiusTest.java @@ -261,7 +261,7 @@ public class MantleObjectComponentBoundaryRadiusTest { @Test @SuppressWarnings("unchecked") - public void translatedTreeSchedulesDistantOwnerChunks() throws Exception { + public void translatedTreeCachesDistantSourcesAndSchedulesOnlyItsDestination() throws Exception { File objectFile = temporaryFolder.newFile("tree.iob"); try (DataOutputStream output = new DataOutputStream(new FileOutputStream(objectFile))) { output.writeInt(1); @@ -293,8 +293,8 @@ public class MantleObjectComponentBoundaryRadiusTest { MantleObjectComponent component = spy(new MantleObjectComponent(engineMantle)); assertEquals(33, component.getRadius()); - assertEquals(33, component.getOutputRadius()); - assertEquals(0, component.getInputRadius()); + assertEquals(0, component.getOutputRadius()); + assertEquals(82, component.getInputRadius()); Mantle mantle = mock(Mantle.class); MantleChunk chunk = mock(MantleChunk.class); @@ -322,9 +322,9 @@ public class MantleObjectComponentBoundaryRadiusTest { component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class)); - assertEquals(1, directOrigins.get()); + assertEquals(49, directOrigins.get()); component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class)); - assertEquals(2, directOrigins.get()); + assertEquals(49, directOrigins.get()); List generatedChunks = new ArrayList<>(); doAnswer(invocation -> { @@ -342,7 +342,7 @@ public class MantleObjectComponentBoundaryRadiusTest { )); generator.generateMatter(0, 0, false, mock(ChunkContext.class)); - assertEquals(49, generatedChunks.size()); + assertEquals(List.of("0,0"), generatedChunks); List replayedSources = new ArrayList<>(); MantleObjectComponent.replaySourceChunks( diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentPlacementDeterminismTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentPlacementDeterminismTest.java new file mode 100644 index 000000000..496e934ce --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/mantle/MantleObjectComponentPlacementDeterminismTest.java @@ -0,0 +1,207 @@ +package art.arcane.iris.generation.mantle; + +import art.arcane.iris.generation.context.ChunkContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.volmlib.nativelib.terrain.NativeBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MantleObjectComponentPlacementDeterminismTest { + private static final Position LEFT = new Position(15, 10, 0); + private static final Position RIGHT = new Position(16, 10, 0); + private static final Position SUPPORT = new Position(15, 11, 0); + + @Test + public void ordinaryObjectsKeepPrerequisitesAndSameSourceReadsAcrossDestinationOrders() throws Exception { + Fixture forward = new Fixture(); + forward.generate(List.of(0, 1)); + Fixture reverse = new Fixture(); + reverse.generate(List.of(1, 0)); + Fixture parallel = new Fixture(); + parallel.generateParallel(); + Map expected = Map.of( + LEFT, "minecraft:mangrove_wood", + RIGHT, "minecraft:mangrove_wood", + SUPPORT, "minecraft:mangrove_wood" + ); + + assertEquals(expected, forward.snapshot()); + assertEquals(expected, reverse.snapshot()); + assertEquals(expected, parallel.snapshot()); + assertEquals(12, parallel.sourceCalls.size()); + for (Integer calls : parallel.sourceCalls.values()) { + assertEquals(1, calls.intValue()); + } + } + + @Test + public void ordinaryObjectSourcePlansRemainReusableAfterNeighborPublicationAndHotload() { + Fixture fixture = new Fixture(); + fixture.generate(List.of(1, 0)); + Map expected = fixture.snapshot(); + Map calls = Map.copyOf(fixture.sourceCalls); + + fixture.generate(List.of(0, 1)); + + assertEquals(expected, fixture.snapshot()); + assertEquals(calls, fixture.sourceCalls); + fixture.component.hotload(); + fixture.generate(List.of(0, 1)); + assertEquals(expected, fixture.snapshot()); + for (Map.Entry entry : fixture.sourceCalls.entrySet()) { + assertEquals(2, entry.getValue().intValue()); + } + } + + private static final class Fixture { + private final NativeBlockState stone = state("minecraft:stone"); + private final NativeBlockState oak = state("minecraft:oak_wood"); + private final NativeBlockState mangrove = state("minecraft:mangrove_wood"); + private final NativeBlockState wrongRead = state("minecraft:dirt"); + private final Map published = new ConcurrentHashMap<>(); + private final Map sourceCalls = new ConcurrentHashMap<>(); + private final ThreadLocal destination = new ThreadLocal<>(); + private final MantleWriter writer; + private final MantleObjectComponent component; + private final ChunkContext context = mock(ChunkContext.class); + + @SuppressWarnings("unchecked") + private Fixture() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getAllRegions(any())).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(any())).thenReturn(new KList<>()); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getEngine()).thenReturn(engine); + Mantle mantle = mock(Mantle.class); + when(mantle.getWorldHeight()).thenReturn(64); + writer = mock(MantleWriter.class); + when(writer.getMantle()).thenReturn(mantle); + when(writer.getEngine()).thenReturn(engine); + when(writer.getPrerequisiteBlock(anyInt(), anyInt(), anyInt())).thenReturn(stone); + when(writer.get(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> published.getOrDefault( + new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)), + stone)); + doAnswer(invocation -> { + destination.set(new Chunk(invocation.getArgument(0), invocation.getArgument(1))); + try { + Runnable task = invocation.getArgument(2); + task.run(); + } finally { + destination.remove(); + } + return null; + }).when(writer).withChunkFence(anyInt(), anyInt(), any(Runnable.class)); + doAnswer(invocation -> { + Position position = new Position( + invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)); + NativeBlockState value = invocation.getArgument(3); + assertEquals(new Chunk(position.x() >> 4, position.z() >> 4), destination.get()); + published.put(position, value); + return null; + }).when(writer).setData(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class)); + component = new OrdinaryObjects(engineMantle, this); + } + + private void generate(List destinations) { + for (Integer chunkX : destinations) { + component.generateLayer(writer, chunkX, 0, context); + } + } + + private void generateParallel() throws Exception { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Future left = executor.submit(() -> generateAfterBarrier(0, ready, start)); + Future right = executor.submit(() -> generateAfterBarrier(1, ready, start)); + assertTrue(ready.await(5L, TimeUnit.SECONDS)); + start.countDown(); + left.get(5L, TimeUnit.SECONDS); + right.get(5L, TimeUnit.SECONDS); + } + } + + private void generateAfterBarrier(int chunkX, CountDownLatch ready, CountDownLatch start) { + ready.countDown(); + try { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + component.generateLayer(writer, chunkX, 0, context); + } + + private Map snapshot() { + Map result = new HashMap<>(); + for (Map.Entry entry : published.entrySet()) { + result.put(entry.getKey(), entry.getValue().key()); + } + return result; + } + + private static NativeBlockState state(String key) { + NativeBlockState state = mock(NativeBlockState.class); + when(state.key()).thenReturn(key); + return state; + } + } + + private static final class OrdinaryObjects extends MantleObjectComponent { + private final Fixture fixture; + + private OrdinaryObjects(EngineMantle engineMantle, Fixture fixture) { + super(engineMantle); + this.fixture = fixture; + } + + @Override + protected int computeRadius() { + return 16; + } + + @Override + void generateOrigin(ObjectPassPlacer placer, int chunkX, int chunkZ, ChunkContext context) { + fixture.sourceCalls.merge(new Chunk(chunkX, chunkZ), 1, Integer::sum); + if (chunkZ != 0 || chunkX < 0 || chunkX > 1) { + return; + } + NativeBlockState prerequisite = placer.get(LEFT.x(), LEFT.y(), LEFT.z()); + NativeBlockState wood = chunkX == 0 ? fixture.oak : fixture.mangrove; + placer.set(SUPPORT.x(), SUPPORT.y(), SUPPORT.z(), wood); + boolean sameSourceSupport = placer.get(SUPPORT.x(), SUPPORT.y(), SUPPORT.z()) == wood; + NativeBlockState placed = prerequisite == fixture.stone && sameSourceSupport ? wood : fixture.wrongRead; + placer.set(LEFT.x(), LEFT.y(), LEFT.z(), placed); + placer.set(RIGHT.x(), RIGHT.y(), RIGHT.z(), placed); + } + } + + private record Position(int x, int y, int z) { + } + + private record Chunk(int x, int z) { + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java index 4c83823f2..af5d109e4 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/ObjectDestinationTransactionTest.java @@ -93,6 +93,35 @@ public class ObjectDestinationTransactionTest { verify(writer).setData(1, 4, 0, source); } + @Test + public void applyingSourcePlanSkipsForeignChunksBeforeOverlayingAndPreservesLocalOrder() { + MantleWriter writer = writer(); + Marker foreign = new Marker("foreign"); + Marker prerequisite = new Marker("prerequisite"); + Marker first = new Marker("first"); + Marker second = new Marker("second"); + when(writer.getPrerequisiteDataIfPresent(0, 4, 0, Marker.class)).thenReturn(prerequisite); + ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0); + source.setData(-1, 4, 0, first); + source.setData(0, 4, 0, foreign); + source.setData(-1, 4, 0, second); + source.setData(-1, 4, 16, foreign); + ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, -1, 0); + + destination.apply(source.sourcePlanSince(0)); + + assertEquals(2, destination.mutationCheckpoint()); + assertSame(second, destination.getDataIfPresent(-1, 4, 0, Marker.class)); + assertSame(prerequisite, destination.getDataIfPresent(0, 4, 0, Marker.class)); + assertNull(destination.getDataIfPresent(-1, 4, 16, Marker.class)); + destination.commit(); + InOrder order = inOrder(writer); + order.verify(writer).setData(-1, 4, 0, first); + order.verify(writer).setData(-1, 4, 0, second); + verify(writer, never()).setData(0, 4, 0, foreign); + verify(writer, never()).setData(-1, 4, 16, foreign); + } + @Test public void sourcePlanRejectsAnInvalidCheckpoint() { ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer(), 0, 0); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBoundsBiomeCacheTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBoundsBiomeCacheTest.java index 0cb24e80a..19b15ff10 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBoundsBiomeCacheTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexBoundsBiomeCacheTest.java @@ -3,11 +3,15 @@ package art.arcane.iris.generation.runtime; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.noise.IrisGenerator; import art.arcane.iris.generation.noise.IrisInterpolator; +import art.arcane.iris.generation.stream.CachedStream2D; +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.util.interpolation.InterpolationMethod; import art.arcane.volmlib.util.interpolation.NoiseBounds; import art.arcane.volmlib.util.interpolation.NoiseBoundsProvider; import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.volmlib.util.stream.interpolation.Interpolated; import org.junit.Test; +import org.mockito.MockedStatic; import java.lang.reflect.Constructor; import java.lang.reflect.Field; @@ -15,6 +19,8 @@ import java.lang.reflect.Method; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.Map; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; @@ -23,8 +29,69 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.mockingDetails; public class IrisComplexBoundsBiomeCacheTest { + @Test + public void cachedBiomeColumnsReuseFractionalSamplesWithSignedParity() throws Exception { + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + ProceduralStream source = ProceduralStream.of( + (x, z) -> x.intValue() < 0 || z.intValue() < 0 ? first : second, + Interpolated.of(value -> 0D, value -> first)); + try (MockedStatic services = mockStatic(IrisServices.class)) { + services.when(() -> IrisServices.get(PreservationRegistry.class)) + .thenReturn(mock(PreservationRegistry.class)); + CachedStream2D stream = spy(new CachedStream2D<>("bounds", mock(Engine.class), source, 16)); + Fixture fixture = new Fixture(stream); + IrisInterpolator interpolator = new IrisInterpolator() + .setFunction(InterpolationMethod.STARCAST_12).setHorizontalScale(1.25D); + fixture.setBounds(interpolator, first, -15.25D, 63.5D); + fixture.setBounds(interpolator, second, 7.75D, 127.25D); + AtomicInteger requestedSamples = new AtomicInteger(); + Set columns = new HashSet<>(); + for (int z = -2; z <= 2; z++) { + for (int x = -2; x <= 2; x++) { + NoiseBounds expected = interpolator.interpolateBounds(x, z, (xx, zz) -> { + requestedSamples.incrementAndGet(); + columns.add(((long) (int) xx << 32) | ((int) zz & 0xffffffffL)); + return (int) xx < 0 || (int) zz < 0 + ? new NoiseBounds(-15.25D, 63.5D) : new NoiseBounds(7.75D, 127.25D); + }); + assertBits(expected, fixture.sample(interpolator, x, z)); + } + } + long reads = mockingDetails(stream).getInvocations().stream() + .filter(invocation -> invocation.getMethod().getName().equals("get")) + .count(); + assertTrue(reads <= columns.size() * 2L); + assertTrue(reads < requestedSamples.get() / 4L); + } + } + + @Test + public void uncachedBiomeStreamsPreserveFractionalSamplesWithinOneColumn() throws Exception { + IrisBiome first = new IrisBiome(); + IrisBiome second = new IrisBiome(); + ProceduralStream source = ProceduralStream.of( + (x, z) -> x < -0.5D || z > 0.5D ? first : second, + Interpolated.of(value -> 0D, value -> first)); + Fixture fixture = new Fixture(source); + IrisInterpolator sampling = new IrisInterpolator() { + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + return provider.noise(x, z); + } + }; + fixture.setBounds(sampling, first, -10D, 20D); + fixture.setBounds(sampling, second, 50D, 80D); + assertBits(new NoiseBounds(-10D, 20D), fixture.sample(sampling, -0.75D, 0.25D)); + assertBits(new NoiseBounds(50D, 80D), fixture.sample(sampling, -0.25D, 0.25D)); + assertBits(new NoiseBounds(-10D, 20D), fixture.sample(sampling, -0.25D, 0.75D)); + } + @Test public void adjacentPassesReuseSamplesWithoutChangingInterpolationBits() throws Exception { IrisBiome first = new IrisBiome(); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java index a01a27c30..e684bf951 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisEngineGenerationRuntimeScopeTest.java @@ -841,7 +841,9 @@ public class IrisEngineGenerationRuntimeScopeTest { private static IrisBiome carvingBiome(IrisEngine engine, IrisDimensionCarvingResolver.State state) { IrisDimensionCarvingEntry root = IrisDimensionCarvingResolver.resolveRootEntry(engine, 80, state); IrisDimensionCarvingEntry child = IrisDimensionCarvingResolver.resolveFromRoot(engine, root, 19, -3, state); - return IrisDimensionCarvingResolver.resolveEntryBiome(engine, child, state); + IrisBiome expected = IrisDimensionCarvingResolver.resolveEntryBiome(engine, child, state); + assertSame(expected, IrisDimensionCarvingResolver.resolveBiome(engine, 19, 80, -3, state)); + return expected; } private static GenerationHistoryRuntimeRouter historyRouter(IrisEngine engine) { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentityTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentityTest.java new file mode 100644 index 000000000..89155f02e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/runtime/PreparedHydrologyCacheIdentityTest.java @@ -0,0 +1,171 @@ +package art.arcane.iris.generation.runtime; + +import art.arcane.iris.world.history.GenerationKernelRegistry; +import art.arcane.iris.world.history.TransitionGenerationPlan; +import org.junit.Test; +import org.junit.Rule; +import org.junit.ClassRule; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import art.arcane.iris.pack.PackValidationCache; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.testsupport.PlatformBinding; +import art.arcane.iris.world.IrisWorld; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.studio.StudioMode; +import static org.junit.Assert.assertNotNull; +import java.nio.file.Files; +import java.nio.file.Path; +import static org.mockito.Mockito.mockStatic; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PreparedHydrologyCacheIdentityTest { + @ClassRule + public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform(); + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void frozenStudioAndProductionCopiesShareUntilPackBytesChange() throws Exception { + Path studio = folder.newFolder("studio").toPath(); + Path production = folder.newFolder("production").toPath(); + Files.writeString(studio.resolve("terrain.json"), "{\"height\":80}"); + Files.copy(studio.resolve("terrain.json"), production.resolve("terrain.json")); + GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation"); + try (MockedStatic validation = mockStatic(PackValidationCache.class)) { + validation.when(PackValidationCache::contextFingerprint).thenReturn("platform"); + PreparedHydrologyCacheIdentity prepared = PreparedHydrologyCacheIdentity.capture(target(studio), kernel, null, true); + assertEquals(prepared, PreparedHydrologyCacheIdentity.capture(target(production), kernel, null, false)); + Files.writeString(production.resolve("terrain.json"), "{\"height\":81}"); + assertNotEquals(prepared, PreparedHydrologyCacheIdentity.capture(target(production), kernel, null, false)); + } + } + + @Test + public void biomeBuffetLayoutsAreIsolatedWhileNormalStudioSharesProduction() throws Exception { + Path pack = folder.newFolder("layout").toPath(); + Files.writeString(pack.resolve("terrain.json"), "{}"); + EngineTarget target = target(pack); + GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation"); + when(IrisPlatforms.get().platformName()).thenReturn("bukkit"); + try (MockedStatic validation = mockStatic(PackValidationCache.class)) { + validation.when(PackValidationCache::contextFingerprint).thenReturn("platform"); + PreparedHydrologyCacheIdentity normal = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false); + assertNotNull(normal); + assertEquals(normal, PreparedHydrologyCacheIdentity.capture(target, kernel, null, true)); + when(target.getDimension().getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_3x3); + PreparedHydrologyCacheIdentity buffet = PreparedHydrologyCacheIdentity.capture(target, kernel, null, true); + assertNotEquals(normal, buffet); + assertEquals(normal, PreparedHydrologyCacheIdentity.capture(target, kernel, null, false)); + when(target.getDimension().getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_9x9); + assertNotEquals(buffet, PreparedHydrologyCacheIdentity.capture(target, kernel, null, true)); + } + } + + @Test + public void visibleFallbackPackBytesAndNamesSeparatePlans() throws Exception { + Path targetPack = folder.newFolder("target").toPath(); + Path packs = folder.newFolder("packs").toPath(); + Path dependency = Files.createDirectory(packs.resolve("shared")); + Path content = dependency.resolve("terrain.json"); + Files.writeString(content, "{\"height\":80}"); + EngineTarget target = target(targetPack); + when(target.getData().hasGenerationRegistryContract()).thenReturn(false); + when(IrisPlatforms.get().packsFolderNoCreate()).thenReturn(packs.toFile()); + GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation"); + try (MockedStatic validation = mockStatic(PackValidationCache.class)) { + validation.when(PackValidationCache::contextFingerprint).thenReturn("platform"); + PreparedHydrologyCacheIdentity initial = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false); + assertNotNull(initial); + Files.writeString(content, "{\"height\":81}"); + PreparedHydrologyCacheIdentity changed = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false); + assertNotNull(changed); + assertNotEquals(initial, changed); + Files.move(dependency, packs.resolve("renamed")); + PreparedHydrologyCacheIdentity renamed = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false); + assertNotNull(renamed); + assertNotEquals(changed, renamed); + Path hidden = Files.createDirectory(packs.resolve(".hidden")); + Files.writeString(hidden.resolve("terrain.json"), "{\"height\":90}"); + assertEquals(renamed, PreparedHydrologyCacheIdentity.capture(target, kernel, null, false)); + } + } + + @Test + public void visiblePackSymlinksUseLogicalNamesAndResolvedContent() throws Exception { + Path targetPack = folder.newFolder("symlink-target").toPath(); + Path first = folder.newFolder("first-packs").toPath(); + Path second = folder.newFolder("second-packs").toPath(); + Path dependency = Files.createDirectory(first.resolve("shared")); + Files.writeString(dependency.resolve("terrain.json"), "{}"); + Files.createSymbolicLink(second.resolve("shared"), dependency); + EngineTarget target = target(targetPack); + when(target.getData().hasGenerationRegistryContract()).thenReturn(false); + GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation"); + try (MockedStatic validation = mockStatic(PackValidationCache.class)) { + validation.when(PackValidationCache::contextFingerprint).thenReturn("platform"); + when(IrisPlatforms.get().packsFolderNoCreate()).thenReturn(first.toFile()); + PreparedHydrologyCacheIdentity direct = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false); + assertNotNull(direct); + when(IrisPlatforms.get().packsFolderNoCreate()).thenReturn(second.toFile()); + assertEquals(direct, PreparedHydrologyCacheIdentity.capture(target, kernel, null, false)); + } + } + + private static EngineTarget target(Path path) { + IrisData data = mock(IrisData.class); + when(data.getDataFolder()).thenReturn(path.toFile()); + when(data.hasGenerationRegistryContract()).thenReturn(true); + IrisWorld world = mock(IrisWorld.class); + when(world.minHeight()).thenReturn(-64); + when(world.maxHeight()).thenReturn(320); + EngineTarget target = mock(EngineTarget.class); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL); + when(target.getDimension()).thenReturn(dimension); + when(target.getData()).thenReturn(data); + when(target.getWorld()).thenReturn(world); + return target; + } + + @Test + public void identicalInputsShareRegardlessOfWorldLocation() { + GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation"); + assertEquals(identity("pack", "closed", "platform", kernel, null, -64), + identity("pack", "closed", "platform", kernel, null, -64)); + } + + @Test + public void everyExternalInputAndTransitionSeparatesPlans() { + GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation"); + PreparedHydrologyCacheIdentity base = identity("pack", "closed", "platform", kernel, null, -64); + assertNotEquals(base, identity("changed", "closed", "platform", kernel, null, -64)); + assertNotEquals(base, identity("pack", "dependency-changed", "platform", kernel, null, -64)); + assertNotEquals(base, identity("pack", "closed", "platform-changed", kernel, null, -64)); + assertNotEquals(base, identity("pack", "closed", "platform", kernel("changed"), null, -64)); + assertNotEquals(base, identity("pack", "closed", "platform", kernel, null, 0)); + TransitionGenerationPlan.Specification transition = new TransitionGenerationPlan.Specification( + 1L, "old", "new", 1, 256, "boundary", "terrain"); + assertNotEquals(base, identity("pack", "closed", "platform", kernel, transition, -64)); + assertNotEquals(identity("pack", "closed", "platform", kernel, transition, -64), + identity("pack", "closed", "platform", kernel, + new TransitionGenerationPlan.Specification(1L, "old", "new", 1, 256, "other", "terrain"), -64)); + } + + private static PreparedHydrologyCacheIdentity identity(String pack, String dependencies, String platform, + GenerationKernelRegistry.RuntimeKernel kernel, TransitionGenerationPlan.Specification transition, int minY) { + return PreparedHydrologyCacheIdentity.fromInputs(pack, dependencies, platform, kernel, transition, minY, 320, 0); + } + + private static GenerationKernelRegistry.RuntimeKernel kernel(String implementation) { + GenerationKernelRegistry.RuntimeKernel kernel = mock(GenerationKernelRegistry.RuntimeKernel.class); + when(kernel.version()).thenReturn(new GenerationKernelRegistry.Version(1, 1, 1)); + when(kernel.implementationFingerprint()).thenReturn(implementation); + return kernel; + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/HydrologyBankTerrainRuntimeTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/HydrologyBankTerrainRuntimeTest.java index 8115defc4..02b800f7a 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/HydrologyBankTerrainRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/HydrologyBankTerrainRuntimeTest.java @@ -31,6 +31,50 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class HydrologyBankTerrainRuntimeTest { + @Test + public void raisedOwnedBanksFillThroughTheirContainmentHeightWithoutDensityOrNeighbourQueries() { + AtomicInteger sampled = new AtomicInteger(); + Terrain3DColumn natural = Terrain3DColumnFixtures.spans(70, 0, 70, 76, 80); + HydrologyBankTerrainRuntime runtime = HydrologyBankTerrainRuntime.withNoise( + new HydrologyBankTerrainRuntime.Sources((x, z) -> natural, (x, z) -> { + assertEquals(-1, x); + assertEquals(-17, z); + sampled.incrementAndGet(); + return HydrologyColumnSnapshot.ready(bank(84, false, true)); + }), options(16), (x, y, z) -> { + throw new AssertionError("A filled bank must not sample erosion density"); + }); + + Terrain3DColumn column = runtime.column(-1, -17); + + assertEquals(84, column.topY()); + assertEquals(1, column.spanCount()); + for (int y = 0; y <= 84; y++) { + assertTrue("Required containment at " + y, column.isSolid(y)); + } + assertFalse(column.isSolid(85)); + assertSame(column, runtime.column(-1, -17)); + assertEquals(1, sampled.get()); + } + + @Test + public void raisedBankFillIsClampedToTheWorldHeightAndDoesNotAffectUnownedBands() { + Terrain3DColumn natural = Terrain3DColumn.unshaped(250, 256); + HydrologyBankTerrainRuntime runtime = HydrologyBankTerrainRuntime.withNoise( + new HydrologyBankTerrainRuntime.Sources((x, z) -> natural, + (x, z) -> HydrologyColumnSnapshot.ready(bank(270, false, x == 0))), + options(16), (x, y, z) -> { + throw new AssertionError("Raised bank fill must not sample density"); + }); + + Terrain3DColumn filled = runtime.column(0, 0); + + assertEquals(255, filled.topY()); + assertTrue(filled.isSolid(255)); + assertFalse(filled.isSolid(256)); + assertSame(natural, runtime.column(1, 0)); + } + @Test public void unavailableSupportColumnsDoNotPoisonLaterAcceptedBankGeometry() { AtomicBoolean ready = new AtomicBoolean(); diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java index c2c3a0d29..7509acab1 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/IrisDimensionCarvingResolverParityTest.java @@ -99,6 +99,8 @@ public class IrisDimensionCarvingResolverParityTest { IrisDimensionCarvingEntry legacyResolved = legacyResolveFromRoot(fixture.engine, legacyRoot, worldX, worldZ); IrisDimensionCarvingEntry statefulResolved = IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, statefulRoot, worldX, worldZ, state); assertSame("entry mismatch at worldY=" + worldY + " worldX=" + worldX + " worldZ=" + worldZ, legacyResolved, statefulResolved); + assertSame(legacyResolveEntryBiome(fixture.engine, legacyResolved), + IrisDimensionCarvingResolver.resolveBiome(fixture.engine, worldX, worldY, worldZ, state)); } } fixture.clearRecordedCalls(); @@ -120,6 +122,8 @@ public class IrisDimensionCarvingResolverParityTest { IrisDimensionCarvingEntry legacyResolved = legacyResolveFromRoot(fixture.engine, legacyRoot, worldX, worldZ); IrisDimensionCarvingEntry statefulResolved = IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, statefulRoot, worldX, worldZ, state); assertSame("mixed entry mismatch at worldY=" + worldY + " worldX=" + worldX + " worldZ=" + worldZ, legacyResolved, statefulResolved); + assertSame(legacyResolveEntryBiome(fixture.engine, legacyResolved), + IrisDimensionCarvingResolver.resolveBiome(fixture.engine, worldX, worldY, worldZ, state)); } } fixture.clearRecordedCalls(); @@ -232,6 +236,54 @@ public class IrisDimensionCarvingResolverParityTest { assertSame(firstBiome, IrisDimensionCarvingResolver.resolveEntryBiome(engine, entry, state)); } + @Test + public void biomeResolutionReadsRuntimeBindingOnce() { + Fixture fixture = createFixture(); + IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State(); + IrisBiome expected = IrisDimensionCarvingResolver.resolveBiome(fixture.engine, -17, 83, 23, state); + assertNotNull(expected); + fixture.clearRecordedCalls(); + + assertSame(expected, IrisDimensionCarvingResolver.resolveBiome(fixture.engine, -17, 83, 23, state)); + verify(fixture.engine).getDimension(); + verify(fixture.engine).getData(); + } + + @Test + public void biomeResolutionInvalidatesAcrossDataDimensionsAndEngines() { + for (boolean explicitState : new boolean[]{false, true}) { + IrisDimensionCarvingResolver.State state = explicitState ? new IrisDimensionCarvingResolver.State() : null; + Fixture fixture = createFixture(); + IrisData firstData = fixture.engine.getData(); + IrisDimension dimension = fixture.engine.getDimension(); + IrisData replacementData = mock(IrisData.class); + IrisDimensionCarvingEntry entry = mock(IrisDimensionCarvingEntry.class); + IrisBiome firstBiome = new IrisBiome(); + IrisBiome replacementBiome = new IrisBiome(); + doReturn(true).when(entry).isEnabled(); + doReturn(firstBiome).when(entry).getRealBiome(firstData); + doReturn(replacementBiome).when(entry).getRealBiome(replacementData); + doReturn(new KList<>(entry)).when(dimension).getCarving(); + + assertSame(firstBiome, IrisDimensionCarvingResolver.resolveBiome(fixture.engine, -17, 83, 23, state)); + doReturn(replacementData).when(fixture.engine).getData(); + assertSame(replacementBiome, IrisDimensionCarvingResolver.resolveBiome(fixture.engine, -17, 83, 23, state)); + doReturn(firstData).when(fixture.engine).getData(); + assertSame(firstBiome, IrisDimensionCarvingResolver.resolveBiome(fixture.engine, -17, 83, 23, state)); + + IrisDimension emptyDimension = new IrisDimension(); + doReturn(emptyDimension).when(fixture.engine).getDimension(); + assertSame(null, IrisDimensionCarvingResolver.resolveBiome(fixture.engine, -17, 83, 23, state)); + + Fixture replacement = createMixedDepthFixture(); + IrisDimensionCarvingEntry expectedEntry = legacyResolveFromRoot(replacement.engine, + legacyResolveRootEntry(replacement.engine, 83), -17, 23); + IrisBiome expected = legacyResolveEntryBiome(replacement.engine, expectedEntry); + assertSame(expected, IrisDimensionCarvingResolver.resolveBiome(replacement.engine, -17, 83, 23, state)); + assertSame(null, IrisDimensionCarvingResolver.resolveBiome(fixture.engine, -17, 83, 23, state)); + } + } + @Test public void resolverStateUsesWeakRuntimeIdentityBindings() throws NoSuchFieldException { Field engineIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("engineIdentity"); diff --git a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DRuntimeTest.java b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DRuntimeTest.java index b53150b39..c1face357 100644 --- a/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DRuntimeTest.java +++ b/core/src/test/java/art/arcane/iris/generation/terrain/Terrain3DRuntimeTest.java @@ -5,6 +5,8 @@ import org.junit.Test; import java.util.ArrayList; import java.util.List; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -19,6 +21,34 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class Terrain3DRuntimeTest { + @Test + public void alignedColumnsOnlySampleContributingAnchors() { + for (int x = -8; x <= -7; x++) { + for (int z = -8; z <= -7; z++) { + Set> samples = new HashSet<>(); + Terrain3DRuntime runtime = runtime(profile(), (sampleX, sampleY, sampleZ) -> { + samples.add(List.of(sampleX, sampleZ)); + return 0D; + }); + Terrain3DColumn column = runtime.column(x, z); + assertEquals(96, column.topY()); + assertEquals(1, column.spanCount()); + Set> expected = new HashSet<>(); + expected.add(List.of(-8D, -8D)); + if (x != -8) { + expected.add(List.of(-4D, -8D)); + } + if (z != -8) { + expected.add(List.of(-8D, -4D)); + } + if (x != -8 && z != -8) { + expected.add(List.of(-4D, -4D)); + } + assertEquals(expected, samples); + } + } + } + @Test public void omittedProfilesPreserveExactHeightsWithoutCreatingNoise() { AtomicInteger compilations = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/pack/PackValidationRegistryTest.java b/core/src/test/java/art/arcane/iris/pack/PackValidationRegistryTest.java index 044307d51..00c03e33f 100644 --- a/core/src/test/java/art/arcane/iris/pack/PackValidationRegistryTest.java +++ b/core/src/test/java/art/arcane/iris/pack/PackValidationRegistryTest.java @@ -24,6 +24,7 @@ import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; import java.io.IOException; import java.nio.file.Files; @@ -43,6 +44,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; public class PackValidationRegistryTest { @Rule @@ -137,7 +139,7 @@ public class PackValidationRegistryTest { Path mismatchedTarget = temporaryFolder.newFolder("copy-mismatched-target").toPath(); PackValidationResult result = new PackValidationResult( "source", List.of(), List.of("source warning"), 7L); - PackValidationRegistry.publish(sourceRoot, result, "fingerprint-a"); + PackValidationRegistry.publish(sourceRoot, result, "fingerprint-a", PackValidationCache.contextFingerprint()); assertSame(result, PackValidationRegistry.publishMatchingCopy( sourceRoot, @@ -151,13 +153,94 @@ public class PackValidationRegistryTest { assertNull(PackValidationRegistry.get(mismatchedTarget)); } + @Test + public void matchingLookupRetainsRootGenerationAndRejectsMutation() throws Exception { + Path packRoot = temporaryFolder.newFolder("matching-source").toPath(); + PackValidationResult validation = new PackValidationResult("source", List.of(), List.of(), 11L); + PackValidationRegistry.publish(packRoot, validation, "fingerprint-a", PackValidationCache.contextFingerprint()); + PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(packRoot); + + assertSame(validation, PackValidationRegistry.getMatching(packRoot, "fingerprint-a")); + assertNull(PackValidationRegistry.getMatching(packRoot, "fingerprint-b")); + assertTrue(PackValidationRegistry.publishIfCurrent(ticket, validation)); + assertNull(PackValidationRegistry.getMatching(packRoot, "fingerprint-a")); + + PackValidationRegistry.publish(packRoot, validation, "fingerprint-a", PackValidationCache.contextFingerprint()); + try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(packRoot)) { + assertNull(PackValidationRegistry.getMatching(packRoot, "fingerprint-a")); + mutation.stage(validation); + mutation.commit(); + } + assertNull(PackValidationRegistry.getMatching(packRoot, "fingerprint-a")); + } + + @Test + public void changedValidationContextRejectsSourceAndCopiedReuse() throws Exception { + Path source = temporaryFolder.newFolder("context-source").toPath(); + Path target = temporaryFolder.newFolder("context-copy").toPath(); + PackValidationResult validation = new PackValidationResult("source", List.of(), List.of(), 13L); + try (MockedStatic context = mockStatic(PackValidationCache.class)) { + context.when(PackValidationCache::contextFingerprint).thenReturn("original-context"); + PackValidationRegistry.publish(source, validation, "fingerprint-a", PackValidationCache.contextFingerprint()); + assertSame(validation, PackValidationRegistry.getMatching(source, "fingerprint-a")); + + context.when(PackValidationCache::contextFingerprint).thenReturn("changed-context"); + assertNull(PackValidationRegistry.getMatching(source, "fingerprint-a")); + assertNull(PackValidationRegistry.publishMatchingCopy(source, target, "fingerprint-a")); + assertNull(PackValidationRegistry.get(target)); + } + } + + @Test + public void changedContextBeforePublicationCannotAuthorizeFingerprintReuse() throws Exception { + Path source = temporaryFolder.newFolder("changed-context-publication").toPath(); + Path target = temporaryFolder.newFolder("changed-context-publication-copy").toPath(); + PackValidationResult validation = new PackValidationResult("source", List.of(), List.of(), 19L); + try (MockedStatic context = mockStatic(PackValidationCache.class)) { + context.when(PackValidationCache::contextFingerprint).thenReturn("original-context"); + String validatedContext = PackValidationCache.contextFingerprint(); + context.when(PackValidationCache::contextFingerprint).thenReturn("changed-context"); + + PackValidationRegistry.publish(source, validation, "fingerprint-a", validatedContext); + + assertSame(validation, PackValidationRegistry.get(source)); + assertNull(PackValidationRegistry.getMatching(source, "fingerprint-a")); + assertNull(PackValidationRegistry.publishMatchingCopy(source, target, "fingerprint-a")); + context.when(PackValidationCache::contextFingerprint).thenReturn("original-context"); + assertNull(PackValidationRegistry.getMatching(source, "fingerprint-a")); + } + } + + @Test + public void mutationDuringContextCheckRejectsMatchingValidation() throws Exception { + Path source = temporaryFolder.newFolder("interleaved-context-mutation").toPath(); + PackValidationResult validation = new PackValidationResult("source", List.of(), List.of(), 17L); + AtomicReference mutation = new AtomicReference<>(); + try (MockedStatic context = mockStatic(PackValidationCache.class)) { + context.when(PackValidationCache::contextFingerprint).thenReturn("context"); + PackValidationRegistry.publish(source, validation, "fingerprint-a", PackValidationCache.contextFingerprint()); + context.when(PackValidationCache::contextFingerprint).thenAnswer(invocation -> { + mutation.set(PackValidationRegistry.beginRootMutation(source)); + return "context"; + }); + + assertNull(PackValidationRegistry.getMatching(source, "fingerprint-a")); + assertNotNull(mutation.get()); + assertNull(PackValidationRegistry.get(source)); + } finally { + if (mutation.get() != null) { + mutation.get().close(); + } + } + } + @Test public void unfingerprintedRepublishRevokesCopiedValidationReuse() throws Exception { Path sourceRoot = temporaryFolder.newFolder("republished-source").toPath(); Path targetRoot = temporaryFolder.newFolder("republished-target").toPath(); PackValidationResult initial = new PackValidationResult("source", List.of(), List.of(), 3L); PackValidationResult replacement = new PackValidationResult("source", List.of(), List.of(), 5L); - PackValidationRegistry.publish(sourceRoot, initial, "old-fingerprint"); + PackValidationRegistry.publish(sourceRoot, initial, "old-fingerprint", PackValidationCache.contextFingerprint()); PackValidationRegistry.publish(sourceRoot, replacement); diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 23ad36577..1aa9762c9 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "7950fa5a3f3abf7fdd2cea1bd4b1fd335745b348ce64e67404cc86e898d18f16"; + private static final String EXPECTED_SCHEMA_DIGEST = "415345947f2c96fc468848aefa064854a085ae5631ea0f780c77c99973bff594"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorEntryMulticoreTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorEntryMulticoreTest.java new file mode 100644 index 000000000..41261b358 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorEntryMulticoreTest.java @@ -0,0 +1,156 @@ +package art.arcane.iris.platform.generation; + +import art.arcane.iris.configuration.IrisSettings; +import art.arcane.iris.generation.context.IrisContext; +import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.EnginePlatformHooks; +import art.arcane.iris.generation.runtime.EngineTarget; +import art.arcane.iris.generation.runtime.GenerationSessionLease; +import art.arcane.iris.generation.terrain.IrisDimension; +import art.arcane.iris.generation.terrain.IrisDimensionRuntimeContract; +import art.arcane.iris.platform.bukkit.nms.INMS; +import art.arcane.iris.platform.bukkit.nms.INMSBinding; +import art.arcane.iris.studio.StudioMode; +import art.arcane.iris.world.IrisWorld; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +public class BukkitChunkGeneratorEntryMulticoreTest { + @Test + public void ordinaryEntryUsesMulticoreUntilEntryCompletes() throws Exception { + try (Fixture fixture = new Fixture(false)) { + fixture.generate(); + fixture.generator.completeInitialEntry(); + fixture.generate(); + + assertEquals(List.of(true, false), fixture.multicore); + } + } + + @Test + public void forceMulticoreStillAppliesAfterOrdinaryEntry() throws Exception { + try (Fixture fixture = new Fixture(false)) { + fixture.generator.completeInitialEntry(); + fixture.settings.getPerformance().getEngineSVC().setForceMulticoreWrite(true); + fixture.generate(); + fixture.settings.getPerformance().getEngineSVC().setForceMulticoreWrite(false); + fixture.generate(); + + assertEquals(List.of(true, false), fixture.multicore); + } + } + + @Test + public void pregenerationStillUsesMulticoreAfterOrdinaryEntry() throws Exception { + try (Fixture fixture = new Fixture(false)) { + fixture.generator.completeInitialEntry(); + when(fixture.hooks.isPregeneratorActive(fixture.engine)).thenReturn(true); + fixture.generate(); + when(fixture.hooks.isPregeneratorActive(fixture.engine)).thenReturn(false); + fixture.generate(); + + assertEquals(List.of(true, false), fixture.multicore); + } + } + + @Test + public void studioBootstrapUsesMulticoreUntilItsOwnLifecycleEnds() throws Exception { + try (Fixture fixture = new Fixture(true)) { + fixture.generate(); + fixture.generator.completeInitialEntry(); + fixture.generate(); + fixture.generator.endStudioEntryBootstrap(); + fixture.generate(); + + assertEquals(List.of(true, true, false), fixture.multicore); + } + } + + private static void field(BukkitChunkGenerator generator, String name, Object value) throws Exception { + Field field = BukkitChunkGenerator.class.getDeclaredField(name); + field.setAccessible(true); + field.set(generator, value); + } + + private static final class Fixture implements AutoCloseable { + private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS); + private final Engine engine = mock(Engine.class); + private final EnginePlatformHooks hooks = mock(EnginePlatformHooks.class); + private final IrisSettings settings = new IrisSettings(); + private final WorldInfo world = mock(WorldInfo.class); + private final ChunkGenerator.ChunkData chunk = mock(ChunkGenerator.ChunkData.class); + private final ArrayList multicore = new ArrayList<>(); + private final MockedStatic settingsAccess = mockStatic(IrisSettings.class); + private final MockedStatic nativeAccess = mockStatic(INMS.class); + + private Fixture(boolean studio) throws Exception { + settingsAccess.when(IrisSettings::get).thenReturn(settings); + INMSBinding binding = mock(INMSBinding.class); + nativeAccess.when(INMS::get).thenReturn(binding); + when(binding.applyChunkDataBlocks(any(), any())).thenReturn(true); + when(chunk.getMaxHeight()).thenReturn(16); + when(engine.getPlatformHooks()).thenReturn(hooks); + when(engine.acquireGenerationLease("bukkit_terrain_stage")).thenReturn(GenerationSessionLease.noop()); + doCallRealMethod().when(engine).shouldGenerateMulticore(); + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.getLoadKey()).thenReturn("entry-multicore-test"); + when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL); + when(engine.getDimension()).thenReturn(dimension); + EngineTarget target = mock(EngineTarget.class); + when(target.getDimension()).thenReturn(dimension); + doReturn(target).when(generator).getTarget(); + field(generator, "world", mock(IrisWorld.class)); + field(generator, "validatedDimension", dimension); + field(generator, "validatedWorldContract", new IrisDimensionRuntimeContract("iris:test", 0, 16, 16)); + field(generator, "startupReady", CompletableFuture.completedFuture(null)); + field(generator, "startupContentReady", CompletableFuture.completedFuture(null)); + field(generator, "setup", new AtomicBoolean(true)); + field(generator, "studio", studio); + field(generator, "studioEntryBootstrapActive", new AtomicBoolean(studio)); + generator.setLastMode(StudioMode.NORMAL); + if (!studio) { + generator.beginInitialEntry(false); + } + generator.setEngine(engine); + doAnswer(invocation -> { + assertSame(engine, IrisContext.require().getEngine()); + multicore.add(invocation.getArgument(4, Boolean.class)); + return null; + }).when(engine).generate(anyInt(), anyInt(), any(), any(), anyBoolean()); + } + + private void generate() { + generator.generateNoise(world, new Random(1L), -2, 3, chunk); + assertNull(IrisContext.get()); + } + + @Override + public void close() { + nativeAccess.close(); + settingsAccess.close(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorHistoryTargetTest.java b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorHistoryTargetTest.java index 14087e4fe..7ed14b429 100644 --- a/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorHistoryTargetTest.java +++ b/core/src/test/java/art/arcane/iris/platform/generation/BukkitChunkGeneratorHistoryTargetTest.java @@ -2,6 +2,15 @@ package art.arcane.iris.platform.generation; import art.arcane.volmlib.util.cache.AtomicCache; import art.arcane.iris.generation.runtime.Engine; +import art.arcane.iris.generation.runtime.IrisEngine; +import art.arcane.iris.world.history.GenerationActivation; +import art.arcane.iris.world.history.GenerationEpoch; +import art.arcane.iris.world.history.GenerationHistoryPaths; +import art.arcane.iris.world.history.GenerationKernelRegistry; +import art.arcane.iris.world.history.GenerationRegistryContract; +import org.mockito.MockedConstruction; +import java.lang.reflect.Method; +import static org.mockito.Mockito.mockConstruction; import art.arcane.iris.generation.runtime.EngineTarget; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.world.history.GenerationHistory; @@ -23,6 +32,30 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class BukkitChunkGeneratorHistoryTargetTest { + @Test + public void selectedFrozenRegistryIsBoundBeforeRuntimeConstruction() throws Exception { + Fixture fixture = new Fixture(); + GenerationActivation activation = mock(GenerationActivation.class); + GenerationEpoch epoch = mock(GenerationEpoch.class); + GenerationRegistryContract registry = mock(GenerationRegistryContract.class); + GenerationHistoryPaths paths = mock(GenerationHistoryPaths.class); + when(fixture.history.activeActivation()).thenReturn(activation); + when(fixture.history.activeEpoch()).thenReturn(epoch); + when(fixture.history.paths()).thenReturn(paths); + when(activation.isInitial()).thenReturn(true); + when(activation.activationId()).thenReturn(1L); + when(epoch.registryContract()).thenReturn(registry); + when(epoch.kernelVersion()).thenReturn(new GenerationKernelRegistry.Version(1, 1, 1)); + when(paths.activationMantleRoot(1L)).thenReturn(Path.of("generation", "mantle", "1")); + try (MockedConstruction engines = mockConstruction(IrisEngine.class, + (engine, context) -> verify(fixture.data).bindGenerationRegistryContract(registry))) { + Method create = BukkitChunkGenerator.class.getDeclaredMethod("createEngine", EngineTarget.class); + create.setAccessible(true); + Object created = create.invoke(fixture.generator, fixture.target); + assertSame(engines.constructed().getFirst(), created); + } + } + @Test public void unchangedVerifiedPackRetainsThePreviouslyLoadedTarget() throws Exception { Fixture fixture = new Fixture(); diff --git a/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java b/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java index b6920015a..d505f077b 100644 --- a/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java +++ b/core/src/test/java/art/arcane/iris/studio/StudioSVCWorldPackPublishTest.java @@ -4,14 +4,17 @@ import art.arcane.iris.pack.datapack.ServerConfigurator; import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.AtomicDirectoryPublisher; import art.arcane.iris.pack.BrokenPackException; +import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationResult; +import art.arcane.iris.pack.PackValidator; import art.arcane.iris.generation.runtime.PreservationRegistry; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.platform.bukkit.plugin.VolmitSender; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.world.history.GenerationHistory; +import art.arcane.iris.world.history.GenerationPackFingerprint; import art.arcane.iris.world.task.J; import art.arcane.iris.testsupport.PlatformLeakGuard; import org.junit.Assume; @@ -278,6 +281,56 @@ public class StudioSVCWorldPackPublishTest { assertTrue(PackValidationRegistry.isBroken(packRoot)); } + @Test + public void unchangedGenerationCandidateReusesStartupValidation() throws Exception { + Path source = temporaryFolder.newFolder("validated-generation-source").toPath(); + writeValidPack(source); + PackValidationResult validation = new PackValidationResult( + "validated-generation-source", List.of(), List.of("retained warning"), 31L); + PackValidationRegistry.publish(source, validation, + ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); + String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); + + try (MockedStatic validator = mockStatic(PackValidator.class)) { + assertSame(validation, StudioSVC.validateGenerationCandidate(source, fingerprint)); + validator.verifyNoInteractions(); + } + assertSame(validation, PackValidationRegistry.getMatching(source, fingerprint)); + } + + @Test + public void editedGenerationCandidateCannotReuseStartupValidation() throws Exception { + Path source = temporaryFolder.newFolder("edited-generation-source").toPath(); + writeValidPack(source); + PackValidationRegistry.publish(source, new PackValidationResult( + "edited-generation-source", List.of(), List.of(), 37L), + ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); + Files.writeString(source.resolve("dimensions/main.json"), "{"); + String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); + + assertThrows(BrokenPackException.class, + () -> StudioSVC.validateGenerationCandidate(source, fingerprint)); + assertTrue(PackValidationRegistry.isBroken(source)); + } + + @Test + public void unchangedBrokenGenerationCandidateRetainsValidationFailure() throws Exception { + Path source = temporaryFolder.newFolder("rejected-generation-source").toPath(); + writeValidPack(source); + PackValidationResult validation = new PackValidationResult( + "rejected-generation-source", List.of("registry content unavailable"), List.of(), 41L); + PackValidationRegistry.publish(source, validation, + ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); + String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION); + + try (MockedStatic validator = mockStatic(PackValidator.class)) { + assertThrows(BrokenPackException.class, + () -> StudioSVC.validateGenerationCandidate(source, fingerprint)); + validator.verifyNoInteractions(); + } + assertSame(validation, PackValidationRegistry.getMatching(source, fingerprint)); + } + @Test public void exactCopiedFingerprintReusesSourceSemanticValidation() throws Exception { Path root = temporaryFolder.newFolder("matching-validation-copy").toPath(); @@ -289,7 +342,7 @@ public class StudioSVCWorldPackPublishTest { String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile()); PackValidationResult sourceValidation = new PackValidationResult( "source", List.of(), List.of("preserved source warning"), 17L); - PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint); + PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint, PackValidationCache.contextFingerprint()); PackValidationResult reused = StudioSVC.validatePublishedPack( target, @@ -310,7 +363,7 @@ public class StudioSVCWorldPackPublishTest { PackValidationResult sourceValidation = new PackValidationResult( "snapshot-source", List.of(), List.of("source warning"), 23L); PackValidationRegistry.publish(source, sourceValidation, - ServerConfigurator.computePackTreeFingerprint(source.toFile())); + ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); IrisData installed = IrisData.openDatapackCompiler(target.toFile()); try (MockedStatic data = mockStatic(IrisData.class, CALLS_REAL_METHODS)) { @@ -330,7 +383,7 @@ public class StudioSVCWorldPackPublishTest { writeValidPack(source); StudioSVC.copyPackTree(source, target); PackValidationRegistry.publish(source, new PackValidationResult("snapshot-source", List.of(), List.of(), 29L), - ServerConfigurator.computePackTreeFingerprint(source.toFile())); + ServerConfigurator.computePackTreeFingerprint(source.toFile()), PackValidationCache.contextFingerprint()); Files.writeString(target.resolve("dimensions/main.json"), "{"); assertThrows(BrokenPackException.class, () -> StudioSVC.loadInstalledDimension(target, source, "main")); @@ -347,7 +400,7 @@ public class StudioSVCWorldPackPublishTest { String sourceFingerprint = ServerConfigurator.computePackTreeFingerprint(source.toFile()); PackValidationResult sourceValidation = new PackValidationResult( "source", List.of(), List.of(), 19L); - PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint); + PackValidationRegistry.publish(source, sourceValidation, sourceFingerprint, PackValidationCache.contextFingerprint()); Files.writeString(target.resolve("dimensions/main.json"), "{"); String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(target.toFile()); @@ -374,7 +427,7 @@ public class StudioSVCWorldPackPublishTest { "source", List.of(), List.of(), 23L); PackValidationResult staleTargetValidation = new PackValidationResult( "pack", List.of(), List.of("stale target"), 11L); - PackValidationRegistry.publish(sourcePack, sourceValidation, sourceFingerprint); + PackValidationRegistry.publish(sourcePack, sourceValidation, sourceFingerprint, PackValidationCache.contextFingerprint()); PackValidationRegistry.publish(target, staleTargetValidation); assertSame(staleTargetValidation, PackValidationRegistry.requireLoadable(target)); diff --git a/core/src/test/java/art/arcane/iris/world/pregen/PregenTaskBoundsOverflowTest.java b/core/src/test/java/art/arcane/iris/world/pregen/PregenTaskBoundsOverflowTest.java index df24fd65b..c6de7261a 100644 --- a/core/src/test/java/art/arcane/iris/world/pregen/PregenTaskBoundsOverflowTest.java +++ b/core/src/test/java/art/arcane/iris/world/pregen/PregenTaskBoundsOverflowTest.java @@ -3,6 +3,9 @@ package art.arcane.iris.world.pregen; import art.arcane.volmlib.util.math.Position2; import org.junit.Test; +import java.util.LinkedHashSet; +import java.util.concurrent.atomic.AtomicLong; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -55,7 +58,7 @@ public class PregenTaskBoundsOverflowTest { int[] bounds = task.regionBounds(); assertEquals(-58594, bounds[0]); - assertEquals(58594, bounds[2]); + assertEquals(58593, bounds[2]); } @Test @@ -93,6 +96,38 @@ public class PregenTaskBoundsOverflowTest { assertArrayEqualsMessage(new int[]{-2, -1, 2, 1}, task.regionBounds()); } + @Test + public void thousandBlockRadiusVisitsOnlyTheSixteenOccupiedRegions() { + PregenTask task = PregenTask.builder().radiusX(1000).radiusZ(1000).build(); + + assertArrayEqualsMessage(new int[]{-63, -63, 63, 63}, task.chunkBounds()); + assertArrayEqualsMessage(new int[]{-2, -2, 1, 1}, task.regionBounds()); + assertEquals(16_129L, task.chunkCount()); + assertRegionTraversal(task); + } + + @Test + public void asymmetricRegionTraversalRetainsEveryChunkAndStartsAtTheCenter() { + int[][] areas = { + {0, 0, 1, 1}, + {497, -497, 1, 17}, + {-513, 511, 32, 17}, + {511, -513, 1000, 32}, + {512, -512, 512, 256}, + {0, 0, 1024, 512}, + {PregenTask.MAX_WORLD_BLOCK - 32, 0, 32, 1}, + {0, -PregenTask.MAX_WORLD_BLOCK + 32, 1, 32} + }; + for (int[] area : areas) { + PregenTask task = PregenTask.builder() + .center(new Position2(area[0], area[1])) + .radiusX(area[2]) + .radiusZ(area[3]) + .build(); + assertRegionTraversal(task); + } + } + @Test public void clampSaturatesInsteadOfWrapping() { assertEquals(Integer.MAX_VALUE, PregenTask.clampBlock((long) Integer.MAX_VALUE + 1L)); @@ -101,6 +136,28 @@ public class PregenTaskBoundsOverflowTest { assertEquals(-7, PregenTask.clampBlock(-7L)); } + private static void assertRegionTraversal(PregenTask task) { + int[] chunks = task.chunkBounds(); + LinkedHashSet expected = new LinkedHashSet<>(); + for (int regionX = chunks[0] >> 5; regionX <= chunks[2] >> 5; regionX++) { + for (int regionZ = chunks[1] >> 5; regionZ <= chunks[3] >> 5; regionZ++) { + expected.add(new Position2(regionX, regionZ)); + } + } + LinkedHashSet actual = new LinkedHashSet<>(); + AtomicLong visitedChunks = new AtomicLong(); + task.iterateRegions((regionX, regionZ) -> { + assertTrue(actual.add(new Position2(regionX, regionZ))); + long before = visitedChunks.get(); + task.iterateChunks(regionX, regionZ, (chunkX, chunkZ) -> visitedChunks.incrementAndGet()); + assertTrue(visitedChunks.get() > before); + }); + + assertEquals(expected, actual); + assertEquals(new Position2(task.getCenter().getX() >> 9, task.getCenter().getZ() >> 9), actual.getFirst()); + assertEquals(task.chunkCount(), visitedChunks.get()); + } + private static void assertArrayEqualsMessage(int[] expected, int[] actual) { assertEquals(expected.length, actual.length); for (int index = 0; index < expected.length; index++) { diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java index 90f72cde8..946d1f710 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java @@ -30,6 +30,9 @@ public final class HydrologyBlockProbe { private static final long MAXIMUM_VOXELS = 16_777_216L; private static final int MAXIMUM_EXAMPLES = 16; private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final Set IMPLICIT_WATER_BLOCKS = Set.of( + "minecraft:seagrass", "minecraft:tall_seagrass", "minecraft:kelp", "minecraft:kelp_plant", + "minecraft:bubble_column"); private HydrologyBlockProbe() { } @@ -99,6 +102,7 @@ public final class HydrologyBlockProbe { int connectedMouthComponents; int disconnectedMouthComponents; int censoredMouthComponents; + final Map plannedWetBlockCounts = new TreeMap<>(); final List examples = new ArrayList<>(); void example(String value) { @@ -306,11 +310,13 @@ public final class HydrologyBlockProbe { for (int y = Math.max(0, surface.bedY() + 1); y <= surface.fluidHeadY() && y < volume.height; y++) { evidence.plannedWetVoxels++; NativeBlockState actual = volume.block(x, y, z); + String actualKey = key(actual); + evidence.plannedWetBlockCounts.merge(actualKey, 1L, Long::sum); boolean matches = column.fluid().isWater() ? water(actual) - : key(column.fluid()).equals(key(actual)); + : key(column.fluid()).equals(actualKey); if (!matches) { evidence.missingWetVoxels++; - evidence.example("missing wet voxel " + x + "," + y + "," + z + "=" + key(actual)); + evidence.example("missing wet voxel " + x + "," + y + "," + z + "=" + actualKey); } } } @@ -509,7 +515,7 @@ public final class HydrologyBlockProbe { } private static boolean water(NativeBlockState block) { - return block != null && (block.isWater() || block.isWaterLogged()); + return block != null && (block.isWater() || block.isWaterLogged() || IMPLICIT_WATER_BLOCKS.contains(key(block))); } private static String key(NativeBlockState block) { diff --git a/probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java index 3fe1dbbf2..51a5278e0 100644 --- a/probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java @@ -90,6 +90,54 @@ public final class HydrologyBlockProbeTest { assertEquals(0, inspect(chunks, planned).missingWetVoxels); } + @Test + public void implicitAquaticWaterCountsWhileDryPlantsAndOtherFluidsStillFail() { + Map chunks = chunks(0, 0); + HydrologyColumnLayer layer = layer(HydrologyFeatureType.RIFFLE, 2, 3, true); + Map planned = Map.of( + RiverFootprint.pack(8, 8), new HydrologyBlockProbe.PlannedColumn(8, false, layer, state("water"))); + for (String block : List.of("seagrass", "tall_seagrass[half=lower]", "tall_seagrass[half=upper]", + "kelp[age=3]", "kelp_plant", "bubble_column[drag=false]")) { + set(chunks, 8, 3, 8, block); + HydrologyBlockProbe.Evidence evidence = inspect(chunks, planned); + assertEquals(block, 1, evidence.plannedWetVoxels); + assertEquals(block, 0, evidence.missingWetVoxels); + assertTrue(block, HydrologyBlockProbe.failures(evidence, 8, Set.of("channel")).isEmpty()); + } + for (String block : List.of("short_grass", "tall_grass[half=lower]", "fern", "lily_pad", + "sugar_cane", "dead_bush", "brain_coral[waterlogged=false]")) { + set(chunks, 8, 3, 8, block); + HydrologyBlockProbe.Evidence evidence = inspect(chunks, planned); + assertEquals(block, 1, evidence.missingWetVoxels); + assertFalse(block, HydrologyBlockProbe.failures(evidence, 8, Set.of("channel")).isEmpty()); + } + set(chunks, 8, 3, 8, "kelp_plant"); + Map lava = Map.of( + RiverFootprint.pack(8, 8), new HydrologyBlockProbe.PlannedColumn(8, false, layer, state("lava"))); + HydrologyBlockProbe.Evidence rejected = inspect(chunks, lava); + assertEquals(1, rejected.missingWetVoxels); + assertEquals(Map.of("minecraft:kelp_plant", 1L), rejected.plannedWetBlockCounts); + } + + @Test + public void wetBlockCountsCoverEveryVoxelBeyondTheExampleLimit() { + Map chunks = chunks(0, 0); + Map planned = new HashMap<>(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 4; z++) { + HydrologyColumnLayer layer = layer(HydrologyFeatureType.RIFFLE, 2, 3, true); + planned.put(RiverFootprint.pack(x, z), new HydrologyBlockProbe.PlannedColumn(8, false, layer, state("water"))); + set(chunks, x, 3, z, x < 8 ? "seagrass" : "short_grass"); + } + } + HydrologyBlockProbe.Evidence evidence = inspect(chunks, planned); + + assertEquals(64, evidence.plannedWetVoxels); + assertEquals(32, evidence.missingWetVoxels); + assertEquals(16, evidence.examples.size()); + assertEquals(Map.of("minecraft:seagrass", 32L, "minecraft:short_grass", 32L), evidence.plannedWetBlockCounts); + } + @Test public void mouthRequiresActualCardinalWaterToAnOceanColumn() { Map chunks = chunks(0, 0);